diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..7153456fb502774dae761feef77184c7e8a1131b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.dockerignore +Dockerfile +node_modules +/puter diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..70748d58600a16c1f94e07cb8947c6d3ce9e0ded --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +PORT=4000 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..c6db71967f87f9d8b9079960e29512d50f3aff7b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1,25 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +# Auto detect text files and perform LF normalization +* text=auto +doc/contributors/image.png filter=lfs diff=lfs merge=lfs -text +doc/File[[:space:]]Structure.drawio.png filter=lfs diff=lfs merge=lfs -text +src/backend/doc/assets/puter-backend-map.drawio.png filter=lfs diff=lfs merge=lfs -text +src/backend/src/modules/test-drivers/assets/starry.jpg filter=lfs diff=lfs merge=lfs -text +src/backend/src/modules/test-drivers/assets/wave.jpg filter=lfs diff=lfs merge=lfs -text +src/backend/src/public/assets/img/logo-bold.psd filter=lfs diff=lfs merge=lfs -text +src/backend/src/public/assets/img/logo-margin.png filter=lfs diff=lfs merge=lfs -text +src/backend/src/public/assets/img/logo.psd filter=lfs diff=lfs merge=lfs -text +src/backend/src/public/assets/img/screenshot.png filter=lfs diff=lfs merge=lfs -text +src/dev-center/icon.png filter=lfs diff=lfs merge=lfs -text +src/gui/src/audio/puter_theme_song.mp3 filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-Black.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-Bold.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-ExtraBold.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-ExtraLight.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-Light.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-Medium.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-Regular.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/fonts/Inter-Thin.ttf filter=lfs diff=lfs merge=lfs -text +src/gui/src/images/screenshot.png filter=lfs diff=lfs merge=lfs -text +src/gui/src/images/wallpaper.webp filter=lfs diff=lfs merge=lfs -text +src/puter-js/src/bg.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..8dc72cfe5160383cfd322ba351e616d65313612f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: ['HeyPuter'] diff --git a/.github/workflows/docker-image.yaml b/.github/workflows/docker-image.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c660f79f417d263794274ddbace54c0e556f2f6d --- /dev/null +++ b/.github/workflows/docker-image.yaml @@ -0,0 +1,84 @@ +# +name: Docker Image CI + +# Configures this workflow to run every time a change is pushed to the +# branch called `main`. +on: + push: + tags: + - '*.*.*' + branches: + - 'main' + +# Defines two custom environment variables for the workflow. These are used +# for the Container registry domain, and a name for the Docker image that +# this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the +# latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions + # in this job. + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Uses the `docker/login-action` action to log in to the Container + # registry using the account and password that will publish the packages. + # Once published, the packages are scoped to the account defined here. + - name: Log in to GitHub Package Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) + # to extract tags and labels that will be applied to the specified image. + # The `id` "meta" allows the output of this step to be referenced in + # a subsequent step. The `images` value provides the base name for the + # tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + tags: | + type=semver,pattern={{version}} + type=ref,event=branch + + # This step uses the `docker/build-push-action` action to build the + # image, based on your repository's `Dockerfile`. If the build succeeds, + # it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the + # set of files located in the specified path. For more information, see + # "[Usage](https://github.com/docker/build-push-action#usage)" in the + # README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image + # with the output from the "meta" step. + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64,linux/arm64 + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000000000000000000000000000000000000..2f09a7ec0cb88e861c20303c73cde25d06e4b1cd --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,40 @@ +name: Maintain Release Merge PR + +on: + push: + branches: + - main + +jobs: + update-release-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Git + run: | + git config user.name github-actions + git config user.email github-actions@github.com + - name: Check for existing PR + id: find-pr + uses: juliangruber/find-pull-request-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + branch: release + - uses: actions/checkout@v4 + if: steps.find-pr.outputs.number == '' + with: + ref: release + - name: Reset release branch + if: steps.find-pr.outputs.number == '' + run: | + git fetch origin main:main + git reset --hard main + - name: Create/Update PR + if: steps.find-pr.outputs.number == '' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: Update release branch + title: Merge Release Auto-PR + body: Merging this PR will invoke release actions + branch: auto-update/release diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000000000000000000000000000000000000..c193b9251d3c0f8522a2c1cb257be24c96f7fe2a --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +name: release-please + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: google-github-actions/release-please-action@v4 + with: + release-type: node diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe91084a3eafabf97563ee5b7fb4352c49026454 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,131 @@ +name: test + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + test-backend: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x, 22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Backend Tests (with coverage) + run: | + rm package-lock.json + npm install -g npm@latest + npm install + npm run build + npm run test:backend -- --coverage + + - name: Upload backend coverage report + if: ${{ always() && hashFiles('coverage/**/coverage-summary.json') != '' }} + uses: actions/upload-artifact@v4 + with: + name: backend-coverage-${{ matrix.node-version }} + path: coverage + retention-days: 5 + + - name: Publish backend coverage summary + if: ${{ always() && matrix.node-version == '22.x' }} + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const globModule = require('glob'); + + const matches = globModule.sync('coverage/**/coverage-summary.json', { + cwd: process.cwd(), + ignore: ['**/node_modules/**'], + }); + + if (!matches.length) { + core.warning('Coverage summary not found (expected coverage/**/coverage-summary.json). Did Vitest run with --coverage and include the json-summary reporter?'); + return; + } + + const summaryPath = path.resolve(matches[0]); + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + const metrics = ['lines', 'statements', 'branches', 'functions'] + .map((key) => ({ + label: key.charAt(0).toUpperCase() + key.slice(1), + ...summary.total[key], + })); + + core.summary + .addHeading('Backend coverage') + .addTable([ + ['Metric', 'Covered', 'Total', 'Pct'], + ...metrics.map(({ label, covered, total, pct }) => [ + label, + `${covered}`, + `${total}`, + `${pct}%`, + ]), + ]) + .addRaw('Full HTML report is available in the uploaded `backend-coverage-${{ matrix.node-version }}` artifact.') + .write(); + + api-test: + name: API tests (node env, api-test) + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + matrix: + node-version: [22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: API Test + run: | + pip install -r ./tests/ci/requirements.txt + ./tests/ci/api-test.py + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: (api-test) server-logs + path: /tmp/backend.log + retention-days: 3 + + vitest: + name: puterjs (node env, vitest) + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + matrix: + node-version: [22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Vitest Test + run: | + pip install -r ./tests/ci/requirements.txt + ./tests/ci/vitest.py diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..5e9f0c1f7d89384330e68f7198f379bbdee6e85f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "submodules/v86"] + path = submodules/v86 + url = git@github.com:HeyPuter/v86.git +[submodule "submodules/twisp"] + path = submodules/twisp + url = git@github.com:MercuryWorkshop/twisp.git +[submodule "submodules/epoxy-tls"] + path = submodules/epoxy-tls + url = git@github.com:MercuryWorkshop/epoxy-tls.git +[submodule "submodules/wiki"] + path = submodules/wiki + url = https://github.com/HeyPuter/puter.wiki.git diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000000000000000000000000000000000..c1122febeb95611ef6393ad38f332e62307d6ce8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +node tools/validate-eslint.js \ No newline at end of file diff --git a/.idx/dev.nix b/.idx/dev.nix new file mode 100644 index 0000000000000000000000000000000000000000..db2885ab33e0198e2e9e408330ce01d74dc0b8b0 --- /dev/null +++ b/.idx/dev.nix @@ -0,0 +1,57 @@ +# To learn more about how to use Nix to configure your environment +# see: https://developers.google.com/idx/guides/customize-idx-env +{ pkgs, ... }: { + # Which nixpkgs channel to use. + channel = "stable-25.05"; # or "unstable" + + # Use https://search.nixos.org/packages to find packages + packages = [ + pkgs.python3 + pkgs.nodejs_24 + ]; + + # Sets environment variables in the workspace + env = {}; + idx = { + # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" + extensions = [ + # "vscodevim.vim" + ]; + + # Enable previews and customize configuration + previews = { + # Currently disabled because the preview system wasn't working + enable = false; + previews = { + web = { + command = [ + "npm" + "run" + "start" + "--" + "--port" + "$PORT" + "--host" + "0.0.0.0" + "--disable-host-check" + ]; + manager = "web"; + }; + }; + }; + + # Workspace lifecycle hooks + workspace = { + # Runs when a workspace is first created + onCreate = { + # npm-install = "npm install"; + # Currently disabled because the preview system wasn't working + }; + # Runs when the workspace is (re)started + onStart = { + # npm-install = "npm install"; + # Currently disabled because the preview system wasn't working + }; + }; + }; +} diff --git a/.idx/icon.png b/.idx/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ce744ea7a697a9340c67525cfa7d3c480e7e3544 Binary files /dev/null and b/.idx/icon.png differ diff --git a/.is_puter_repository b/.is_puter_repository new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..4fd021952d5a1d74ce03afc79643268a0480adfb --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..b45d476c5df6ce939bd88fd70ae7c17626dc50c9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +node_modules +dist +build \ No newline at end of file diff --git a/BUG-BOUNTY.md b/BUG-BOUNTY.md new file mode 100644 index 0000000000000000000000000000000000000000..cedf9a9dc6a708c980ee8e6e94d1618a2e309499 --- /dev/null +++ b/BUG-BOUNTY.md @@ -0,0 +1,66 @@ +# Puter Bug Bounty Program + +We at **Puter** are committed to maintaining a secure experience for our users and community. We greatly value the contributions of security researchers and welcome responsible disclosure of security issues. + +## Scope + +The following are in scope for this program: + +* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter)) +* **`puter.com`** +* **`api.puter.com`** + +Out-of-scope: + +* Third-party services, applications, or libraries not maintained by Puter. +* Social engineering attacks (e.g., phishing against staff). +* Denial of Service (DoS), spam, or volumetric attacks. +* Physical security issues. + +## Rules of Engagement + +To participate, you must: + +1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code or screenshots where applicable. +2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data. +3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service. +4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue. +5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services. + +Reports that do not meet these guidelines may not be eligible for a reward. + +## Reporting Process + +To report a vulnerability, email us at: **[security@puter.com](mailto:security@puter.com)**. +Include: + +* A description of the vulnerability +* Steps to reproduce +* Potential impact +* Suggested remediation (if available) + +We aim to acknowledge receipt within **72 hours** and provide a resolution timeline. + +## Reward Structure + +We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide). + +* **Critical: \$1,000 – \$2,000** +* **High: \$500 – \$1,000** +* **Medium: \$200 – \$500** +* **Low: \$50 – \$100** + +Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward. +If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive. + +## Payments Disclaimer + +All reward amounts are **guidelines only**. Final decisions about eligibility, severity classification, and payout amount are made at the sole discretion of the Puter security team. We reserve the right to determine whether a report qualifies for a bounty, and whether any payment will be issued at all. Submitting a report does not guarantee compensation. + +### Payment Method Requirement + +At this time, **payments will only be made via PayPal**. To be eligible to receive a bounty, researchers must have a valid PayPal account capable of receiving payments. We are unable to process payments through other services or methods at this time. + +## Legal Safe Harbor + +If you make a good-faith effort to comply with this policy, we will consider your research to be authorized. If you inadvertently access data outside your own account, stop immediately and include details in your report so we can investigate and remediate. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..eeb5701dc803e432761e602e75df26e6439e6929 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,726 @@ +# Changelog + +## v2.5.1 (2025-02-13) + +### Puter + +#### Bug Fixes + +- phoenix changelog ([0bcbc8f](https://github.com/HeyPuter/puter/commit/0bcbc8f7845de99305f53c6da2bb1f365b87ac50)) +- update package.json ([c2c5d88](https://github.com/HeyPuter/puter/commit/c2c5d883365ae33749709d11e0c2de9050ca144e)) +- oops, no export (putility.libs.event) ([fa4b38c](https://github.com/HeyPuter/puter/commit/fa4b38cd028be4b19ec98bcf588227e0fc92af9d)) +- broken test in putility ([a803d55](https://github.com/HeyPuter/puter/commit/a803d55cfbdd5b15e7fe48df3f4363c1658f0930)) +- parse body before auth for /down ([70fde95](https://github.com/HeyPuter/puter/commit/70fde95255532a7fe0d99c64a4efb1ae625776a4)) +- fix previous fix ([e5c3769](https://github.com/HeyPuter/puter/commit/e5c3769bd813b1510dd0429e1e4eca8e277af7c7)) +- potential fix for /down auth ([390230c](https://github.com/HeyPuter/puter/commit/390230c5a07b1774f84a1b3505f7531ce81dc2cc)) +- allow command provider to not implement complete method ([2000b89](https://github.com/HeyPuter/puter/commit/2000b8909f08d91147b86fce22fe006e0c3152d2)) +- unfixed fix from earlier ([e6fc773](https://github.com/HeyPuter/puter/commit/e6fc7737066d09509f0c7b38e4c51f25e86e12d0)) +- parser error for empty json buffer ([484bb5c](https://github.com/HeyPuter/puter/commit/484bb5c201e17bf45e1a1d97b1e9b2d61d6087dc)) +- fix name and id for openai tool calls ([d2358d2](https://github.com/HeyPuter/puter/commit/d2358d234b45d719a2cc4e92582ed89d2d1832ab)) +- let messages with tool_calls have content=null ([29c0241](https://github.com/HeyPuter/puter/commit/29c024111943267b741b1b4a8933e1ea1a35a65e)) +- repair stream end ([8f27742](https://github.com/HeyPuter/puter/commit/8f277420380e9c6fa8a9925a3e9651f48b8734e6)) +- add type=text ([e2797c3](https://github.com/HeyPuter/puter/commit/e2797c38d0754930033780d5270cc64cbba2c94e)) +- various issues with Mail module ([55d052c](https://github.com/HeyPuter/puter/commit/55d052cfc2549bfdf72f3a8b27cdc7dc4294bc54)) +- buffer incomplete JSON objects from AI stream ([60eef2f](https://github.com/HeyPuter/puter/commit/60eef2fc6734f88df06e2f85db9b9368cc8c227f)) +- mistake in 0c42613 ([8ffd000](https://github.com/HeyPuter/puter/commit/8ffd0004b3b7b34cd6a9c43c6ca960c7a1cbbe15)) +- fix microcents to USD conversion in AIChatService ([dcd47bc](https://github.com/HeyPuter/puter/commit/dcd47bc4cfc5f8a67ea86e0485d08c2417f899ed)) +- claude duplicate messages in stream ([0fac03a](https://github.com/HeyPuter/puter/commit/0fac03a05a4f597f7ed531651c830e44012b646b)) +- skip request-count usage check via AIChatService ([6083e3a](https://github.com/HeyPuter/puter/commit/6083e3ac52fcde7f598c838bc49085e6b3de7162)) +- remove log from InternetModule ([c7f3e0b](https://github.com/HeyPuter/puter/commit/c7f3e0b937f5d72d6f30dba25d7c351e2e14f289)) +- small workaround for duplicate close ([06452f5](https://github.com/HeyPuter/puter/commit/06452f5283085b18266ee7fb89136b9c23879243)) +- race condition and buffer issue in puter.http ([36dc966](https://github.com/HeyPuter/puter/commit/36dc9664ad5520b21c07a1b5c85c8aff7cbe423b)) +- missing some buffer contents in no-keepalive ([3f5b34c](https://github.com/HeyPuter/puter/commit/3f5b34cd341b9063d01baba72e708a9ebb16485b)) +- new edge cases with function calls / tools ([9cbb741](https://github.com/HeyPuter/puter/commit/9cbb741a8ae8ea6b869b6ccf64cd3152b28c2b8c)) +- oops, we're passing negative values; let's just remove this ([cf7aa27](https://github.com/HeyPuter/puter/commit/cf7aa27543700d6268ee709f127e73f7cfe12a5a)) +- oops we still need that ([61824ea](https://github.com/HeyPuter/puter/commit/61824ea04b0cb7611d2acdf45e0a1ecc2856901a)) +- remove hard-coded token limit for OpenAI ([8143e57](https://github.com/HeyPuter/puter/commit/8143e5700f53279a5a18d21b7c5466f3b9bb6ce6)) +- wisp relay authentication ([6f39365](https://github.com/HeyPuter/puter/commit/6f39365b24cda53a6cac7e203b9d8cbc09bb0ba3)) +- reduce code paths for querystrings ([e8f5450](https://github.com/HeyPuter/puter/commit/e8f5450cb05213c3c06802442103f5c414eee5cc)) +- icons ([d03952b](https://github.com/HeyPuter/puter/commit/d03952b23712ae8a61c7f2c7582d297691e0ecc1)) +- subdomains to deleted files tried to deref fs node ([38ccc82](https://github.com/HeyPuter/puter/commit/38ccc82c8e95636ee4b7c5ca2f761098f12affa2)) +- app icon empty string should be skipped ([37ca892](https://github.com/HeyPuter/puter/commit/37ca89228cc2f978602098ee4aae1ecb3d333526)) +- save_account case for disable_user_signup ([766c235](https://github.com/HeyPuter/puter/commit/766c235cc738051588a67ff5ab4230e76b64173c)) +- use .get() for Map lookup. fix: correctly set url and url_paths. fix: null check to throw error. ([78ac033](https://github.com/HeyPuter/puter/commit/78ac033a1ca4f51b71c2bcb185b305903f7be495)) +- ensure puter.signup emit resolves ([113ed31](https://github.com/HeyPuter/puter/commit/113ed31336c494a3f7a9e744a34de35b3785c033)) +- --onlycase param broke cartesian tests ([d9822a4](https://github.com/HeyPuter/puter/commit/d9822a4f09e3e0c5fbed8c655435f534af949290)) +- empty response when mkdir is a no-op ([f359ae1](https://github.com/HeyPuter/puter/commit/f359ae193e87552b3a2e2aafa3fda389478fca38)) +- mkdir with create_missing when some parents exist ([807c3ba](https://github.com/HeyPuter/puter/commit/807c3ba5eca02f69b5e6ce547420312b68c7993f)) +- possible out-or-order response objects from batch ([fb70251](https://github.com/HeyPuter/puter/commit/fb7025164e3f42cae1365ec65960019b24f4360d)) +- app data check error in write ([5ef75e5](https://github.com/HeyPuter/puter/commit/5ef75e5df35ae95242da97235512495b7585bd0d)) +- missing parent dirs created in move ([9d9d97f](https://github.com/HeyPuter/puter/commit/9d9d97fd0074058506b0506d5027b0c6b8a26845)) +- missing changes to run-selfhosted.js ([6f4b1bf](https://github.com/HeyPuter/puter/commit/6f4b1bf94a031b3324f5ecd51557b1298a1c3175)) +- appease mocha's import requirements ([d6bbba7](https://github.com/HeyPuter/puter/commit/d6bbba7bf064991d59fbfe74db5221e0118a781c)) +- error msg for invalid puter-ocr urls ([6a6bfa0](https://github.com/HeyPuter/puter/commit/6a6bfa034fe16dba7172ab5adbf23f00df38301d)) +- improper 500 in wisp token verify ([75aaaa6](https://github.com/HeyPuter/puter/commit/75aaaa66a8c7df00e1fb80c353d890269296839c)) +- actor param in legacy /write ([7aa886d](https://github.com/HeyPuter/puter/commit/7aa886d573362e6739bd99bbed02f4831557ccb4)) +- new desktop height calculation when resizing browser window ([a295420](https://github.com/HeyPuter/puter/commit/a295420f58326b04c976cf92bd2d582d2eafa71b)) +- circular imports ([8fabf01](https://github.com/HeyPuter/puter/commit/8fabf014a9eb783183e87489ae2b6c6bbc42c99a)) +- test and improve boolify ([44ad3c5](https://github.com/HeyPuter/puter/commit/44ad3c578106d2b01007240188db57760c15af96)) +- skip test files in mod lib loading ([f60c008](https://github.com/HeyPuter/puter/commit/f60c008158127458e02e3bb92287617d9f1f9514)) +- shortcut issue ([6d196d5](https://github.com/HeyPuter/puter/commit/6d196d59f026bec4acb0296d8f0f38c7cee2e8c2)) +- test for get-launch-apps ([740fdb5](https://github.com/HeyPuter/puter/commit/740fdb592e494bf5b197493774cef6559bfb50b9)) +- add package-lock.json ([3097b86](https://github.com/HeyPuter/puter/commit/3097b86597218de9e59b450b70185634a94be210)) +- try redundant npm install after build stage ([8963eb0](https://github.com/HeyPuter/puter/commit/8963eb0c4f1220dd515ac6ed7a2a8f1de26655ae)) +- I'ma buy GitHub a coffee and spill it on their servers if this works ([686d3de](https://github.com/HeyPuter/puter/commit/686d3de518e6e090d683294ad3dd856db26856a0)) +- oh, right; there's two of them ([a13af7e](https://github.com/HeyPuter/puter/commit/a13af7e31aa4cd36457a90a7d75878b6d39ba73b)) + +## v2.5.0 (2025-01-07) + +### Puter + +#### Features + +- hash-based distributed cache inval ([d386096](https://github.com/HeyPuter/puter/commit/d38609646793a5a14b8af96964fc7176725a0531)) +- add Escape key functionality to UIPrompt for closing the prompt ([e1b6c83](https://github.com/HeyPuter/puter/commit/e1b6c83813d03809aba0abdecbf6de5529728031)) +- set max token to 8096 ([b2ea8a3](https://github.com/HeyPuter/puter/commit/b2ea8a3888c5496858d257018071ba54abd6f4a8)) +- added tagify in Filetype-Association input in dev center ([0cd1f15](https://github.com/HeyPuter/puter/commit/0cd1f151b5986ede431f1792139fa1a5471ae059)) +- add reset edit changes button to dev-center ([55ffd80](https://github.com/HeyPuter/puter/commit/55ffd801e007723758eacc17ec732ee5a336123e)) +- enable/disable save button in dev-center iff changes made ([63a0053](https://github.com/HeyPuter/puter/commit/63a0053da8c76bf4ac175c7f17353225443dd342)) +- record signup metadata for abuse prevention ([66016b9](https://github.com/HeyPuter/puter/commit/66016b9db602ca85e8f0ddc846865d4641e64190)) +- add support for categories in the Dev Center ([7cf215a](https://github.com/HeyPuter/puter/commit/7cf215ab677e3fc912a3bd1ac52795c1e8860c32)) +- puter.js's showSpinner() will keep the spinner active for at least 1200ms ([fc5aca1](https://github.com/HeyPuter/puter/commit/fc5aca1f72de22c1530054272b55a59021ba9caa)) +- allow developers to set social media images for their apps ([be36d31](https://github.com/HeyPuter/puter/commit/be36d31509280340e2a62a8c478b1e64617792a4)) +- automatically open the browser when starting Puter ([2d43129](https://github.com/HeyPuter/puter/commit/2d4312972a1377a64732694811fe889f59573432)) +- spinner for the `showWorking()` overlay in puter.js ([1062363](https://github.com/HeyPuter/puter/commit/1062363096418f164a6d00ed8872770ff64237b5)) +- show profile pics in sharing notifications ([0e45132](https://github.com/HeyPuter/puter/commit/0e45132c05aa1106503fef02b7e4c97ecc675e10)) +- Implement profile pictures ([0885937](https://github.com/HeyPuter/puter/commit/0885937f033caf35503eeb9e65bb390952992faf)) +- allow `launchApp` to open explorer at a specific path ([8fefd4a](https://github.com/HeyPuter/puter/commit/8fefd4a61f0005d4f3ec2e43f7249f3edd91c837)) +- Require email confirmation before sharing ([cdd1a8c](https://github.com/HeyPuter/puter/commit/cdd1a8c4e379b885ff48a874ae5577d2f0efae06)) +- show unread notification count in the browser tab's title ([045259c](https://github.com/HeyPuter/puter/commit/045259cefbe24e3f52fe3840e4975d3243e99957)) +- in Share window, display access level next to recipient ([cf4b6aa](https://github.com/HeyPuter/puter/commit/cf4b6aa1c24d936f9a42ca1e2945eea40939c970)) +- when sharing, users can choose between 'viewer' and 'editor' for permissions ([0cbe013](https://github.com/HeyPuter/puter/commit/0cbe0139d7f306ce62992f1eda94d99e09b32df8)) +- handle `notif.ack` in desktop ([a6650ee](https://github.com/HeyPuter/puter/commit/a6650ee2d8074aeb7c476e5572334853f1b6d7e8)) +- add error handling to the share flow ([b5bb95e](https://github.com/HeyPuter/puter/commit/b5bb95e2d7f6021a6341e26cf15d5449ada48830)) +- search ([55d2af1](https://github.com/HeyPuter/puter/commit/55d2af189e9479fb5980ce149ce74e890b325014)) +- search endpoint ([b589512](https://github.com/HeyPuter/puter/commit/b589512c9dedec22fd41b92cbba2570042149873)) +- the `socialLink` UI component ([1adfe5c](https://github.com/HeyPuter/puter/commit/1adfe5c70947d9de008c9d601f91b1ee14128d5d)) +- Reaload App option in the window title bar context menu ([27c01c9](https://github.com/HeyPuter/puter/commit/27c01c9bd991ef871153eb5931f78fec265a62e4)) +- add puter.auth.whoami() ([da0022a](https://github.com/HeyPuter/puter/commit/da0022abf0f880c7b52d2cd937ef9d1298fc09cc)) +- add puter.log ([755736e](https://github.com/HeyPuter/puter/commit/755736edee9baa783be9b7d96083d908a2f2f750)) +- collapsible sidebar menu in Dev Center ([1056231](https://github.com/HeyPuter/puter/commit/1056231004a629f3f76f2525ec7d83b67d3d7fa5)) +- customize the order of Explorer sidebar items ([ff30de1](https://github.com/HeyPuter/puter/commit/ff30de1d6947e4692b5cf0da2e19ab37aacf1ec8)) +- add extension API for modules ([14d45a2](https://github.com/HeyPuter/puter/commit/14d45a27edb99f63b4f6e010221e3a0880ae246d)) +- first extension that implements a custom user options menu ([fc5e15f](https://github.com/HeyPuter/puter/commit/fc5e15f2a6d4eb5e5847fa7f2dd87b1fa382fc7c)) +- add support for extensions ([b018571](https://github.com/HeyPuter/puter/commit/b018571a86f4114eab9b5edde4ecd87e343d22a7)) +- add an 'Upload' button at the bottom of `OpenFilePicker` ([54ae69b](https://github.com/HeyPuter/puter/commit/54ae69b7b76016307c3b92437ca06dc2aa1eddb9)) +- Allow apps to toggle `credentialless` via Dev Center ([af511c0](https://github.com/HeyPuter/puter/commit/af511c05e3ddddcce661c5406d5c831a21689608)) +- add config for blocked email domains ([955b087](https://github.com/HeyPuter/puter/commit/955b087297f829b11b82dc9bd79a0e03721c5f33)) +- add support for `fadeIn` effect for `UIWindow` ([13248a9](https://github.com/HeyPuter/puter/commit/13248a99bfa318e84cb99e2954a5f46805eda34f)) +- welcome screen to quickly explain what Puter is ([564ff65](https://github.com/HeyPuter/puter/commit/564ff65363258cab4196b967dd556105e424d48c)) +- v86 9p server support ([b145e30](https://github.com/HeyPuter/puter/commit/b145e30a90ff2f0d44d89f83dbda4de1bf2991d4)) +- support readdir for directory symlinks ([7f1b870](https://github.com/HeyPuter/puter/commit/7f1b870d302421972c4f6221ae6d93b5979d51dd)) +- allow passing cli args via url ([5317adf](https://github.com/HeyPuter/puter/commit/5317adf8a4961be3f0ca2a8c403c922633f934fa)) +- add -c flag for phoenix ([b6c0cb6](https://github.com/HeyPuter/puter/commit/b6c0cb6abc1c29846b4b7e696812476bea24bbc7)) +- translate README.md to Dutch ([31e2773](https://github.com/HeyPuter/puter/commit/31e2773743c336630c917e893b0148441f5fc515)) +- add connectToInstance method to puter.ui ([62634b0](https://github.com/HeyPuter/puter/commit/62634b0afe4d33da08768975322d4deb23041442)) +- add method to list models ([fd86934](https://github.com/HeyPuter/puter/commit/fd86934bc9021541810447cf7e2a5f33b3e283b3)) +- add streaming to XHR driver client ([7600d9b](https://github.com/HeyPuter/puter/commit/7600d9b07c5b719d529f8a48c38d9178efefa266)) +- add writable attribute to fs items ([2386d87](https://github.com/HeyPuter/puter/commit/2386d87229aa6205ef8ced6563371ab40a0def62)) +- report feature flags in /whoami ([4561b89](https://github.com/HeyPuter/puter/commit/4561b8937de025471c2dfb1771465d779cefab5d)) +- make public folders a config opt-in ([209555c](https://github.com/HeyPuter/puter/commit/209555c1d93845fa129bea450f9c25d595a3c60f)) +- add feature flag for /share ([461ea3e](https://github.com/HeyPuter/puter/commit/461ea3eae6ad32bf34c43a822de7a06f08efb556)) +- add message encryption between Puter peers ([cea2964](https://github.com/HeyPuter/puter/commit/cea29645fec493020a4f66e378b087fa17ae03d4)) +- add test_mode flag ([9a9bd5e](https://github.com/HeyPuter/puter/commit/9a9bd5eaf0aca8fd1cc57455db03dba55801d5a0)) +- add tts driver to puterai module ([78fa77d](https://github.com/HeyPuter/puter/commit/78fa77d9200e0b9fafc4014f8d0cb08c74cd16cb)) +- add image generation driver to puterai module ([fb26fdb](https://github.com/HeyPuter/puter/commit/fb26fdbc561d5545d28352427553695cd3237ad5)) +- add chat completions driver to puterai module ([4e3bd18](https://github.com/HeyPuter/puter/commit/4e3bd1831e92e83ce9b4e30a16afd562b0221dd8)) +- add --overwrite-config and configurable uuid masking ([ef6671d](https://github.com/HeyPuter/puter/commit/ef6671da18f6841cb2143808fe21586ac3505942)) +- add textract driver to puterai module ([f924d48](https://github.com/HeyPuter/puter/commit/f924d48b02f39884931db45a05dd61b65f2cee4a)) +- add password reset from server console ([984ae9e](https://github.com/HeyPuter/puter/commit/984ae9e6a23da17414e43d58fc0e861827031269)) +- add server command to scan permissions ([54471fa](https://github.com/HeyPuter/puter/commit/54471fada946a70eaa0df6bfceae995bc4e5848c)) +- grant user driver perms from admin ([c9ded89](https://github.com/HeyPuter/puter/commit/c9ded89b22bb822c20aea379a17a8bdf74a658de)) +- replace default_user with admin ([f0c36a1](https://github.com/HeyPuter/puter/commit/f0c36a1cdf16f11765c29360a5c38140008b90c7)) +- add system user ([ab15629](https://github.com/HeyPuter/puter/commit/ab156297a746c0754145c2abdb2c99bb1b30651a)) +- add options to disable winston and devwatch ([5d5f566](https://github.com/HeyPuter/puter/commit/5d5f5660b4020650b68b79ccf3860d3fb0bf98a9)) +- add new file templates ([1f7f094](https://github.com/HeyPuter/puter/commit/1f7f094282fae915a2436701cfb756444cd3f781)) +- add cross_origin_isolation option ([e539932](https://github.com/HeyPuter/puter/commit/e53993207077aecd2c01712519251993bb2562bc)) +- add option to disable temporary users ([f9333b3](https://github.com/HeyPuter/puter/commit/f9333b3d1e05bd0dffaecd2e29afd08ea61559fc)) +- add some default groups ([ba50d0f](https://github.com/HeyPuter/puter/commit/ba50d0f96d58075abec067d24e6532bd874093f0)) +- Add support for dropping multiple Puter items onto Dev Center (close #311) ([8e7306c](https://github.com/HeyPuter/puter/commit/8e7306c23be01ee6c31cdb4c99f2fb1f71a2247f)) + +#### Translations + + +- complete Hungarian translation of Puter #972 ([7d2787d](https://github.com/HeyPuter/puter/commit/7d2787d26b3a64cbc128fb2cb3871b43b41912fe)) +- add missing Igbo translations for billing-related terms ([f0f19e7](https://github.com/HeyPuter/puter/commit/f0f19e727e574a8558fcbbf27ba501f434db69f8)) +- Complete the Vietnamese translation of Puter #954 ([56489c3](https://github.com/HeyPuter/puter/commit/56489c33f611fc053096b455e4cb7b3d8f20852c)) +- Complete the French (Français) translation of Puter #975 ([c840bc8](https://github.com/HeyPuter/puter/commit/c840bc8161055b90e040bdae3196817e0791ecf5)) +- Complete the German (Deutsch) translation of Puter ([05fef67](https://github.com/HeyPuter/puter/commit/05fef6749e8d80f13ab94a4e0ea49ce4972a0961)) +- (#954) Add Vietnamese translations for billing-related terms ([267a55a](https://github.com/HeyPuter/puter/commit/267a55aae50f87edb483abb375029ff79e736112)) +- add vietnamese translations for billing in vi.js ([3e26dbe](https://github.com/HeyPuter/puter/commit/3e26dbe6a0411fe75c36cf2866d34f28a2dcb553)) +- added a few Korean translatations ([b23e800](https://github.com/HeyPuter/puter/commit/b23e800f4e70f162b52cc15053d03961a37033bb)) +- add brazillian translations for billing-related terms in br.js (revision) ([fdfc90a](https://github.com/HeyPuter/puter/commit/fdfc90a9317a19d45a0b2b3ad283be9a10a92732)) +- add brazillian translations for billing-related terms in br.js ([e66df14](https://github.com/HeyPuter/puter/commit/e66df14862e6dd7278623279e43e2189e7ddafe5)) +- Add Indonesian Translation for i18n ([033643b](https://github.com/HeyPuter/puter/commit/033643b0e757b51ea0be90e2198bbec65d31cfc5)) +- add Polish translations for billing-related terms ([15f9ade](https://github.com/HeyPuter/puter/commit/15f9aded26eaa4c630fe948350d3a53cdb0278a3)) +- update Urdu localization with missing translations ([0c4b994](https://github.com/HeyPuter/puter/commit/0c4b9946442ad92549522fcd91ea6aefbb9f19d6)) +- Update ig.js ([382fb24](https://github.com/HeyPuter/puter/commit/382fb24dbb1737a8a54ed2491f80b2e2276cde61)) +- feat: add vietnamese localization-a ([c2d3d69](https://github.com/HeyPuter/puter/commit/c2d3d69dbe33f36fcae13bcbc8e2a31a86025af9)) +- Update zhtw.js, Complete Traditional Chinese translation based on English file #550 ([b9e73b7](https://github.com/HeyPuter/puter/commit/b9e73b7288aebb14e6bbf1915743e9157fc950b1)) +- update zhtw.js to match en.js ([37fd666](https://github.com/HeyPuter/puter/commit/37fd666a9a6788d5f0c59311499f29896b48bc82)) +- Add Tamil translation to translations.js ([8a3d043](https://github.com/HeyPuter/puter/commit/8a3d0430f39f872b8a460c344cce652c340b700b)) +- Move Tamil translation to the rest of translations ([333d6e3](https://github.com/HeyPuter/puter/commit/333d6e3b651e460caca04a896cbc8c175555b79b)) +- Translation improvements, mainly style and context-based ([8bece96](https://github.com/HeyPuter/puter/commit/8bece96f6224a060d5b408e08c58865fadb8b79c)) +- update translation file es.js to be up to date with the file en.js ([1515278](https://github.com/HeyPuter/puter/commit/151527825f1eb4b060aaf97feb7d18af4fcddbf2)) +- Translate en.js as of 2024-07-10 ([8e297cd](https://github.com/HeyPuter/puter/commit/8e297cd7e30757073e2f96593c363a273b639466)) +- Create hu.js hungarian language ([69a80ab](https://github.com/HeyPuter/puter/commit/69a80ab3d2c94ee43d96021c3bcbdab04a4b5dc6)) +- Update translations.js to Hungarian lang ([56820cf](https://github.com/HeyPuter/puter/commit/56820cf6ee56ff810a6b495a281ccbb2e7f9d8fb)) +- Tamil translation ([81781f8](https://github.com/HeyPuter/puter/commit/81781f80afc07cd1e6278906cdc68c8092fbfedf)) +- Update it.js ([84e31ef](https://github.com/HeyPuter/puter/commit/84e31eff2f58584d8fab7dd10606f2f6ced933a2)) +- Update Armenian translation file ([3b8af7c](https://github.com/HeyPuter/puter/commit/3b8af7cc5c1be8ed67be827360bbfe0f0b5027e9)) +- correct Igbo translation for "Free" in billing terms ([6f4d57a](https://github.com/HeyPuter/puter/commit/6f4d57a3c6da607038f4fbe49c691478f47933be)) + +#### Bug Fixes + +- missing ll_copy import ([8a9164d](https://github.com/HeyPuter/puter/commit/8a9164d7c5380aafb864b56ca1a3ee59f24daf38)) +- bad uuid reference to resourceService ([13003c4](https://github.com/HeyPuter/puter/commit/13003c486fbebad0f26dd1b569f5fd5f2cefc9e7)) +- allow localhost for development ([ad8a397](https://github.com/HeyPuter/puter/commit/ad8a3978c07e44f7a534981ddd65bc131c9aac6b)) +- rewrite confusing log message ([dacbbf0](https://github.com/HeyPuter/puter/commit/dacbbf033dcc0f4506198761eab3bfb6ef915336)) +- AppInformationService initialization ([2332602](https://github.com/HeyPuter/puter/commit/233260233c4e52399541aedbf8b13800de80d3fd)) +- dev center app icon SVG issue ([47a4313](https://github.com/HeyPuter/puter/commit/47a4313d92152b9e5b4036715ac4f19431be8940)) +- app icon double-encode bug ([23eab63](https://github.com/HeyPuter/puter/commit/23eab63776a146a78b10e973518158fc07b13653)) +- first read of recommended apps ([a6b9d33](https://github.com/HeyPuter/puter/commit/a6b9d33d27909ead3d14eff4446062d62aad4651)) +- prefix peer addresses with protocol ([efd4730](https://github.com/HeyPuter/puter/commit/efd4730f757471c3eac2d5e396dd69b619ad2999)) +- clone message object ([728ecbf](https://github.com/HeyPuter/puter/commit/728ecbfb033082186ca9480f2ab2d1607b57ca5a)) +- timing for PrefixLogger call to /whoami ([2dc6c47](https://github.com/HeyPuter/puter/commit/2dc6c4737b9ec9db281b4b32ed4bd20ac490e47d)) +- try catching icon read errors before stream ([e56a62c](https://github.com/HeyPuter/puter/commit/e56a62c5390958e585f299751bafd13becc1c9b6)) +- try catching on stream_to_buffer ([ada051b](https://github.com/HeyPuter/puter/commit/ada051b9b87e945b4a80c1fae99b8c5644b82dc0)) +- check if row.timestamp is Date ([5d049e8](https://github.com/HeyPuter/puter/commit/5d049e8f06dafe2e499ccfea66ef013a9b595396)) +- AppES PD alert ([f14e1fe](https://github.com/HeyPuter/puter/commit/f14e1fefcf18438bd59eb86d625b8c5a6fb3ffc5)) +- fix for previous fix ([648d6e0](https://github.com/HeyPuter/puter/commit/648d6e036d6f8040a1e440c1e76dc9dcc746156f)) +- fix fallback icon behavior in get_icon_stream ([4f3a161](https://github.com/HeyPuter/puter/commit/4f3a1618b10dd393f5c94c0967beb228a593b214)) +- revert test change ([9c86614](https://github.com/HeyPuter/puter/commit/9c86614df5d58ca0385450e1edb5adb5b6d72300)) +- acl check for subdomain on access ([c69006e](https://github.com/HeyPuter/puter/commit/c69006e1852befa93f94a7c45651025214941a4e)) +- attempt fix for prod issue with app icons ([925ebd5](https://github.com/HeyPuter/puter/commit/925ebd531013e36ee5c05d53ef229d314fb89435)) +- remove redundant notification query ([f87769b](https://github.com/HeyPuter/puter/commit/f87769b445d53e6322a55a788e26d38629299ae9)) +- share only emails email_confirmed recipients ([2336a62](https://github.com/HeyPuter/puter/commit/2336a62b4f635c025b02bb7efe91b5ddf58bae25)) +- database issue with KBKV update ([7ba1b76](https://github.com/HeyPuter/puter/commit/7ba1b7656b5e24375cad639b9a8e37577b526c09)) +- taskbar items of apps should always appear before Trash ([94e7f5d](https://github.com/HeyPuter/puter/commit/94e7f5deb4330a844a680c22f55b8753225a1a7e)) +- fullpage mode ([65d9188](https://github.com/HeyPuter/puter/commit/65d918866ea0ee981bc26151332b730abccb7be8)) +- bug in writeFile rename ([298609c](https://github.com/HeyPuter/puter/commit/298609c6e9080e00c90b66c673e104d90f9d3ed0)) +- remove unnecessary `item_path` definition in `delete` fs api ([c792f4a](https://github.com/HeyPuter/puter/commit/c792f4a345b307d024f73ff2817ae473b2620913)) +- add missing permissions ([69e9df1](https://github.com/HeyPuter/puter/commit/69e9df1ae21cf906dfcc3d9d7a23455e5274271c)) +- logic from previous commit ([6ca7011](https://github.com/HeyPuter/puter/commit/6ca701139a07a0d20071cf1532cc6e95639a01da)) +- add fallback moderation in case openai goes down ([c6e814d](https://github.com/HeyPuter/puter/commit/c6e814daa80eec01c10f319ebebcb84c42cd26e1)) +- permission strings for ES services ([4d9cc9b](https://github.com/HeyPuter/puter/commit/4d9cc9bd830d0c73024f2bc5a91ab226aedefded)) +- resolve issue #983 - Stuck on Creating new app loading screen ([c75c9d0](https://github.com/HeyPuter/puter/commit/c75c9d03833af52730cac89a8fee5f5c317f0f78)) +- provide actor context to ws event ([1b57801](https://github.com/HeyPuter/puter/commit/1b578019f915918e51185f5705d7fa6e0328b9ae)) +- context error in user connected event ([9600823](https://github.com/HeyPuter/puter/commit/96008233ba4935e789cd092c07aa8b351cb44d45)) +- signup 500 for temp user ([01395f3](https://github.com/HeyPuter/puter/commit/01395f302e763cdad022c0e5a995869fcd805d86)) +- bad import for TeePromise ([acf8ae3](https://github.com/HeyPuter/puter/commit/acf8ae302ec4ee79c11c2b0e810edd53f21446c5)) +- sorting bug in AIChatService ([7acb096](https://github.com/HeyPuter/puter/commit/7acb096addd58113cc8d4338ba941cd14ac81f4f)) +- test issues from contextlink removal ([545e7db](https://github.com/HeyPuter/puter/commit/545e7db5bdac6e39962390469767667bc62857fd)) +- add missing import ([e279dc6](https://github.com/HeyPuter/puter/commit/e279dc6e5f4095550f41aadd194ea94e1e2a2271)) +- fake_chat default model and usage errors ([13a895b](https://github.com/HeyPuter/puter/commit/13a895b76b1e5a677c2eeeb0a07be6ce9fd02a99)) +- update test kernel ([a1c2226](https://github.com/HeyPuter/puter/commit/a1c2226561655e091cbc0d014ada62bfc7881f2a)) +- correct AI comment faults ([b40d453](https://github.com/HeyPuter/puter/commit/b40d4534a71565a7f2d0ae278c98d7326c5aa963)) +- update package-lock.json ([8577185](https://github.com/HeyPuter/puter/commit/857718538b8a7bf27dc036f4eeb3728cb6ea96e7)) +- ignore two calls with undefined origin ([ab4ba76](https://github.com/HeyPuter/puter/commit/ab4ba76433ac623abaa17c0e5dd024e95b9fef3f)) +- undefined APIOrigin ([340c7a8](https://github.com/HeyPuter/puter/commit/340c7a821fb91e2d106c2b3febf8182de7b21f7d)) +- add id to the setting menu item in user option menu ([67ca4cc](https://github.com/HeyPuter/puter/commit/67ca4ccf20fd714848121192d5ae7c41f3763da4)) +- add an id to `My Websites` content menu item ([e662c78](https://github.com/HeyPuter/puter/commit/e662c782b745f4f98024d1353a6a162d5fe58c44)) +- remove unnecessary `integrity` and `crossorigin` attributes in dev center when linking to jquery ([8dec78b](https://github.com/HeyPuter/puter/commit/8dec78b090ec4434ad77003d6f3c25de98779864)) +- remove inactive links in README ([f3d270c](https://github.com/HeyPuter/puter/commit/f3d270ccbcd8990270cf968a3638b7affa2df6ba)) +- improve backend mod error handling ([fe1a4cf](https://github.com/HeyPuter/puter/commit/fe1a4cfd4d5dd1eddbb2d50ef3f5ebf78a81656d)) +- app query should return app metadata ([3cedd17](https://github.com/HeyPuter/puter/commit/3cedd17b8ed4acb1099bc2e87aba0137339c8a17)) +- safe parsing of app metadata ([a2c7b37](https://github.com/HeyPuter/puter/commit/a2c7b379f8181b373b0513d9166f75adc147aafa)) +- configuration for browser launch ([791f774](https://github.com/HeyPuter/puter/commit/791f7748c7c1959f63327a73a7e24e41b574a910)) +- previous fix ([ee7bedd](https://github.com/HeyPuter/puter/commit/ee7bedd5586d69ce74f32c1400f377d6a8971eaa)) +- always adapt model for ClaudeEnough ([56710e1](https://github.com/HeyPuter/puter/commit/56710e17f3b06eef07e54c243f6b725fcc4a4583)) +- automatically open browser when starting only if in dev env ([f500fb4](https://github.com/HeyPuter/puter/commit/f500fb47061f8f3a3dc7d871cb529f5c0b058185)) +- image generation supports test mode ([f533dca](https://github.com/HeyPuter/puter/commit/f533dca1a6d88ca7a14bd69f15d0a151e24c58e1)) +- share issue with prefix usernames ([d30d62f](https://github.com/HeyPuter/puter/commit/d30d62f558ca5f8c74090900aa39c13ca3ca1d2e)) +- permission grants in open_item ([16257a7](https://github.com/HeyPuter/puter/commit/16257a7b5459550ee3782cf32c87a8241325878d)) +- sharing notification click opening directories ([bfacfc2](https://github.com/HeyPuter/puter/commit/bfacfc2a4e4b50c9e0842f9f2d56de67a598b959)) +- add placeholders ([2c86240](https://github.com/HeyPuter/puter/commit/2c862403994ff6385144841db07dcc94c5c2fc2e)) +- capitalize `Hindi` in i18n ([35fd158](https://github.com/HeyPuter/puter/commit/35fd15854ad3cc92924c4ded752e337f467a7125)) +- give camera and recorder write permission to Desktop ([65e6d6c](https://github.com/HeyPuter/puter/commit/65e6d6c09fd464b3fea979689fab5f26a2647c4a)) +- potential null-or-undefined in DriverService ([01725ff](https://github.com/HeyPuter/puter/commit/01725ffebf86ed332087c877956e59570ea700ed)) +- usage bug ([0fd3b1e](https://github.com/HeyPuter/puter/commit/0fd3b1e61157d989d55e6dacba2add0e03d260e7)) +- update share email ([7e7234b](https://github.com/HeyPuter/puter/commit/7e7234b2f3fb89560108447cfd7fa87499ec6f38)) +- allow scrolling of user list in share window ([905b5d8](https://github.com/HeyPuter/puter/commit/905b5d851ef68d923d8f7fbaddbe214cb812bae6)) +- mobile detection ([b11016d](https://github.com/HeyPuter/puter/commit/b11016dab321717f2c367e985167a4689fc02814)) +- mobile-friendly taskbar ([7a7c14f](https://github.com/HeyPuter/puter/commit/7a7c14fb040b28ef769abdba41b50d88c856fb20)) +- prevent permission cycles ([e0128aa](https://github.com/HeyPuter/puter/commit/e0128aa88c54548304532282e5ed1b4a2d36ff3e)) +- `launchApp` on explorer supports `~` now ([e482b00](https://github.com/HeyPuter/puter/commit/e482b00a303ca7ec0230be1924334d59adc00f8e)) +- only allow UserActorType for ShareService ([69bfa60](https://github.com/HeyPuter/puter/commit/69bfa601993eb6c47c3555b92559878d76ba749e)) +- new sessions miss notifications ([b1ffb8e](https://github.com/HeyPuter/puter/commit/b1ffb8eca13520fa41833f5361ff6a6505a80a2c)) +- don't allow sharing with recipient just shared with ([d0f16c8](https://github.com/HeyPuter/puter/commit/d0f16c810509c7e4e8acba3408c71655664cfad2)) +- add username to comments ([085d808](https://github.com/HeyPuter/puter/commit/085d808817e985f2bc52b7a91a31991ca3b2e89f)) +- occasional db error from notics ([9e303a2](https://github.com/HeyPuter/puter/commit/9e303a2f7c7bf6ac9032e6c9b87bffd3126baa86)) +- un-awked notif check in wrong place ([3f3f4e6](https://github.com/HeyPuter/puter/commit/3f3f4e6cb9fd3faad2e87fbf9ea1f09b934151ca)) +- disabled sortable on sharing section in the sidebar ([9d7987f](https://github.com/HeyPuter/puter/commit/9d7987fae50b510f1836e306d5f6f497a560de08)) +- add mixxing context to BroadcastService ([665471f](https://github.com/HeyPuter/puter/commit/665471f9f02b1f1163edb47932a31f52577ee7df)) +- attempt at fixing broadcast ([22dd42e](https://github.com/HeyPuter/puter/commit/22dd42ef7f64d32ada0c776287f53a80a4470315)) +- replace ll_readshares with better approach ([cd22425](https://github.com/HeyPuter/puter/commit/cd22425a3d363f6008b3d07f40a082769ee22a14)) +- only add enabled_logs when not empty ([34836e3](https://github.com/HeyPuter/puter/commit/34836e374fccac297a6f0fa5f323f3609d0c9179)) +- don't check share permission anymore ([249dc06](https://github.com/HeyPuter/puter/commit/249dc062014947c32bee8a8238b2c8acf86188bb)) +- files shared array in notification ([27cc07e](https://github.com/HeyPuter/puter/commit/27cc07e985a799fae791d6edf61b7e656e0e182e)) +- report path for broken files as /-void/ ([5725bd8](https://github.com/HeyPuter/puter/commit/5725bd8c66539564e7f58f96c6e81044a3751f97)) +- issue with popover closing when clicked ([ac3317a](https://github.com/HeyPuter/puter/commit/ac3317aea918953358947638ca11822baa38e23f)) +- groups manager location ([a08e975](https://github.com/HeyPuter/puter/commit/a08e9758fe7625d31279b8947a4e5ca6471578ff)) +- don't show kvstore in usages ([402ffb0](https://github.com/HeyPuter/puter/commit/402ffb0fd1e812a8db8ea90ac53ed613fdd30a4b)) +- add missing id for task_manager menu item ([4f9d9a5](https://github.com/HeyPuter/puter/commit/4f9d9a54efb3c5177125904a1c9ddec66ca089dc)) +- Update security.txt canonical URL ([6c44032](https://github.com/HeyPuter/puter/commit/6c44032293836871a27fb3c857a0ff3b80462702)) +- update apps cache by reading from primary db ([e8f67da](https://github.com/HeyPuter/puter/commit/e8f67da9a3d81273f59d136c8383f00d9dc8ca5a)) +- logging in AppConnection ([5caa2c0](https://github.com/HeyPuter/puter/commit/5caa2c0e3a152d1fc947b86329778db462139db0)) +- persist clock visibility change ([1a6d648](https://github.com/HeyPuter/puter/commit/1a6d648a6ecdda07b23da9e6f4ef49b70b54cce1)) +- don't access `metadata.credentialless` if it doesn't exist ([9590bbd](https://github.com/HeyPuter/puter/commit/9590bbdad1099cf75d6073663a9fcec5f3136482)) +- reinitialize settings tabs for DOM events ([16b9f09](https://github.com/HeyPuter/puter/commit/16b9f09e66ffe1584f925cb1a9f261bc159c8dda)) +- use correct cursor when hovering over sidebar items ([c44b9ab](https://github.com/HeyPuter/puter/commit/c44b9ab8d5f575393bf864fd30235287f845a4e8)) +- issue with context menu divider item stealing the event from previous item ([121043d](https://github.com/HeyPuter/puter/commit/121043d312577a6e048497108309cd08b73df4d0)) +- issue with non-scrollable window body and document Context Menu ([0315cb3](https://github.com/HeyPuter/puter/commit/0315cb333719b08c6581b556c69a14cbe671b7bd)) +- temporary fix because .on can't call ensure_service ([f836ac3](https://github.com/HeyPuter/puter/commit/f836ac30a901a7b3258399a54eab5c7c8cc47463)) +- issues in kdmod ([0a47daa](https://github.com/HeyPuter/puter/commit/0a47daa2896d97c318aec2e2288f61ade5f4ea48)) +- Collector bug on undefined body ([14f477a](https://github.com/HeyPuter/puter/commit/14f477a6330c9169145a7f8b2721d02e7517513b)) +- hyphenize_confirm_code bug ([463c96c](https://github.com/HeyPuter/puter/commit/463c96c69a915ea75db66fd449e83a61ca036f6f)) +- app close issue in phoenix ([38adb57](https://github.com/HeyPuter/puter/commit/38adb5741b241081dd3f30de2f9afdd708cc9fa5)) +- reading JSON string from service_usage_monthly ([b30de5b](https://github.com/HeyPuter/puter/commit/b30de5bf786ae8f28f3248277c5b2df2f0e5ebf4)) +- recently broke counting service sql ([7ba16d1](https://github.com/HeyPuter/puter/commit/7ba16d1c21d07e58cefebf967e5ca2b74502e841)) +- ignore invalid entries from service_usage_monthly ([f108795](https://github.com/HeyPuter/puter/commit/f1087953b57297a1e066ea68563e8a273a1af4c0)) +- service usage screen ([193da63](https://github.com/HeyPuter/puter/commit/193da633044f463ec1ed60eca4608761fc40b1d7)) +- continue work on blocked_email_domains (2) ([4dc1e01](https://github.com/HeyPuter/puter/commit/4dc1e01682571f16a25eebb2e9c7918587ca89ae)) +- continue work on blocked_email_domains ([515051d](https://github.com/HeyPuter/puter/commit/515051dabf9f2a145ae2d090f829df7188e9fd28)) +- errors thrown by launch_app ([c22a69f](https://github.com/HeyPuter/puter/commit/c22a69ffb1809ad7959f8a8fe934052369b5d44f)) +- notepad save issue ([bc51d4b](https://github.com/HeyPuter/puter/commit/bc51d4bd52b5d0a7bb4feddea7bb9d73e449f7d8)) +- height 100% on flexer and step view ([c6bc42f](https://github.com/HeyPuter/puter/commit/c6bc42f551a46919b4b70a9ae3dfec85086b0233)) +- wait no ([12e0cec](https://github.com/HeyPuter/puter/commit/12e0cecf02f4d906035a6f0059557416475db106)) +- phoenix incorrect lookup order ([c8f913d](https://github.com/HeyPuter/puter/commit/c8f913d710454d0ab3da2147309b442a78965720)) +- turns out we don't support `utm_source` I learn something new about Puter every day! ([99ce3bd](https://github.com/HeyPuter/puter/commit/99ce3bde199de729c4796a681c188c4a0da9165e)) +- issue with service scripts that use TestView ([e0b9072](https://github.com/HeyPuter/puter/commit/e0b90721299fa3013f66c866ba637c52efe9df1d)) +- 1954f8-related issue #2 ([143cfb5](https://github.com/HeyPuter/puter/commit/143cfb5654eca8b50fb7ff434f47db24d7bdf3aa)) +- 1954f8-related issue ([f5865da](https://github.com/HeyPuter/puter/commit/f5865daede2b32682d0472926bc5db65c9ef37ab)) +- small issue in Service.js ([3c5d2af](https://github.com/HeyPuter/puter/commit/3c5d2af8c8341ef78236ef38153ed0b4f20c5cac)) +- prevent code from breaking just because it was bundled ([fb1216d](https://github.com/HeyPuter/puter/commit/fb1216d488bed8ee8d88c7c71e4a6f1054e3a01c)) +- don't display all apps for extensionless files ([010282e](https://github.com/HeyPuter/puter/commit/010282edf299c2a39e53de7441b8850d0b8011b8)) +- creating app shortcut in self-hosted ([38dcb60](https://github.com/HeyPuter/puter/commit/38dcb60d3f407dd185999d01d8e14355b47df0b8)) +- disable thumbnails for AppData uploads ([37e7b6a](https://github.com/HeyPuter/puter/commit/37e7b6ad70f197db3be8712315446079caa23892)) +- thumbnail service updates ([c2a9506](https://github.com/HeyPuter/puter/commit/c2a9506b4855f67d320eb479a67800098d73e8ec)) +- remove redundant openai model fallback ([9db55fc](https://github.com/HeyPuter/puter/commit/9db55fc5f7a975ab301c88bbac493b7a5b1933bb)) +- app pseudonym in wrong conditional block ([9985996](https://github.com/HeyPuter/puter/commit/99859966866ebce005f88e3a916c68dc04ba97bf)) +- properly add owner object to fsentries ([04c05a5](https://github.com/HeyPuter/puter/commit/04c05a5bb8b73dda21093a2bf563f5cd6faaa356)) +- add progress bar fix ([a70d0dd](https://github.com/HeyPuter/puter/commit/a70d0dd0881b0a07cea404fe13515a5e10321e3e)) +- allow ETX to propagate to bash ([259877b](https://github.com/HeyPuter/puter/commit/259877b677a7bfc8e5b377c8852d687978c9bc24)) +- error deleting entry from My Websites window ([fff8993](https://github.com/HeyPuter/puter/commit/fff89932002d67bf0f121532709c871263e33473)) +- second half of connectToInstance ([4311b48](https://github.com/HeyPuter/puter/commit/4311b482fd629c6d1f65956eb711c8e890453179)) +- error in process.handle_connection ([cb324cc](https://github.com/HeyPuter/puter/commit/cb324cc125285b5cd6a6b0cebf444a6cd873ded9)) +- quick patch to avoid columnify error ([4396534](https://github.com/HeyPuter/puter/commit/439653458eab38e622cf215ae96b6af34d1db7d4)) +- upsert subdomain check to insert only ([f2acd83](https://github.com/HeyPuter/puter/commit/f2acd83b72c388939233fd7145f2dcf78d8ad39e)) +- simplify callback listener and fix async bug ([db3e0b5](https://github.com/HeyPuter/puter/commit/db3e0b5ce84e4b0b35550f380da97b5d6fcb394b)) +- email change on account with unverified email ([33de981](https://github.com/HeyPuter/puter/commit/33de98107f6e3284acb180b1a44bb02ae082642f)) +- html-webpack-plugin dev dep ([cc4ab1c](https://github.com/HeyPuter/puter/commit/cc4ab1cb36a002929f26a39f252a262fc1f1aab4)) +- double-echo in phoenix ([6bdcae7](https://github.com/HeyPuter/puter/commit/6bdcae769d311b5deb82136d5e35d7ad986bca28)) +- webpack error reporting + unintentional whitespace changes ([4910838](https://github.com/HeyPuter/puter/commit/4910838ab1a72738b44f948cbf65feea848e5271)) +- dist ([ed7d6dc](https://github.com/HeyPuter/puter/commit/ed7d6dcbfbf432ae90d9e379dbf47de5587a57a2)) +- use jq el for focus ([d350264](https://github.com/HeyPuter/puter/commit/d35026467eb9a5f67d6ec0c99f2a24d418b8e3a5)) +- fix sourcemap ([cd39bb5](https://github.com/HeyPuter/puter/commit/cd39bb5aa073286baa053f8458f0af54a4b7313a)) +- remove now-redundant loadScript call ([c9d09a7](https://github.com/HeyPuter/puter/commit/c9d09a78b6f4bc9682d13d2f982f9a2b7f77dd66)) +- env for dev build ([46a0f71](https://github.com/HeyPuter/puter/commit/46a0f714d10c2fa99ee9436f453176d54cc161f8)) +- mistakes ([3092300](https://github.com/HeyPuter/puter/commit/3092300a0144791b25816b39845a3d85968e9059)) +- add env to EmitPlugin config ([4b89101](https://github.com/HeyPuter/puter/commit/4b8910169a26f85489135cd84b27fe8f91b37bc6)) +- remove accidentally left-over code ([72946f9](https://github.com/HeyPuter/puter/commit/72946f920c9f27f4c9de3156aa9144d290699222)) +- don't var when no var ([5f7d1f5](https://github.com/HeyPuter/puter/commit/5f7d1f589a56b3d3ea2026dcbd5f9c48b8dc9e6d)) +- fallback to read access in /sign ([813ee95](https://github.com/HeyPuter/puter/commit/813ee95cee6f1fca79a886b12d8fe4603ca0d213)) +- typo in a default file ([aa61c30](https://github.com/HeyPuter/puter/commit/aa61c3009c624099e7bd518870b18b02c008530c)) +- fix 500 when check-app has bad url ([9a62200](https://github.com/HeyPuter/puter/commit/9a622004ea488783127abd83f3f4caf779a5aabb)) +- ll_write ([a7cdb70](https://github.com/HeyPuter/puter/commit/a7cdb70251ae86f883257de3596838d20196c62d)) +- don't try to sanitize null owners ([cb4cab5](https://github.com/HeyPuter/puter/commit/cb4cab529affa5c28ddb32b90328ad47f21de8d4)) +- missing key for feature flag perm check ([1482048](https://github.com/HeyPuter/puter/commit/14820481b9700a5c61c6d9a156944f42f9879008)) +- implicit app permissions bug ([6b4a19e](https://github.com/HeyPuter/puter/commit/6b4a19e12a115be2c0e323d17340ab2ce2b6b025)) +- share services and features with apps ([48fea77](https://github.com/HeyPuter/puter/commit/48fea77a20a0938fc2272483c798b817ca1c9848)) +- admin user public folder ([3819584](https://github.com/HeyPuter/puter/commit/3819584d119076658c9d4be2b2b941c58d122ad4)) +- add anti-csrf token for /revoke-session ([b6b64d3](https://github.com/HeyPuter/puter/commit/b6b64d3bccb6e17240a245c956ead2ae5a87c8dd)) +- only show 2fa when available ([9fa12d4](https://github.com/HeyPuter/puter/commit/9fa12d43fc782d7e4d2584b1cf74dca13b7ced25)) +- requirement for email_confirmed in backend ([6e325fa](https://github.com/HeyPuter/puter/commit/6e325fa000f19b8f20d79829ab2bd78edce80425)) +- do primary read of user after setting email_confirmed ([ef245b7](https://github.com/HeyPuter/puter/commit/ef245b70df482ff470877459fcb28e1f490fe42d)) +- require confirmed email for public folder ([0519b4a](https://github.com/HeyPuter/puter/commit/0519b4a71b236e464c9d1136065e8f5ba15def8e)) +- sqlite condition in MonthlyUsageService ([d4319ea](https://github.com/HeyPuter/puter/commit/d4319ea072e0793a32dbddb1d456227cf481e42c)) +- add context to event listener aiife ([3f07ead](https://github.com/HeyPuter/puter/commit/3f07ead1b9940ee133c142f4c34d19884bbb3cd2)) +- missing method in SLink ([5b74b4a](https://github.com/HeyPuter/puter/commit/5b74b4affae5473029e887542717c76c7b32f562)) +- disable unconfigured ai services ([476acae](https://github.com/HeyPuter/puter/commit/476acae0e0d07c7b025cdbcfd86aacfedd7831a5)) +- add missing driver parameter to /call endpoint ([b520783](https://github.com/HeyPuter/puter/commit/b520783bf4a543c71eaef73277f42d5918ac4469)) +- sqlite migrations error ([d0e461e](https://github.com/HeyPuter/puter/commit/d0e461e206300e7fe3f9bc7f54eaa3a25bb762d8)) +- prevent large logs from service events (2) ([e514dfc](https://github.com/HeyPuter/puter/commit/e514dfcf5049771af3901334e37b1a7c53e05452)) +- prevent large logs from service events (1) ([fa9cc8e](https://github.com/HeyPuter/puter/commit/fa9cc8efcfda5e573c73841ae49c423879e5fcd8)) +- fix templates ([5d2a6fc](https://github.com/HeyPuter/puter/commit/5d2a6fce305a3dcd4857f52ebb75f529dffe4790)) +- popup login in co isolation mode ([8f87770](https://github.com/HeyPuter/puter/commit/8f87770cebab32c00cb10133979d426306685292)) +- add necessary iframe attributes for co isolation ([2a5cec7](https://github.com/HeyPuter/puter/commit/2a5cec7ee914c9c97ae90b85464f9fc5332ad2fb)) +- chore: fix confirm for type_confirm_to_delete_account ([02e1b1e](https://github.com/HeyPuter/puter/commit/02e1b1e8f5f8e22d7ab39ebff99f7dd8e08a4221)) +- syntax error and formatting issue ([3a09e84](https://github.com/HeyPuter/puter/commit/3a09e84838fe8b74bd050641620eec87d9f59dfc)) +- #432 ([f897e84](https://github.com/HeyPuter/puter/commit/f897e844989083b0b369ba0ce4d2c5a9f3db5ad8)) +- `launch_app` not considering `explorer` as a special case ([98e6964](https://github.com/HeyPuter/puter/commit/98e69642d027a83975a0b2b825317213098bb689)) +- well kinda (HOSTNAME in phoenix) ([7043b94](https://github.com/HeyPuter/puter/commit/7043b9400c63842c4c54d82724167666708d3119)) +- it was github actions the entire time ([602a198](https://github.com/HeyPuter/puter/commit/602a19895c05b45a7d283470e7af3ae786be1bf2)) +- run mocha within packages in monorepo ([58c199c](https://github.com/HeyPuter/puter/commit/58c199c15356ac087a04b16dd18e8fe0f1aea359)) +- make webpack output not look like errors ([ad3d318](https://github.com/HeyPuter/puter/commit/ad3d318d07377c78c0429247225655e489b68be4)) +- No scrollbar for session list ([45f131f](https://github.com/HeyPuter/puter/commit/45f131f8eaf94cf3951ca7ffeb6f311590233b8a)) +- fix path issues under win32 platform ([d80f2fa](https://github.com/HeyPuter/puter/commit/d80f2fa847bfaef98dc8d482898f5c15f268e4bd)) +- remove abnoxious debug file ([5c636d4](https://github.com/HeyPuter/puter/commit/5c636d4fd25e14ba3813f7fca3b70ff7bd6860e7)) +- read_only fields in ES ([e8f4c32](https://github.com/HeyPuter/puter/commit/e8f4c328bff5c36b95fe460b80803e12e619f8ee)) +### Security + + +#### Bug Fixes + +- verify dest_node uid matches signature ([e208b99](https://github.com/HeyPuter/puter/commit/e208b99d211e98cd88e0a8b2917bbe6b2f2423a0)) +- always use actor ([1954f86](https://github.com/HeyPuter/puter/commit/1954f86680be642e1af03f648d6b587fe67dfaa8)) +- signing in public folders ([937528f](https://github.com/HeyPuter/puter/commit/937528f7676e8ace7287141e1f5057842a2b5eb7)) +- remove unconfirmed_email from /whoami for apps ([a002ad0](https://github.com/HeyPuter/puter/commit/a002ad08e5622a349b5d24ed2c7c5f61215146b8)) +- hoist acl check in ll_read ([6a2fbc1](https://github.com/HeyPuter/puter/commit/6a2fbc1925952ecceed741afe138270d1eeda7b7)) +### Backend + + +#### Features + +- add comments for fsentries ([db79a72](https://github.com/HeyPuter/puter/commit/db79a72daab5460bc8e24f6e16c6280291b2f6fe)) +### AI + + +#### Features + +- add xAI grok-beta ([28adcf5](https://github.com/HeyPuter/puter/commit/28adcf533fd867dfdf3bda0007753e65c91ff5e5)) +- add groq ([53e7a91](https://github.com/HeyPuter/puter/commit/53e7a91f1800b60b48575a6e41d96d2ccbd6d362)) +- add mistral ([055c628](https://github.com/HeyPuter/puter/commit/055c628afd2e33589d3dc66c52934505143eafd4)) +- add togetherai ([bdfdf23](https://github.com/HeyPuter/puter/commit/bdfdf2331b37680b95ac56b31026d3bdab4c173b)) +- add claude ([d009cd0](https://github.com/HeyPuter/puter/commit/d009cd0aaff645a24d37085ed41c55fe296a5722)) +- add streaming ([9d5963c](https://github.com/HeyPuter/puter/commit/9d5963cdf5fe63a4f7970d2d03bc307f4d4fa3ab)) + +#### Bug Fixes + +- close streams ([eb18550](https://github.com/HeyPuter/puter/commit/eb18550f411947a0d8ccaf283701596b1386cfe6)) +- adapt message role for claude ([c08b897](https://github.com/HeyPuter/puter/commit/c08b897d4a6a77c54a7e8d2e705e2048ab4797ba)) +### GUI + +### Putility + + +#### Features + +- trait method override support ([43c5402](https://github.com/HeyPuter/puter/commit/43c5402b7cb92e604cbe59badc8f735131d2c349)) +### Docker + + +#### Bug Fixes + +- ensure temp admin pass shows ([d2c7477](https://github.com/HeyPuter/puter/commit/d2c7477b3bf170be492a6d5387330645cdf9c33a)) +### Puter JS + + +#### Features + +- add drivers module ([439f52b](https://github.com/HeyPuter/puter/commit/439f52b5a3f1a94e6d15ddacc315ae797f4709c2)) + +#### Bug Fixes + +- fix settings object check ([5a616f6](https://github.com/HeyPuter/puter/commit/5a616f67dd22a0dcbb8a380bbbd2347a0029ce31)) +### API + + +#### Features + +- add /lsmod ([32f0edb](https://github.com/HeyPuter/puter/commit/32f0edb93a8fb0c33b0614b99c7fc439c8f6afc9)) + + + +## v2.4.2 (2024-07-22) + +### Puter + +#### Features + +- add new file templates ([1f7f094](https://github.com/HeyPuter/puter/commit/1f7f094282fae915a2436701cfb756444cd3f781)) +- add cross_origin_isolation option ([e539932](https://github.com/HeyPuter/puter/commit/e53993207077aecd2c01712519251993bb2562bc)) +- add option to disable temporary users ([f9333b3](https://github.com/HeyPuter/puter/commit/f9333b3d1e05bd0dffaecd2e29afd08ea61559fc)) +- add some default groups ([ba50d0f](https://github.com/HeyPuter/puter/commit/ba50d0f96d58075abec067d24e6532bd874093f0)) +- Add support for dropping multiple Puter items onto Dev Center (close #311) ([8e7306c](https://github.com/HeyPuter/puter/commit/8e7306c23be01ee6c31cdb4c99f2fb1f71a2247f)) + +#### Translations + +- Update ig.js ([382fb24](https://github.com/HeyPuter/puter/commit/382fb24dbb1737a8a54ed2491f80b2e2276cde61)) +- feat: add vietnamese localization-a ([c2d3d69](https://github.com/HeyPuter/puter/commit/c2d3d69dbe33f36fcae13bcbc8e2a31a86025af9)) +- Update zhtw.js, Complete Traditional Chinese translation based on English file #550 ([b9e73b7](https://github.com/HeyPuter/puter/commit/b9e73b7288aebb14e6bbf1915743e9157fc950b1)) +- update zhtw.js to match en.js ([37fd666](https://github.com/HeyPuter/puter/commit/37fd666a9a6788d5f0c59311499f29896b48bc82)) +- Add Tamil translation to translations.js ([8a3d043](https://github.com/HeyPuter/puter/commit/8a3d0430f39f872b8a460c344cce652c340b700b)) +- Move Tamil translation to the rest of translations ([333d6e3](https://github.com/HeyPuter/puter/commit/333d6e3b651e460caca04a896cbc8c175555b79b)) +- Translation improvements, mainly style and context-based ([8bece96](https://github.com/HeyPuter/puter/commit/8bece96f6224a060d5b408e08c58865fadb8b79c)) +- update translation file es.js to be up to date with the file en.js ([1515278](https://github.com/HeyPuter/puter/commit/151527825f1eb4b060aaf97feb7d18af4fcddbf2)) +- Translate en.js as of 2024-07-10 ([8e297cd](https://github.com/HeyPuter/puter/commit/8e297cd7e30757073e2f96593c363a273b639466)) +- Create hu.js hungarian language ([69a80ab](https://github.com/HeyPuter/puter/commit/69a80ab3d2c94ee43d96021c3bcbdab04a4b5dc6)) +- Update translations.js to Hungarian lang ([56820cf](https://github.com/HeyPuter/puter/commit/56820cf6ee56ff810a6b495a281ccbb2e7f9d8fb)) +- Tamil translation ([81781f8](https://github.com/HeyPuter/puter/commit/81781f80afc07cd1e6278906cdc68c8092fbfedf)) +- Update it.js ([84e31ef](https://github.com/HeyPuter/puter/commit/84e31eff2f58584d8fab7dd10606f2f6ced933a2)) +- Update Armenian translation file ([3b8af7c](https://github.com/HeyPuter/puter/commit/3b8af7cc5c1be8ed67be827360bbfe0f0b5027e9)) + +#### Bug Fixes + +- fix templates ([5d2a6fc](https://github.com/HeyPuter/puter/commit/5d2a6fce305a3dcd4857f52ebb75f529dffe4790)) +- popup login in co isolation mode ([8f87770](https://github.com/HeyPuter/puter/commit/8f87770cebab32c00cb10133979d426306685292)) +- add necessary iframe attributes for co isolation ([2a5cec7](https://github.com/HeyPuter/puter/commit/2a5cec7ee914c9c97ae90b85464f9fc5332ad2fb)) +- chore: fix confirm for type_confirm_to_delete_account ([02e1b1e](https://github.com/HeyPuter/puter/commit/02e1b1e8f5f8e22d7ab39ebff99f7dd8e08a4221)) +- syntax error and formatting issue ([3a09e84](https://github.com/HeyPuter/puter/commit/3a09e84838fe8b74bd050641620eec87d9f59dfc)) +- #432 ([f897e84](https://github.com/HeyPuter/puter/commit/f897e844989083b0b369ba0ce4d2c5a9f3db5ad8)) +- `launch_app` not considering `explorer` as a special case ([98e6964](https://github.com/HeyPuter/puter/commit/98e69642d027a83975a0b2b825317213098bb689)) +- well kinda (HOSTNAME in phoenix) ([7043b94](https://github.com/HeyPuter/puter/commit/7043b9400c63842c4c54d82724167666708d3119)) +- it was github actions the entire time ([602a198](https://github.com/HeyPuter/puter/commit/602a19895c05b45a7d283470e7af3ae786be1bf2)) +- fix CI attempt #7 ([614f2c5](https://github.com/HeyPuter/puter/commit/614f2c5061525f230ccd879bfb047434ac46a9ba)) +- fix CI attempt #6 ([9d549b1](https://github.com/HeyPuter/puter/commit/9d549b192d149eac96c316ded645bf7c2e96153d)) +- fix CI attempt #5 ([74adcdd](https://github.com/HeyPuter/puter/commit/74adcddc1d60e0a513408a0716ed2b301126225d)) +- fix CI attempt #4 ([84b993b](https://github.com/HeyPuter/puter/commit/84b993bce913c3ad99127063bcfaae19331b199c)) +- fix CI attempt #3 ([3bca973](https://github.com/HeyPuter/puter/commit/3bca973f5f4e65a2bd24c634c347fbd681a7458b)) +- fix CI attempt #2 ([aebe89a](https://github.com/HeyPuter/puter/commit/aebe89a1acb070764551e8e89e325325ffbed8f9)) +- run mocha within packages in monorepo ([58c199c](https://github.com/HeyPuter/puter/commit/58c199c15356ac087a04b16dd18e8fe0f1aea359)) +- make webpack output not look like errors ([ad3d318](https://github.com/HeyPuter/puter/commit/ad3d318d07377c78c0429247225655e489b68be4)) +- No scrollbar for session list ([45f131f](https://github.com/HeyPuter/puter/commit/45f131f8eaf94cf3951ca7ffeb6f311590233b8a)) +- fix path issues under win32 platform ([d80f2fa](https://github.com/HeyPuter/puter/commit/d80f2fa847bfaef98dc8d482898f5c15f268e4bd)) +- remove abnoxious debug file ([5c636d4](https://github.com/HeyPuter/puter/commit/5c636d4fd25e14ba3813f7fca3b70ff7bd6860e7)) +- read_only fields in ES ([e8f4c32](https://github.com/HeyPuter/puter/commit/e8f4c328bff5c36b95fe460b80803e12e619f8ee)) + +### Security + +#### Bug Fixes + +- hoist acl check in ll_read ([6a2fbc1](https://github.com/HeyPuter/puter/commit/6a2fbc1925952ecceed741afe138270d1eeda7b7)) + +## v2.4.1 (2024-07-11) + +### Puter + + +#### Features + +- update BR translation ([42a6b39](https://github.com/HeyPuter/puter/commit/42a6b3938a588b8b4d1bd976c37e9c6e58408c75)) +- JSON support for kv driver ([3ed7916](https://github.com/HeyPuter/puter/commit/3ed7916856f03eafbe0891f2ab39c34d20d2bd24)) + +#### Translations + +- Update bn.js file formatting ([cff488f](https://github.com/HeyPuter/puter/commit/cff488f4f4378ca6c7568a585a665f2a3b87b89c)) +- Issue#530 - Update bengali translations ([92abc99](https://github.com/HeyPuter/puter/commit/92abc9947f811f94f17a5ee5a4b73ee2b210900a)) +- Added missing Romanian translations. ([8440f56](https://github.com/HeyPuter/puter/commit/8440f566b91c9eb4f01addcb850061e3fbe3afc7)) +- Add 2FA Romanian translations ([473b651](https://github.com/HeyPuter/puter/commit/473b6512c697854e3f3badae1eb7b87742954da5)) +- Add Japanese Translation ([47ec74f](https://github.com/HeyPuter/puter/commit/47ec74f0aa6adb3952e6460909029a4acb0c3039)) +- Completing Italian translation based on English file ([f5a8ee1](https://github.com/HeyPuter/puter/commit/f5a8ee1c6ab950d62c90b6257791f026a508b4e4)) +- Completing Italian translation based on English file. ([a96abb5](https://github.com/HeyPuter/puter/commit/a96abb5793528d0dc56d75f95d771e1dcf5960d1)) +- Completing Arabic translation based on English file ([78a0ace](https://github.com/HeyPuter/puter/commit/78a0acea6980b6d491da4874edbd98e17c0d9577)) +- Update Arabic translations in src/gui/src/i18n/translations/ar.js to match English version in src/gui/src/i18n/translations/en.js ([fe5be7f](https://github.com/HeyPuter/puter/commit/fe5be7f3cf7f336730137293ba86a637e8d8591d)) +- Update Arabic translations in src/gui/src/i18n/translations/ar.js to match English version in src/gui/src/i18n/translations/en.js ([bffa192](https://github.com/HeyPuter/puter/commit/bffa192805216fc17045cd8d629f34784dca7f3f)) +- Ukrainian updated ([e61039f](https://github.com/HeyPuter/puter/commit/e61039faf409b0ad85c7513b0123f3f2e92ebe32)) +- Update ru.js issue #547 ([17145d0](https://github.com/HeyPuter/puter/commit/17145d0be6a9a1445947cc0c4bec8f16a475144c)) +- Russian translation fixed ([8836011](https://github.com/HeyPuter/puter/commit/883601142873f10d69c84874499065a7d29af054)) + +#### Bug Fixes + +- remove flag that breaks puter-js webpack ([7aadae5](https://github.com/HeyPuter/puter/commit/7aadae58ce1a51f925bf64c3d65ac1fa6971b164)) +- Improve `getMimeType` to remove trailing dot in the extension if preset ([535475b](https://github.com/HeyPuter/puter/commit/535475b3c36a37e3319ed067a24fb671790dcda3)) + + +## 2.4.0 (2024-07-08) + + +### Features + +* add (pt-br) translation for system settings. ([77211c4](https://github.com/HeyPuter/puter/commit/77211c4f71b0285fb3060f7e5c8d493b4d7c4f0c)) +* add /group/list endpoint ([d55f38c](https://github.com/HeyPuter/puter/commit/d55f38ca68899c3574cfe328d2b206b1143ff0d4)) +* add /share/file-by-username endpoint ([5d214c7](https://github.com/HeyPuter/puter/commit/5d214c7b52887b594af6be497f1892baf7d77679)) +* add /sharelink/request endpoint ([742f625](https://github.com/HeyPuter/puter/commit/742f625309f9f4cfa70cf7d2fe5b03fd164913ea)) +* add /show urls ([079e25a](https://github.com/HeyPuter/puter/commit/079e25a9fe8e179f26d72378856058eb656e2314)) +* add app metadata ([f7216b9](https://github.com/HeyPuter/puter/commit/f7216b95672b38802b288ef5b022e947017ff311)) +* add appdata permission (if applicable) on app share ([9751fd9](https://github.com/HeyPuter/puter/commit/9751fd92a50e75385cffed0ca847d5076ba98c92)) +* add cookie for site token ([a813fbb](https://github.com/HeyPuter/puter/commit/a813fbbb88bcfb8b9a61976e2a4fc4aab943fc88)) +* add cross-server event broadcasting ([1207a15](https://github.com/HeyPuter/puter/commit/1207a158bdc88a90b14d31d03387ce353c176a9c)) +* add debug mod ([16b1649](https://github.com/HeyPuter/puter/commit/16b1649ff62fd87a4dda5d2e1c68941c864c5da4)) +* add endpoints for share tokens ([301ffaf](https://github.com/HeyPuter/puter/commit/301ffaf61dbb4fca1a855650ab80707ae6d9f602)) +* Add exit status code to apps ([7674da4](https://github.com/HeyPuter/puter/commit/7674da4cd225bcad34079251c5600fc32e32248b)) +* add external mod loading ([eb05fbd](https://github.com/HeyPuter/puter/commit/eb05fbd2dc4877553b5118a069a9afdc32bea137)) +* add group management endpoints ([4216346](https://github.com/HeyPuter/puter/commit/4216346384d90dcba429dbcb175e6f86482d19f4)) +* add group permission endpoints ([c374b0c](https://github.com/HeyPuter/puter/commit/c374b0cbca761e7c8a47d56a09551f2e9378066a)) +* add mark-read endpoint ([0101f42](https://github.com/HeyPuter/puter/commit/0101f425d480705c20df4919a76f66e987f5790f)) +* add permission rewriter for app by name ([16c4907](https://github.com/HeyPuter/puter/commit/16c4907be592dae31ed3c1aa3fac3b9655255d6f)) +* add protected apps ([f2f3d6f](https://github.com/HeyPuter/puter/commit/f2f3d6ff460932698fb8da7309fbce3e96132950)) +* add protected subdomains ([86fca17](https://github.com/HeyPuter/puter/commit/86fca17fb17c0c24397c29b49b133deadea1de8b)) +* add querystring-informed errors ([e7c0b83](https://github.com/HeyPuter/puter/commit/e7c0b8320a6829315d9154d6d513bab4491c47ea)) +* add readdir delegate for shares in a user directory ([8424d44](https://github.com/HeyPuter/puter/commit/8424d446099ac30ccf829c57d43eef1f235618e4)) +* add readdir delegate for sharing user homedirs ([19a5eb0](https://github.com/HeyPuter/puter/commit/19a5eb00763f3ac31df8483fb59cb7a96c448745)) +* add service for notifications ([a1e6887](https://github.com/HeyPuter/puter/commit/a1e6887bf93da21b9482040b3e30ee083fb23477)) +* add service to test file share logic ([332371f](https://github.com/HeyPuter/puter/commit/332371fccb198462948a440419adc7a26d671a23)) +* add share list to stat ([8c49ba2](https://github.com/HeyPuter/puter/commit/8c49ba2553ce6bee20eb5b6f2721bc80f639e98a)) +* add share service and share-by-email to /share ([db5990a](https://github.com/HeyPuter/puter/commit/db5990a98935817c0e16d30e921bb99c57a98fc8)) +* add subdomain permission (if applicable) on app share ([13e2f72](https://github.com/HeyPuter/puter/commit/13e2f72c9f33f485570f13f45341246b1a05879f)) +* add user-group permission check ([0014940](https://github.com/HeyPuter/puter/commit/00149402e041443aa3ac571fbe97a9a85f95564b)) +* **backend:** add script service ([30550fc](https://github.com/HeyPuter/puter/commit/30550fcddda18469735499546de502d29b85e2ad)) +* **backend:** Add tab completion to server console command arguments ([fa81dca](https://github.com/HeyPuter/puter/commit/fa81dca9507b7fa0f82099b75f2ab89c865626ac)) +* **backend:** Add tab-completion to server console command names ([e1e76c6](https://github.com/HeyPuter/puter/commit/e1e76c6be71fdeb3b6246307b626734d8dc26f86)) +* **backend:** add tip of day ([2d8e624](https://github.com/HeyPuter/puter/commit/2d8e6240c61dc6301f49cbdcd1c3b04736f9ca93)) +* **backend:** allow services to provide user properties ([522664d](https://github.com/HeyPuter/puter/commit/522664d415c33342500defec309c2ff15bc94804)) +* **backend:** allow services to provide whoami values ([fccabf1](https://github.com/HeyPuter/puter/commit/fccabf1bc0c4418f3599222616dd63bf98c14fe1)) +* **backend:** improve logger and reduce logs ([4bdad75](https://github.com/HeyPuter/puter/commit/4bdad75766d0617a164024b39b79bf5373c495a6)) +* Display app icon and description in embeds ([ef298ce](https://github.com/HeyPuter/puter/commit/ef298ce3aa3ce90224e883fb0ba33f9cd3a3da44)) +* get first test working on share-test service ([88d6bee](https://github.com/HeyPuter/puter/commit/88d6bee9546f36d689c53ec7fe95f01f772f5211)) +* **git:** Add --color and --no-color options ([d6dd1a5](https://github.com/HeyPuter/puter/commit/d6dd1a5bb0a2b2bba2cfe86d2e51ff2a6e42841c)) +* **git:** Add a --debug option, which sets the DEBUG global ([fa3df72](https://github.com/HeyPuter/puter/commit/fa3df72f6ed2d45a440ebc2aacbbae67bf042478)) +* **git:** Add authentication to clone, fetch, and pull. ([364d580](https://github.com/HeyPuter/puter/commit/364d580ff896691ee70d3735f495c720651a9f41)) +* **git:** Add diff display to `show` and `log` subcommands ([3cad1ec](https://github.com/HeyPuter/puter/commit/3cad1ec436f99a78f782ab9576325d4341284964)) +* **git:** Add start-revision and file arguments to `git log` ([49c2f16](https://github.com/HeyPuter/puter/commit/49c2f163515d2130c17a6f6a6a16bc27ea69336a)) +* **git:** Allow checking out a commit instead of a branch ([057b3ac](https://github.com/HeyPuter/puter/commit/057b3acf00af49c005b9bf7069c5d22983a32e1e)) +* **git:** Color output for `git status` files ([bab5204](https://github.com/HeyPuter/puter/commit/bab5204209aa2efc0c053643677a78db6ede0929)) +* **git:** Display file contents as a string for `git show FILE_OID` ([a680371](https://github.com/HeyPuter/puter/commit/a68037111a04580cfa2688694a68ef6ac7a495fa)) +* **git:** Display ref names in `git log` and `git show` ([45cdfcb](https://github.com/HeyPuter/puter/commit/45cdfcb5bfa66937b33054a127e0b17001f3faa4)) +* **git:** Format output closer to canonical git ([60976b1](https://github.com/HeyPuter/puter/commit/60976b1ed61984d9d290f3a0ae99dd97632e9909)) +* **git:** Handle detached HEAD in `git status` and `git branch --list` ([2c9b1a3](https://github.com/HeyPuter/puter/commit/2c9b1a3ffc3d5e282ffe5b83a86314e99445bbc6)) +* **git:** Implement `git branch` ([ad4f132](https://github.com/HeyPuter/puter/commit/ad4f13255d52f8226f22800c16b388cf0e6384d7)) +* **git:** Implement `git checkout` ([35e4453](https://github.com/HeyPuter/puter/commit/35e4453930bc4e151887f83c97efec19cc15da70)) +* **git:** Implement `git cherry-pick` ([2e4259d](https://github.com/HeyPuter/puter/commit/2e4259d267b3cfafd5cefc57a02643c6432fec4d)) +* **git:** Implement `git clone` ([95c8235](https://github.com/HeyPuter/puter/commit/95c8235a4a1fea39a46c40df04cb1004a2fe7b23)) +* **git:** Implement `git diff` ([622b6a9](https://github.com/HeyPuter/puter/commit/622b6a9b921c3c03efc0b519c9a26c6701d80e50)) +* **git:** Implement `git fetch` ([98a4b9e](https://github.com/HeyPuter/puter/commit/98a4b9ede39b94c0c6b6b8345d7551359961186a)) +* **git:** Implement `git pull` ([eb2b6a0](https://github.com/HeyPuter/puter/commit/eb2b6a08b03cee0612885412cd4b03c9564044e3)) +* **git:** Implement `git push` ([8c70229](https://github.com/HeyPuter/puter/commit/8c70229a188b743220db076a740a992fd7971301)) +* **git:** Implement `git remote` ([43ce0d5](https://github.com/HeyPuter/puter/commit/43ce0d5b45d4eb4f296afcaaa1ecadc125c53e89)) +* **git:** Implement `git restore` ([4ba8a32](https://github.com/HeyPuter/puter/commit/4ba8a32b45d395f28433572db5644d630776789e)) +* **git:** Make `git add` work for deleted files ([9551544](https://github.com/HeyPuter/puter/commit/955154468f48e45028dad2e916708d6a763affad)) +* **git:** Make shorten_hash() guaranteed to produce a unique hash ([dd10a37](https://github.com/HeyPuter/puter/commit/dd10a377493c0d8f10a1ac8779dc27f3f3bf6c37)) +* **git:** Resolve more forms of commit reference ([b6906bb](https://github.com/HeyPuter/puter/commit/b6906bbcaaa50fc8a8c60beb6d2d38bcb7dda758)) +* **git:** Understand references like `HEAD^` and `main~3` ([711dbc0](https://github.com/HeyPuter/puter/commit/711dbc0d2fde9c2ddc6c86f64fb4caa7837c9dcb)) +* implicit access from apps to shared appdata dirs ([31d4eb0](https://github.com/HeyPuter/puter/commit/31d4eb090efb340fdfb7cb6b751145e859624eeb)) +* introduce notification selection via driver ([c5334b0](https://github.com/HeyPuter/puter/commit/c5334b0e19cf9762f536ec482c3ff872e9c12399)) +* multi-recipient multi-file share endpoint ([846fdc2](https://github.com/HeyPuter/puter/commit/846fdc20d4a887a1f8a4f3bda4fafe41efab2733)) +* **parsely:** Add a fail() parser ([5656d9d](https://github.com/HeyPuter/puter/commit/5656d9d42f76202a534ad640d3a4e287e0e40418)) +* **parsely:** Add stringUntil() parser ([d46b043](https://github.com/HeyPuter/puter/commit/d46b043c5d16f1205d61de3f3ba43ed8ad7bff93)) +* **phoenix:** Add --dump and --file options to sed ([f250f86](https://github.com/HeyPuter/puter/commit/f250f86446a506f24fa2ad396328e3a2212a68d0)) +* **phoenix:** Add more commands to sed, including labels and branching ([306014a](https://github.com/HeyPuter/puter/commit/306014adc77a7ca155feb95d1146cb46ee075b52)) +* **phoenix:** Expose parsed arg tokens to apps that request them ([4067c82](https://github.com/HeyPuter/puter/commit/4067c82486c99cad20f41927ad39ebea438b717f)) +* **phoenix:** Implement an `exit` builtin ([3184d34](https://github.com/HeyPuter/puter/commit/3184d3482c7b95c0fd1fc0745555ff82fc9a8c99)) +* **phoenix:** Implement parsing of sed scripts ([0d4f907](https://github.com/HeyPuter/puter/commit/0d4f907b6675b15bd50a55f50aa28f0803b18b7b)) +* **phoenix:** Make `clear` clear scrollback unless `-x` is given ([75a989a](https://github.com/HeyPuter/puter/commit/75a989a7b69bfdfdf69e5f0365027c5b27d8bfc6)) +* **Phoenix:** Pass command line arguments and ENV when launching apps ([8f1c4fc](https://github.com/HeyPuter/puter/commit/8f1c4fcda98e72a7b970e8c6fc2fe39a5e012264)) +* **phoenix:** Respond to exit status codes ([5de3052](https://github.com/HeyPuter/puter/commit/5de305202656a172b187dac87543d6c1c69a2958)) +* **phoenix:** Show actual host name in prompt and neofetch ([4539408](https://github.com/HeyPuter/puter/commit/4539408a218a50244dc615cf7de56c29dcac53e6)) +* rate-limit for excessive groups ([4af279a](https://github.com/HeyPuter/puter/commit/4af279a72fc9de89ddc3ba51806ca3760a36265d)) +* re-send unreads on login ([02fc4d8](https://github.com/HeyPuter/puter/commit/02fc4d86b7166fb4803be5d28e2a593d6b7d9785)) +* register dev center to apps ([10f4d7d](https://github.com/HeyPuter/puter/commit/10f4d7d50ce9314f9c3888c74cb17c8ebbecee98)) +* send notification when file gets shared ([2f6c428](https://github.com/HeyPuter/puter/commit/2f6c428a403a006f7878861d2f0356c3294519be)) +* start directory index frame ([fb1e2f2](https://github.com/HeyPuter/puter/commit/fb1e2f21fb67aefe0602f6c978199c7cd019bbf7)) +* support canonical puter.js url in dev ([fd41ae2](https://github.com/HeyPuter/puter/commit/fd41ae217c7a9f7229326f62a829471580a744bd)) +* **ui:** add new components ([577bd59](https://github.com/HeyPuter/puter/commit/577bd59b6cc94810e851ad544f8234e25a4e6e27)) +* **ui:** add new components ([38ba425](https://github.com/HeyPuter/puter/commit/38ba42575ce9f3506f8ce219b9580202b3ed9993)) +* **ui:** allow component-based settings tabs ([1245960](https://github.com/HeyPuter/puter/commit/124596058a286241b51dd87ce2fc1a68478cb5b8)) +* update share endpoint to support more things ([dd5fde5](https://github.com/HeyPuter/puter/commit/dd5fde5130c1840ab598e6622766ae835142e58a)) + + +### Bug Fixes + +* add app_uid param to kv interface ([f7a0549](https://github.com/HeyPuter/puter/commit/f7a054956b8739a3bc305a49faee929ea0da1e15)) +* add missing columns for public directory update ([b10302a](https://github.com/HeyPuter/puter/commit/b10302ad744fd9c58f9735743e075815183c772c)) +* Add missing file extension to 0009_app-prefix-fix.sql in DB init ([a8160a8](https://github.com/HeyPuter/puter/commit/a8160a8cdcdd6aff98728a6f1643d93386e6bb5a)) +* add permission implicator for file modes ([e63ab3a](https://github.com/HeyPuter/puter/commit/e63ab3a67f6555eb13d6af477a8da9f1b54d6608)) +* add stream limit ([ceba309](https://github.com/HeyPuter/puter/commit/ceba309dbd4df89f310d1a530f939a5b7991f4c7)) +* **backend:** remove a bad thing that really doesn't work ([8d22276](https://github.com/HeyPuter/puter/commit/8d22276f13106f7642d11da30b1500817a20ad43)) +* bug introduced when refactoring /share to Sequence ([ecb9978](https://github.com/HeyPuter/puter/commit/ecb997885c1efb766827c84d2ffb8dc6ddabe992)) +* check subdomain earlier for /apps ([4e3a24e](https://github.com/HeyPuter/puter/commit/4e3a24e6093e279e210765e07e436f4e63b74072)) +* column nullability blunder ([1429d6f](https://github.com/HeyPuter/puter/commit/1429d6f57c67dff51fc41ca0c2868f8d000845f1)) +* Correct APIError imports ([062e23b](https://github.com/HeyPuter/puter/commit/062e23b5c9673db1f8b0ff0469289d52dd1e3f99)) +* correct shown flag behavior ([632c536](https://github.com/HeyPuter/puter/commit/632c5366161ff8fbbd4d60c61dfbe52dad488a2c)) +* database migration ([9b39309](https://github.com/HeyPuter/puter/commit/9b39309e18a2927d25fe794d91da4e4d068c4bca)) +* do not delegate to select on read like ever that is really dumb ([a2a10b9](https://github.com/HeyPuter/puter/commit/a2a10b94be59403e03fb08bec5d7c056ce5b554f)) +* docker runtime fail because stdout columns ([94c0449](https://github.com/HeyPuter/puter/commit/94c0449437ce4cb26d00a15a3f277bc7b09367b4)) +* fix issues with apps in /share endpoint ([0cf90ee](https://github.com/HeyPuter/puter/commit/0cf90ee39af6548d271dec45ed8ee9e6df1cd14d)) +* fix owner ids for default apps ([283f409](https://github.com/HeyPuter/puter/commit/283f409a662d126e7f3ce811f1467ac6fab9a522)) +* fix permission cascade properly this time ([de58866](https://github.com/HeyPuter/puter/commit/de5886698e1eae2b250baac174b57029f3244e96)) +* Fix phoenix app prefix and TokenService test ([afb9d86](https://github.com/HeyPuter/puter/commit/afb9d866b5091058711db931cde904947e661c15)) +* fix that fix ([b126b67](https://github.com/HeyPuter/puter/commit/b126b670940a0e20cfe7bd0eba3db891bab5c142)) +* fix typo ([ce328b7](https://github.com/HeyPuter/puter/commit/ce328b7245ad741b64c5885f64f806fc98a55d84)) +* **git:** Make git commit display detached HEAD correctly ([73d0f5a](https://github.com/HeyPuter/puter/commit/73d0f5a90cb5dcbadfc6d0fd22f14e8bc0e61f86)) +* group permission audit table ([7d2f6d2](https://github.com/HeyPuter/puter/commit/7d2f6d256f56e30d752e9999c6e8bde68f9d9637)) +* handle subpaths under another user ([d128cee](https://github.com/HeyPuter/puter/commit/d128ceed6f4928fa0793815feb2e2715cd273ff8)) +* handling of batch requests with zero files ([c0063a8](https://github.com/HeyPuter/puter/commit/c0063a871fd891a1774f1bee00e86170fed249fa)) +* i forgot to test reloading ([7eabb43](https://github.com/HeyPuter/puter/commit/7eabb43bd4257b4129d67eaeda2aa27e8268dc78)) +* improve console experience on mac ([15465bf](https://github.com/HeyPuter/puter/commit/15465bfc5035a64762f7c86a3d38af8be6be5b59)) +* incorrect error from suggested_apps ([b648817](https://github.com/HeyPuter/puter/commit/b648817f2743c2b6214ebe4177d921c9b9027594)) +* Make polyfilled import.meta.filename getter a valid function ([85c6798](https://github.com/HeyPuter/puter/commit/85c679844869b6b05fcbda231d8dc7026a66da97)) +* null email in request to /share ([bf63144](https://github.com/HeyPuter/puter/commit/bf63144f7a79c48bd650ae851ddd0c8a10d748c3)) +* Only run Component initialization functions once ([5b43358](https://github.com/HeyPuter/puter/commit/5b43358219402bee3eadf4a0f184a4b924d3293b)) +* oops ([a136ee5](https://github.com/HeyPuter/puter/commit/a136ee5edd3149798a0d82f494f423f503b65f00)) +* **parsely:** Make Repeat parser work when no separator is given ([9b4d16f](https://github.com/HeyPuter/puter/commit/9b4d16fbe9d5698c57f9da725a22b528a7d7cac2)) +* peers array assumption ([10cbf08](https://github.com/HeyPuter/puter/commit/10cbf08233620440aa39f5302deaac4f59f02247)) +* **phoenix:** Add missing newlines to sed command output ([e047b0b](https://github.com/HeyPuter/puter/commit/e047b0bf302284da61e677432e4cc25b531b24f2)) +* **phoenix:** Gracefully handle completing a non-existent path ([d76e713](https://github.com/HeyPuter/puter/commit/d76e7130cba9f0ca05940abafe4fd1a41464aa83)) +* property validation on some permission endpoints ([0855f2b](https://github.com/HeyPuter/puter/commit/0855f2b36eca3bbdaa8429cbde3aa1242e8e96ee)) +* readdir on file ([a72ec97](https://github.com/HeyPuter/puter/commit/a72ec9799ac3bd76ceafa22cce149e373a13f3b9)) +* remove last component when share URL is file ([1166e69](https://github.com/HeyPuter/puter/commit/1166e69c76688d1811701c56cd4df9d38e286793)) +* remove legacy permission check in stat ([f2c6e01](https://github.com/HeyPuter/puter/commit/f2c6e01296e4214336e63bc2d69bcbf17f59890f)) +* Remove null or duplicate app entries from suggest_app_for_fsentry() ([6900233](https://github.com/HeyPuter/puter/commit/6900233c5aaa2d1a49f495e9f9a060796757a91e)) +* **security:** Move token for socket.io to request body ([49b257e](https://github.com/HeyPuter/puter/commit/49b257ecffbb1e12090b86a67528a5ad09da69db)) +* switch share notif username to sender ([cd65217](https://github.com/HeyPuter/puter/commit/cd65217f5cda1c986ee231e2eeeef5abefa36ecb)) +* **Terminal:** Accept input from Chrome on Android ([4ef3e53](https://github.com/HeyPuter/puter/commit/4ef3e53de34f0097950a7e707ca2483863beafb5)) +* Throw an error when readdir is called on a non-directory ([46eb4ed](https://github.com/HeyPuter/puter/commit/46eb4ed2b96c235e10e15645a30d2f192a1af0de)) +* type error in puter-site ([d96f924](https://github.com/HeyPuter/puter/commit/d96f924cad7a13ea6e9084bb0ebb79ecc5fcb8a3)) +* ui color input attributes ([d9c4fbb](https://github.com/HeyPuter/puter/commit/d9c4fbbd1dcce12ee05ee33652a5fa518196463d)) +* **ui:** improve Component base class ([f8780d0](https://github.com/HeyPuter/puter/commit/f8780d032b10138851c22af53b8610c578139acc)) +* update email share object ([9033f6f](https://github.com/HeyPuter/puter/commit/9033f6f8c74ef8739294d640ac1c7eba95519bbd)) +* update PD alert custom details ([2f16322](https://github.com/HeyPuter/puter/commit/2f163221bdde09425cae11ef7f8e4eb0b10c7103)) +* update test kernel ([55c609b](https://github.com/HeyPuter/puter/commit/55c609b3fec4ef018febc6e88c44a6277960d728)) +* validate size metadata ([2008db0](https://github.com/HeyPuter/puter/commit/2008db08524259264a0c8186a34fc75d7a133f5f)) + +## 2.3.0 (2024-05-22) + + +### Features + +* add /healthcheck endpoint ([c166560](https://github.com/HeyPuter/puter/commit/c166560ff4ab5a453d3ec4f97326c995deb7f522)) +* Add command names to phoenix tab-completion ([cf0eee1](https://github.com/HeyPuter/puter/commit/cf0eee1fa35328e05aefc8a425b5977efe5f4ec9)) +* add option to change desktop background to default ([03f05f3](https://github.com/HeyPuter/puter/commit/03f05f316f11e8afe5fcee40b2b80a0de5e6826f)) +* allow apps to add a menubar via puter.js ([331d9e7](https://github.com/HeyPuter/puter/commit/331d9e75428ec7609394f59b1755374c7340f83e)) +* Allow querying puter-apps driver by partial app names ([dc5b010](https://github.com/HeyPuter/puter/commit/dc5b010d0913d2151b4851f8da5df72d2c8f42e7)) +* Display upload errors in UIWindowProgress dialog ([edebbee](https://github.com/HeyPuter/puter/commit/edebbee9e7e9efbb33bf709b637c103be40d15a8)) +* Implement 'Like' predicate in entity storage ([a854a0d](https://github.com/HeyPuter/puter/commit/a854a0dc0aa79a31695db833184c5ca3698632a9)) +* improve password recovery experience ([04432df](https://github.com/HeyPuter/puter/commit/04432df5540811710ce1cc47ce6c136e5453bccb)) +* **security:** add ip rate limiting ([ccf1afc](https://github.com/HeyPuter/puter/commit/ccf1afc93c24ee7f9a126216209a185d6b4d9fe4)) +* Show "Deleting /foo" in progress window when deleting files ([f07c13a](https://github.com/HeyPuter/puter/commit/f07c13a50cee790eec44bce2f6e56fbcbf73f9b0)) + + +### Bug Fixes + +* Add missing file extension to 0009_app-prefix-fix.sql in DB init ([a8160a8](https://github.com/HeyPuter/puter/commit/a8160a8cdcdd6aff98728a6f1643d93386e6bb5a)) +* Add missing TextEncoder to PTT ([8d4a1e0](https://github.com/HeyPuter/puter/commit/8d4a1e0ed3872e2c82b9e4be9b6d8b359e9cea09)) +* Correct APIError imports ([062e23b](https://github.com/HeyPuter/puter/commit/062e23b5c9673db1f8b0ff0469289d52dd1e3f99)) +* Correct grep output when asking for line numbers ([c8a20ca](https://github.com/HeyPuter/puter/commit/c8a20cadbfd539d185d32f4558916825fcf265ba)) +* Correct inverted instanceof check in SignalReader.read() ([d4c2b49](https://github.com/HeyPuter/puter/commit/d4c2b492ef4864804776d3cb7d24797fdc536886)) +* Correct variables used in errors in sign.js ([fa7c6be](https://github.com/HeyPuter/puter/commit/fa7c6bee9699527028be0ae9759155bc67c52324)) +* Eliminates duplicate translation keys ([5800350](https://github.com/HeyPuter/puter/commit/5800350b253994dea410afff64e3df2a171e7775)) +* fix error handling for outdated node versions ([4c1d5a4](https://github.com/HeyPuter/puter/commit/4c1d5a4b6d009ce075897d499d3517219bd745a4)) +* Fix phoenix app prefix and TokenService test ([afb9d86](https://github.com/HeyPuter/puter/commit/afb9d866b5091058711db931cde904947e661c15)) +* increase QR code size ([d2de46e](https://github.com/HeyPuter/puter/commit/d2de46edfbc05d132d5c929f6935b82515fbbda0)) +* Make PathCommandProvider reject queries with path separators ([d733119](https://github.com/HeyPuter/puter/commit/d73311945610417a1ebc7bb0723ced0a599594b4)) +* Make url variable accessible to all users of it ([2f30ae7](https://github.com/HeyPuter/puter/commit/2f30ae7a825adcd8da95888c38fe39c34acee0ff)) +* Only run Component initialization functions once ([5b43358](https://github.com/HeyPuter/puter/commit/5b43358219402bee3eadf4a0f184a4b924d3293b)) +* Parse octal echo escapes ([6ad8f5e](https://github.com/HeyPuter/puter/commit/6ad8f5e06abd050d319271f818d72debf5bc8e44)) +* reduce token lengths ([5a76bad](https://github.com/HeyPuter/puter/commit/5a76bad28dfd8ec89a309941e410a54927fae22d)) +* reliability issue :bug: ([1d546d9](https://github.com/HeyPuter/puter/commit/1d546d9ef70ef9066ad5838e9782ae330d289f29)) +* Remove null or duplicate app entries from suggest_app_for_fsentry() ([6900233](https://github.com/HeyPuter/puter/commit/6900233c5aaa2d1a49f495e9f9a060796757a91e)) +* **security:** always use application/octet-stream ([74e213a](https://github.com/HeyPuter/puter/commit/74e213a534dbf2844c8cebeee7eb59ec70de306e)) +* **security:** Fix session revocation ([eb166a6](https://github.com/HeyPuter/puter/commit/eb166a67a9f0caf4fd77f9e27dc8209c2fc51f4c)) +* **security:** Move token for socket.io to request body ([49b257e](https://github.com/HeyPuter/puter/commit/49b257ecffbb1e12090b86a67528a5ad09da69db)) +* **security:** Prevent email enumeration ([ed70314](https://github.com/HeyPuter/puter/commit/ed703146863f896df76c98fad7127c6748c0ef9b)) +* **security:** skip cache when checking old passwd ([7800ef6](https://github.com/HeyPuter/puter/commit/7800ef61029c8d1ba47491b4028a0cb972298725)) +* **Terminal:** Accept input from Chrome on Android ([4ef3e53](https://github.com/HeyPuter/puter/commit/4ef3e53de34f0097950a7e707ca2483863beafb5)) +* test release-please action [#3](https://github.com/HeyPuter/puter/issues/3) ([8fb0a66](https://github.com/HeyPuter/puter/commit/8fb0a66ef21921990e564e5f61c0e80e7f929dc7)) +* test release-please action [#4](https://github.com/HeyPuter/puter/issues/4) ([f392de7](https://github.com/HeyPuter/puter/commit/f392de722a5232b622ed91b656a31cdc443c2e84)) +* typographical error :bug: ([2949f71](https://github.com/HeyPuter/puter/commit/2949f71691eb0a258888c5d2a5bb496d2fe64a23)) +* typographical errors :bug: ([4d30740](https://github.com/HeyPuter/puter/commit/4d30740198402cd1cc61b9ea4c45e006b69ec87e)) +* Use correct variable for version number ([52d5299](https://github.com/HeyPuter/puter/commit/52d52993744dffa9f7f59a232da5df9077560731)) +* use primary read in signup ([30f17ad](https://github.com/HeyPuter/puter/commit/30f17ade3a893d2283316e581836607e2029f9b9)) + +## [2.2.0](https://github.com/HeyPuter/puter/compare/v2.1.1...v2.2.0) (2024-04-23) + + +### Features + +* add /healthcheck endpoint ([c166560](https://github.com/HeyPuter/puter/commit/c166560ff4ab5a453d3ec4f97326c995deb7f522)) +* allow apps to add a menubar via puter.js ([331d9e7](https://github.com/HeyPuter/puter/commit/331d9e75428ec7609394f59b1755374c7340f83e)) + +## [2.1.1](https://github.com/HeyPuter/puter/compare/v2.1.0...v2.1.1) (2024-04-22) + + +### Bug Fixes + +* test release-please action [#3](https://github.com/HeyPuter/puter/issues/3) ([8fb0a66](https://github.com/HeyPuter/puter/commit/8fb0a66ef21921990e564e5f61c0e80e7f929dc7)) +* test release-please action [#4](https://github.com/HeyPuter/puter/issues/4) ([f392de7](https://github.com/HeyPuter/puter/commit/f392de722a5232b622ed91b656a31cdc443c2e84)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..3e6c94a4a81f653fafa0b172ef2a9fbe9506b0fc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to Puter + +Welcome to Puter, the open-source distributed internet operating system. We're excited to have you contribute to our project, whether you're reporting bugs, suggesting new features, or contributing code. This guide will help you get started with contributing to Puter in different ways. + +
+ +# Report bugs + +Before reporting a bug, please check [the issues on our GitHub repository](https://github.com/HeyPuter/puter/issues) to see if the bug has already been reported. If it has, you can add a comment to the existing issue with any additional information you have. + +If you find a new bug in Puter, please [open an issue on our GitHub repository](https://github.com/HeyPuter/puter/issues/new). We'll do our best to address the issue as soon as possible. When reporting a bug, please include as much information as possible, including: + +- A clear and descriptive title +- A description of the issue +- Steps to reproduce the bug +- Expected behavior +- Actual behavior +- Screenshots, if applicable +- Your host operating system and browser +- Your Puter version, location, ... + +Please open a separate issue for each bug you find. + +Maintainers will apply the appropriate labels to your issue. + +
+ +# Suggest new features + +If you have an idea for a new feature in Puter, please open a new discussion thread on our [GitHub repository](https://github.com/HeyPuter/puter/discussions) to discuss your idea with the community. We'll do our best to respond to your suggestion as soon as possible. + +When suggesting a new feature, please include as much information as possible, including: + +- A clear and descriptive title +- A description of the feature +- The problem the feature will solve +- Any relevant screenshots or mockups +- Any relevant links or resources + +
+ +# Contribute code + +If you'd like to contribute code to Puter, you need to fork the project and submit a pull request. If this is your first time contributing to an open-source project, we recommend reading this short guide by GitHub on [how to contribute to a project](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project). + +We'll review your pull request and work with you to get your changes merged into the project. + +
+ +## PR Standards + +We expect the following from pull requests (it makes things easier): +- If you're closing an issue, please reference that issue in the PR description +- Avoid whitespace changes +- No regressions for "appspace" (Puter apps) + +
+ +## Code Review + +Once you've submitted your pull request, the project maintainers will review your changes. We may suggest some changes or improvements. This is a normal part of the process, and your contributions are greatly appreciated! + +
+ +## Contribution License Agreement (CLA) + +Like many open source projects, we require contributors to sign a Contribution License Agreement (CLA) before we can accept your code. When you open a pull request for the first time, a bot will automatically add a comment with a link to the CLA. You can sign the CLA electronically by following the link and filling out the form. + +
+ +# Getting Help + +If you have any questions about Puter, please feel free to reach out to us through the following channels: + +- [Discord](https://discord.com/invite/PQcx7Teh8u) +- [Reddit](https://www.reddit.com/r/Puter/) +- [Twitter](https://twitter.com/HeyPuter) +- [Email](mailto:support@puter.com) diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..67f7d057957541a55e5c689f391c50888a42c971 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/README.md b/README.md index 3baee773db5e8070291123b24ed1dc62f05dfcdf..7b1b7d649f512b45b510eec0d86921fedb7b1efc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,187 @@ ---- -title: Puter Deploy -emoji: 🚀 -colorFrom: indigo -colorTo: pink -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +--- +title: Puter +emoji: 🖥️ +colorFrom: blue +colorTo: purple +sdk: docker +pinned: false +--- + +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

The Internet OS! Free, Open-Source, and Self-Hostable.

+ +

+ « LIVE DEMO » +
+
+ Puter.com + · + App Store + · + Developers + · + CLI + · + Discord + · + Reddit + · + X +

+ +

screenshot

+ +
+ +## Puter + +Puter is an advanced, open-source internet operating system designed to be feature-rich, fast, and highly extensible. Puter can be used as: + +- A privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time. +- A platform for building and publishing websites, web apps, and games. +- An alternative to Dropbox, Google Drive, OneDrive, etc. with a fresh interface and powerful features. +- A remote desktop environment for servers and workstations. +- A friendly, open-source project and community to learn about web development, cloud computing, distributed systems, and much more! + +
+ +## Getting Started + +### 💻 Local Development + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` +**→** This should launch Puter at + http://puter.localhost:4100 (or the next available port). + + + +If this does not work, see [First Run Issues](./doc/self-hosters/first-run-issues.md) for +troubleshooting steps. + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` +**→** This should launch Puter at + http://puter.localhost:4100 (or the next available port). + +
+ +### 🐙 Docker Compose + +#### Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +**→** This should be available at + http://puter.localhost:4100 (or the next available port). + +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +**→** This should launch Puter at + http://puter.localhost:4100 (or the next available port). + +
+ +### 🚀 Self-Hosting + +For detailed guides on self-hosting Puter, including configuration options and best practices, see our [Self-Hosting Documentation](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md). + +
+ +### ☁️ Puter.com + +Puter is available as a hosted service at [**puter.com**](https://puter.com). + +
+ +## System Requirements + +- **Operating Systems:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB recommended) +- **Disk Space:** 1GB free space +- **Node.js:** Version 20.19.5+ (Version 23+ recommended) +- **npm:** Latest stable version + +
+ +## Support + +Connect with the maintainers and community through these channels: + +- Bug report or feature request? Please [open an issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Security issues? [security@puter.com](mailto:security@puter.com) +- Email maintainers at [hi@puter.com](mailto:hi@puter.com) + +We are always happy to help you with any questions you may have. Don't hesitate to ask! + +
+ +## License + +This repository, including all its contents, sub-projects, modules, and components, is licensed under [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) unless explicitly stated otherwise. Third-party libraries included in this repository may be subject to their own licenses. + +
+ +## Translations + +- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md) +- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md) +- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md) +- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md) +- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md) +- [English](https://github.com/HeyPuter/puter/blob/main/README.md) +- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md) +- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md) +- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md) +- [German / Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md) +- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md) +- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md) +- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md) +- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md) +- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md) +- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md) +- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md) +- [Malay / Bahasa Malaysia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.my.md) +- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md) +- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md) +- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md) +- [Punjabi / ਪੰਜਾਬੀ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pa.md) +- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md) +- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md) +- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md) +- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md) +- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md) +- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md) +- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md) +- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md) +- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md) +- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md) +- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md) diff --git a/SECURITY-ACKNOWLEDGEMENTS.md b/SECURITY-ACKNOWLEDGEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..d54620972c5ed2c91be6723f3ca66d02da125283 --- /dev/null +++ b/SECURITY-ACKNOWLEDGEMENTS.md @@ -0,0 +1,9 @@ +We would like to thank the following security researchers for their responsible disclosures: + + +# 2024 + +- Ritesh Sahu [GitHub](https://github.com/riteshs4hu/) | [X](https://x.com/riteshs4hu) | [Website](https://medium.com/@riteshs4hu) +- Tim Suess: [GitHub](https://github.com/blackfortresslabs) | [Email](tim@blackfortresslabs.com) | [Website](https://www.blackfortresslabs.com) +- xyzeva: [Github](https://github.com/xyzeva) | [Email](mailto:xyzeva@riseup.net) | [Website](https://kibty.town/) +- Yusuf Kelany: [GitHub](https://github.com/YusufYaser) | [X](https://x.com/RealYusufYaser) | [Website](https://yusufyaser.xyz) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..f1fc70551b3a0bea49fc1e4688c2a1b55fba540f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Puter Security Policy + +Thank you for helping make Puter safe. Keeping user information safe and secure is a top priority, and we welcome the contribution of external security researchers. + +
+ +# Scope + +If you believe you've found a security issue in software that is maintained in this repository, we encourage you to notify us. + +
+ +# How to Submit a Report + +To submit a vulnerability report, please contact us at security@puter.com. Your submission will be reviewed and validated by a member of our team. + +> [!WARNING] +> Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests. + +
+ +# Safe Harbor + +We support safe harbor for security researchers who: + +* Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our services. +* Only interact with accounts you own or with explicit permission of the account holder. If you do encounter Personally Identifiable Information (PII) contact us immediately, do not proceed with access, and immediately purge any local information. +* Provide us with a reasonable amount of time to resolve vulnerabilities prior to any disclosure to the public or a third-party. + +We will consider activities conducted consistent with this policy to constitute "authorized" conduct and will not pursue civil action or initiate a complaint to law enforcement. We will help to the extent we can if legal action is initiated by a third party against you. + +Please submit a report to us before engaging in conduct that may be inconsistent with or unaddressed by this policy. + +
+ +# Preferences + +* Please provide detailed reports with reproducible steps and a clearly defined impact. +* Include the version number of the vulnerable package in your report +* Social engineering (e.g. phishing, vishing, smishing) is prohibited. diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000000000000000000000000000000000000..08303c86769bd46584a00c0d43db90d99a681cdf --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,214 @@ +# Trademark Guidelines + +Version 1.0 dated January 1, 2025 + +Puter Technologies Inc. Logo + +
+ + +This trademark policy was prepared to help you understand how to use the Puter trademarks, service marks and logos with Puter Technologies Inc.'s Puter software. + +While some of our software is available under a free and open source software license, that copyright license does not include a license to use our trademark, and this Policy is intended to explain how to use our marks consistent with background law and community expectation. + +This Policy covers: + +1. Our **word** trademarks and service marks: Puter Technologies Inc., Puter, Puter.com +2. Our **logos**: The Puter Technologies Inc. logo at the top of this policy + +This policy encompasses all trademarks and service marks, whether they are registered or not. + +
+
+ +## 1. GENERAL GUIDELINES + +Whenever you use one of our marks, you must always do so in a way that does not mislead anyone about what they are getting and from whom. For example, you cannot say you are providing the Puter software when you're providing a modified version of it, because recipients may not understand the differences between your modified versions and our own. + +You also cannot use our logo on your website in a way that suggests that your website is an official website or that we endorse your website. + +You can, though, say you like the Puter software, that you participate in the Puter community, that you are providing an unmodified version of the Puter software. + +You may not use or register our marks, or variations of them as part of your own trademark, service mark, domain name, company name, trade name, product name or service name. + +Trademark law does not allow your use of names or trademarks that are too similar to ours. You therefore may not use an obvious variation of any of our marks or any phonetic equivalent, foreign language equivalent, takeoff, or abbreviation for a similar or compatible product or service. We would consider the following too similar to one of our Marks: + +- MyPuter +- PuterFooBar + +
+
+ +## 2. ACCEPTABLE USES + +
+ +### Distribution of Unmodified Software + +When you redistribute an unmodified copy of Puter software, you must retain all trademarks, logos, and notices we have placed on the software to identify its origin. This includes: + +* Binary distributions exactly as we provide them +* Source code distributions exactly as we provide them +* Documentation and other materials directly from our official repositories + +
+ +### Distribution of Modified Software + +If you distribute a modified version of Puter software, you: + +* Must remove all Puter logos from the modified software +* May use our word marks (but not logos) to accurately describe the software's origin +* Must clearly indicate that the software has been modified +* Must include a notice stating: "This software is a modified version of Puter software and is not endorsed by Puter Technologies Inc." + +Example of acceptable description: "This software is derived from Puter software and includes modifications for [describe your changes]." + +
+ +### Compatibility Statements + +You may use our word marks (but not logos) to accurately describe your software's compatibility with Puter software under these conditions: + +* Your statements about compatibility must be accurate and not misleading +* You must include the following notice: "Puter is a trademark of Puter Technologies Inc. This [product/service] is not affiliated with or endorsed by Puter Technologies Inc." +* You may not suggest that Puter Technologies Inc. has certified or approved your software + + +
+ +### Products Built for Puter + +You may describe your product as working with or being built for Puter if: + +* Your product is fully compatible with the documented Puter APIs +* Your product name follows this format: "[Your Product Name] for Puter" +* You include this notice in all materials: "Puter is a trademark of Puter Technologies Inc. [Your Product Name] is not affiliated with or endorsed by Puter Technologies Inc." +* Your branding and marketing materials do not create confusion about the source of your product + +
+ +### Open Source Projects + +For open source projects that interact with or extend Puter software: + +* You may use "puter" as part of your project name only if: + * The name is in the format "[descriptor]-puter" (e.g., "auth-puter", "backup-puter") + * The project's README clearly states it's not officially associated with Puter + * The project maintains compatibility with current Puter APIs +* You must not use our logos without explicit permission +* You must include appropriate trademark attribution notices + +
+ +### Community Activities + +You may use our word marks (but not logos) for non-commercial community activities: + +* User groups and meetups focused on Puter software +* Educational content about Puter software +* Blog posts, videos, articles, or tutorials about Puter software + +Conditions for community use: + +* Activities must be non-commercial +* Any fees charged must only cover actual costs +* You must include appropriate trademark attribution +* You must not suggest official endorsement without explicit permission + +
+ +### Merchandise and Promotional Items + +You may not create merchandise or promotional items bearing our marks without explicit written permission from Puter Technologies Inc. + +
+ +### Academic and Research Use + +You may use our word marks (but not logos) in: + +* Academic papers +* Research publications +* Technical documentation +* Educational materials + +Include appropriate citations and trademark attributions in such uses. + +
+ +### Online Content and Social Media + +When using our marks in online content: + +* You may use our word marks in hashtags, handles, or usernames if: + * The content is clearly about Puter software + * You don't imply official status + * You include appropriate trademark attribution +* You must not register social media accounts that could be confused with official Puter accounts + +
+ +### APIs and Development + +When developing with Puter APIs: + +* You may use our word marks to accurately describe your integration +* You must not use our marks in a way that suggests your API or service is endorse by +Puter or provided by Puter +* You must include appropriate trademark attribution + +All uses described above must also comply with the General Guidelines section of this policy and maintain the integrity of our marks as described in the How to Display Our Marks section. + +
+ +### No Domain Names + +You must not register any domain that includes our word marks or any variant or combination of them. + +
+
+ +## 3. HOW TO DISPLAY OUR MARKS + +When you have the right to use our mark, here is how to display it. + +
+ +### Trademark marking and legends + +The first or most prominent mention of a mark on a webpage, document, or documentation should be accompanied by a symbol indicating whether the mark is a registered trademark ("®") or an unregistered trademark ("™"). If you don't know which applies, contact us. + +Place the following notice at the foot of the page where you have used the mark: "Puter is trademark of Puter Technologies Inc." + +
+ +### Use of trademarks in text + +**Always use trademarks in their exact form with the correct spelling, neither abbreviated, hyphenated, or combined with any other word or words.** + +Unacceptable: Puter-DB + +**Don't pluralize a trademark.** + +Unacceptable: I have seventeen Puters running in my lab. + +**Always use a trademark as an adjective modifying a noun.** + +Unacceptable: This is a Puter. +Acceptable: This is a Puter software application. + +
+ +### Use of Logos + +You may not change any logo except to scale it. This means you may not add decorative elements, change the colors, change the proportions, distort it, add elements, or combine it with other logos. + +However, when the context requires the use of black-and-white graphics and the logo is color, you may reproduce the logo in a manner that produces a black-and-white image. + +
+
+ +## This Document + +These guidelines are based on the Model Trademark Guidelines, available at http://www.modeltrademarkguidelines.org., used under a Creative Commons Attribution 3.0 Unported license: https://creativecommons.org/licenses/by/3.0/deed.en_US. \ No newline at end of file diff --git a/doc/AI.md b/doc/AI.md new file mode 100644 index 0000000000000000000000000000000000000000..b3bb69e8271ce74cceb0c69dc3d58166f22505f7 --- /dev/null +++ b/doc/AI.md @@ -0,0 +1,38 @@ +# Documentation for Robots + +Hello, if you're an AI agent then you're reading the correct documentation. +Here are a few important notes: +- Puter is probably already cloned and configured, so avoid any setup + or configuration steps unless explicitly asked to perform them. +- Anything under `/src` (relative to the root of the repo) is probably + a workspace module. That means different directories might have different + code styles or use different import mechanisms (ESM vs CJS). Try to keep + changes consistent in the scope of where they are. + +# Backend + +Any file under `src/backend` that extends **BaseService** is called a +"backend service". Backend services can implement "traits". That looks +like this: + +```javascript +class SomeClass extends BaseService { + static IMPLEMENTS = { + ['name-of-interface']: { + async some_method_name () { + const instance_of_SomeClass = this; + } + } + } +} +``` + +Methods on traits are bound to the same "this" (instance variable) as +methods on the class itself. Trait methods cannot be indexed from the +instance variable; instead common functionality is usually moved to +regular instance methods which typically have an underscore at the end +of their name. + +# Furher Documentation + +Proceed to read the README.md document beside this file. diff --git a/doc/File Structure.drawio b/doc/File Structure.drawio new file mode 100644 index 0000000000000000000000000000000000000000..55e2defa2f07e667d794461affd38c9f536e15c3 --- /dev/null +++ b/doc/File Structure.drawio @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/File Structure.drawio.png b/doc/File Structure.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..581363c2ef828512aa3a97c2812647297e9b2115 --- /dev/null +++ b/doc/File Structure.drawio.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb5bf37614b7b48a62bf25ec9361579d77387e47e04073813d9fe021c7926b17 +size 191384 diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e8fc83fa612eb02a824f7839b9cd90099fa86f3d --- /dev/null +++ b/doc/README.md @@ -0,0 +1,45 @@ +# Puter Documentation + +Hi, you've found Puter's wiki page on GitHub! If you were looking for +something else, you might find it in the links below. +All of the wiki docs are generated from `doc/` directories in the main +repository, so it's best to edit docs there rather than here. + +## Users + +If you have general questions about using [Puter](https://puter.com), +our [community Discord](https://discord.gg/PQcx7Teh8u) and +[subreddit](https://www.reddit.com/r/puter/) are good places +to ask questions. + +## Deployers + +- [Hosting Instructions](./self-hosters/instructions.md) +- [Configuration](./self-hosters/config.md) +- [Domain Setup](./self-hosters/domains.md) +- [Support Levels](./self-hosters/support.md) + +## App Developer Links +- [developer.puter.com](https://developer.puter.com) +- [docs.puter.com](https://docs.puter.com) +- share your apps on [Reddit](https://www.reddit.com/r/puter/) or + [Discord](https://discord.gg/PQcx7Teh8u) + +## Contributor Documentation + +### Where to Start + +Start with [Repo Structure and Tooling](./contributors/structure.md). + +### Index + +- **Conventions** + - [Repo Structure and Tooling](./contributors/structure.md) + - How directories and files are organized in our GitHub repo + - What tools are used to build parts of Puter + - [Comment Prefixes](./contributors/comment_prefixes.md) + - A convention we use for line comments in code + +- [Frontend Documentation](/src/gui/doc) +- [Backend Documentation](/src/backend/doc) +- [Extensions](./contributors/extensions/) diff --git a/doc/RFCS/20250826_captcha_cloudflare_turnstile.md b/doc/RFCS/20250826_captcha_cloudflare_turnstile.md new file mode 100644 index 0000000000000000000000000000000000000000..142aed979046b3a607998399e8147497537b7a17 --- /dev/null +++ b/doc/RFCS/20250826_captcha_cloudflare_turnstile.md @@ -0,0 +1,57 @@ +- Feature Name: Cloudflare Turnstile CAPTCHA +- Status: Completed +- Created: 2025-08-26 + +## Summary + +We propose integrating **Cloudflare Turnstile** to protect our signup flow against automated bot activity, while maintaining a seamless experience for legitimate users. + +## Motivation + +Puter allocates resources to **free** user account — including storage, compute, and AI credits. To prevent these from being exploited by bots, we need a more robust verification mechanism. Although Puter currently includes a [custom CAPTCHA service](https://github.com/HeyPuter/puter/blob/4c3a68ee51a1b255edbe6b3c7e4c4e3b0394dae3/src/backend/src/modules/captcha/services/CaptchaService.js), it has several shortcomings: + +* The text-recognition CAPTCHA creates friction and disrupts the user experience. +* Maintaining a token pool is resource-intensive and doesn’t scale well. The validation logic also requires ongoing maintenance within the codebase. + +## Choose of Service Provider + +We choose Cloudflare Turnstile since: + +* It's free for unlimited use. +* It's easy to integrate. +* It's relative secure. + +Here's a comparison of major CAPTCHA providers: + + +| Provider | Security (typical) | User experience (typical) | Price (publicly listed) | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cloudflare Turnstile** | **High** for most sites; adaptive challenges; works without image puzzles. | **Excellent** (can be fully invisible or auto-verify; checkbox only for risky traffic). | **Free for everyone (unlimited use)**. ([The Cloudflare Blog](https://blog.cloudflare.com/turnstile-ga/?utm_source=chatgpt.com), [cloudflare.com](https://www.cloudflare.com/application-services/products/turnstile/?utm_source=chatgpt.com)) | +| **Google reCAPTCHA (Essentials / Standard / Enterprise)** | **Medium–High** (v3 score + server rules; Enterprise adds features & support). | **Good–OK** (v3 is invisible; v2 can show puzzles). | **Free up to 10k assessments/mo; \$8 for up to 100k/mo; then \$1 per 1k** (Enterprise tiers). ([Google Cloud](https://cloud.google.com/recaptcha/docs/compare-tiers?utm_source=chatgpt.com)) | +| **hCaptcha (Basic / Pro / Enterprise)** | **High** (ML signals; enterprise options). | **Good** on Basic; **Very good** on Pro with “low-friction 99.9% passive mode.” | **Basic: Free. Pro: \$99/mo annual (\$139 month-to-month) incl. 100k evals, then \$0.99/1k**; Enterprise custom. ([hcaptcha.com](https://www.hcaptcha.com/pricing?utm_source=chatgpt.com)) | +| **Friendly Captcha** | **Medium–High** (proof-of-work + risk signals). | **Excellent** (invisible/automatic challenge; no image tasks). | **Starter €9/mo (1k req/mo); Growth €39/mo (5k/mo); Advanced €200/mo (50k/mo); Free non-commercial 1k/mo**; Enterprise custom. ([Friendly Captcha](https://friendlycaptcha.com/)) | +| **Arkose Labs (FunCaptcha / MatchKey)** | **Very High** (step-up, anti-farm, enterprise focus). | **Good–OK** (challenge can be more involved when risk is high). | **Enterprise pricing (contact sales)**; publicly not listed. (Product overview only.) ([Arkose Labs](https://www.arkoselabs.com/arkose-matchkey/?utm_source=chatgpt.com)) | + +## Implementation + +### Signup Flow + +When a user submits the signup form, the client will include a **Turnstile token** alongside the other form data. +On the backend, Puter will call the **Cloudflare Turnstile verification API** to validate this token before provisioning a new account. + +Only if the token is verified as valid will the signup request be processed. Invalid or missing tokens will result in a rejected signup attempt. + +## Setup + +1. Create a new *Widget* on the Cloudflare Turnstile dashboard. +2. Configure *Widget name* and *Hostnames*. +3. Set *Widget Mode* to **Managed** and *pre-clearance* to **Yes - Interactive**. These settings minimize friction for legitimate users while also giving suspicious users one more chance to clear the CAPTCHA. (See [Turnstile widgets · Cloudflare Turnstile docs](https://developers.cloudflare.com/turnstile/concepts/widget/) for details) +4. Add Site Key and Secret Key to the config file (default location: `volatile/config/config.json`): + + ``` + "cloudflare-turnstile": { + "enabled": true, + "site_key": "", + "secret_key": "" + } + ``` diff --git a/doc/api/README.md b/doc/api/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2883e7cfe45327d56bbd130613853bd51cf8de4c --- /dev/null +++ b/doc/api/README.md @@ -0,0 +1,5 @@ +# API Documentation + +Note that this documentation is different from the [puter.js docs](https://docs.puter.com). +The scope of the documentation in this directory includes both stable API endpoints that +are used by **puter.js**, as well as API endpoints that may be subject to future changes. diff --git a/doc/api/concepts/share-link.md b/doc/api/concepts/share-link.md new file mode 100644 index 0000000000000000000000000000000000000000..b3cf6a5311d74df1baba3baa04147a78d4406c6b --- /dev/null +++ b/doc/api/concepts/share-link.md @@ -0,0 +1,9 @@ +# Share Links + +A **share link** is a link to Puter's origin which contains a token +in the query string (the key is `share_token`; ex: +`http://puter.localhost:4100?share_token=...`). + +This token can be used to apply permissions to the user of the +current session **if and only if** this user's email is confirmed +and matches the share link's associated email. diff --git a/doc/api/drivers.md b/doc/api/drivers.md new file mode 100644 index 0000000000000000000000000000000000000000..d1dce40c7050ebb81ced5f4d3610cec49b8912a2 --- /dev/null +++ b/doc/api/drivers.md @@ -0,0 +1,60 @@ +## Puter Drivers + +### **POST** `/drivers/call` + +#### Notes + +- **HTTP response status** - + A successful driver response, even if the response is an error message, will always have HTTP status `200`. Note that sometimes this will include rate limit and usage limit errors as well. + +This endpoint allows you to call a Puter driver. Whether or not the +driver call fails, this endpoint will respond with HTTP 200 OK. +When a driver call fails, you will get a JSON response from the driver +with + +#### Parameters + +Parameters are provided in the request body. The content type of the +request should be `application/json`. + +- **interface:** `string` + - **description:** The type of driver to call. For example, + LLMs use the interface called `puter-chat-completion`. +- **service:** `string` + - **description:** The name of the service to use. For example, the `claude` service might be used for `puter-chat-completion`. +- **method:** `string` + - **description:** The name of the method to call. For example, LLMs implement `complete` which does a chat completion, and `list` which lists models. +- **args:** `object` + - **description:** Parametized arguments for the driver call. For example, `puter-chat-completion`'s `complete` method supports the arguments `messages` and `temperature` (and others), so you might set this to `{ "messages": [...], "temperature": 1.2 }` + +#### Example +```json +{ + "interface": "", + "service": "", + "method": "", + "args": { "parametized": "arguments" } +} +``` + +#### Response + +- **Error Response** - Driver error responses will always have **status 200**, content type `application/json`, and a response body in this format: + ```json + { + "success": false, + "error": { + "code": "string identifier for the error", + "message": "some message about the error", + } + } + ``` +- **Success Response** - The success response is either a JSON response + wrapped in `{ "success": true, "result": ___ }`, or a response with a + `Content-Type` that is **not** `application/json`. + ```json + { + "success": true, + "result": {} + } + ``` \ No newline at end of file diff --git a/doc/api/group.md b/doc/api/group.md new file mode 100644 index 0000000000000000000000000000000000000000..89168e062567c4110bb1650eb53be79243173eed --- /dev/null +++ b/doc/api/group.md @@ -0,0 +1,219 @@ +# Group Endpoints + +## POST `/group/create` (auth required) + +### Description + +Creates a group and returns a UID (UUID formatted). +Groups do not have names, or any other descriptive attributes. +Instead they are always identified with a UUID, and they have +a `metadata` property. + +The `metadata` property will always be given back to the client +in the same way it was provided. The `extra` property, also an +object, may be changed by the backend. The behavior of setting +any property on `extra` is currently undefined as all properties +are reserved for future use. + +### Parameters + +- **metadata:** _- optional_ + - **accepts:** `object` + - **description:** arbitrary metadata to describe the group +- **extra:** _- optional_ + - **accepts:** `object` + - **description:** extra parameters (server may change these) + +### Request Example + +```javascript +await fetch(`${window.api_origin}/group/create`, { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + metadata: { title: 'Some Title' } + }), + "method": "POST", +}); + +// { uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6' } +``` + +### Response Example + +```json +{ + "uid": "9c644a1c-3e43-4df4-ab67-de5b68b235b6" +} +``` + +## POST `/group/add-users` + +### Description + +Adds one or more users to a group + +### Parameters + +- **uid:** _- required_ + - **accepts:** `string` + UUID of an existing group +- **users:** `Array` + usernames of users to add to the group + +### Request Example + +```javascript +await fetch(`${window.api_origin}/group/add-users`, { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6', + users: ['first_user', 'second_user'], + }), + "method": "POST", +}); +``` + +## POST `/group/remove-users` + +### Description + +Remove one or more users from a group + +### Parameters + +- **uid:** _- required_ + - **accepts:** `string` + UUID of an existing group +- **users:** `Array` + usernames of users to remove from the group + +### Request Example + +```javascript +await fetch(`${window.api_origin}/group/add-users`, { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6', + users: ['first_user', 'second_user'], + }), + "method": "POST", +}); +``` + +## GET `/group/list` + +### Description + +List groups associated with the current user + +### Parameters + +_none_ + +### Response Example + +```json +{ + "owned_groups": [ + { + "uid": "c3bd4047-fc65-4da8-9363-e52195890de4", + "metadata": {}, + "members": [ + "default_user" + ] + } + ], + "in_groups": [ + { + "uid": "c3bd4047-fc65-4da8-9363-e52195890de4", + "metadata": {}, + "members": [ + "default_user" + ] + } + ] +} +``` + +# Group Permission Endpoints + +## POST `/grant-user-group` + +Grant permission from the current user to a group. +This creates an association between the user and the +group for this permission; the group will only have +the permission effectively while the user who granted +permission has the permission. + +### Parameters + +- **group_uid:** _- required_ + - **accepts:** `string` + UUID of an existing group +- **permission:** _- required_ + - **accepts:** `string` + A permission string + +### Request Example + +```javascript +await fetch("http://puter.localhost:4100/auth/grant-user-group", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + group_uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6', + permission: 'fs:/someuser/somedir/somefile:read' + }), + "method": "POST", +}); +``` + +## POST `/revoke-user-group` + +Revoke permission granted from the current user +to a group. + +### Parameters + +- **group_uid:** _- required_ + - **accepts:** `string` + UUID of an existing group +- **permission:** _- required_ + - **accepts:** `string` + A permission string + +### Request Example + +```javascript +await fetch("http://puter.localhost:4100/auth/grant-user-group", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + group_uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6', + permission: 'fs:/someuser/somedir/somefile:read' + }), + "method": "POST", +}); +``` + +- > **TODO** figure out how to manage documentation that could + reasonably show up in two files. For example: this is a group + endpoint as well as a permission system endpoint. + (architecturally it's a permission system endpoint, and + the permissions feature depends on the groups feature; + at least until a time when PermissionService is refactored + so a service like GroupService can mutate the permission + check sequences) diff --git a/doc/api/notifications.md b/doc/api/notifications.md new file mode 100644 index 0000000000000000000000000000000000000000..43c57d81a10f013063d7368b713285a1a8ef16cf --- /dev/null +++ b/doc/api/notifications.md @@ -0,0 +1,112 @@ +# Notification Endpoints + +Endpoints for managing notifications. + +## POST `/notif/mark-ack` (auth required) + +### Description + +The `/notif/mark-ack` endpoint marks the specified notification +as "acknowledged". This indicates that the user has chosen to either +dismiss or act on this notification. + +### Parameters + +| Name | Description | Default Value | +| ---- | ----------- | -------- | +| uid | UUID associated with the notification | **required** | + +### Response + +This endpoint responds with an empty object (`{}`). + + +## POST `/notif/mark-read` (auth required) + +### Description + +The `/notif/mark-read` endpoint marks that the specified notification +has been shown to the user. It will not "pop up" as a new notification +if they load the gui again. + +### Parameters + +| Name | Description | Default Value | +| ---- | ----------- | -------- | +| uid | UUID associated with the notification | **required** | + +### Response + +This endpoint responds with an empty object (`{}`). + +### Request Example + +```javascript +await fetch("https://api.puter.local/notif/mark-read", { + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + body: JSON.stringify({ + uid: 'a14ea3d5-828b-42f9-9613-35f43b0a3cb8', + }), + method: "POST", +}); +``` +## ENTITY STORAGE `puter-notifications` + +The `puter-notifications` driver is an Entity Storage driver. +It is read-only. + +### Request Examples + +#### Select Unread Notifications + +```javascript +await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'puter-notifications', + method: 'select', + args: { predicate: ['unread'] } + }), + "method": "POST", +}); +``` + +#### Select First 200 Notifications + +```javascript +await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'puter-notifications', + method: 'select', + args: {} + }), + "method": "POST", +}); +``` + +#### Select Next 200 Notifications + +```javascript +await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'puter-notifications', + method: 'select', + args: { offset: 200 } + }), + "method": "POST", +}); +``` diff --git a/doc/api/share.md b/doc/api/share.md new file mode 100644 index 0000000000000000000000000000000000000000..0343bb1c6877c1683e04e726abe3b4aaf1b38b02 --- /dev/null +++ b/doc/api/share.md @@ -0,0 +1,367 @@ +# Share Endpoints + +Share endpoints allow sharing files with other users. + +## POST `/share` (auth required) + +### Description + +The `/share` endpoint shares 1 or more filesystem items +with one or more recipients. The recipients will receive +some notification about the shared item, making this +different from calling `/grant-user-user` with a permission. + +When users are **specified by email** they will receive +a [share link](./concepts/share-link.md). + +Each item specified in the `shares` property is a tag-typed +object of type `fs-share` or `app-share`. + +#### File Shares (`fs-share`) + +File shares grant permission to a file or directory. By default +this is read permission. If `access` is specified as `"write"`, +then write permission will be granted. + +#### App Shares (`app-share`) + +App shares grant permission to read a protected app. + +##### subdomain permission +If there is a subdomain associated with the app, and the owner +of the subdomain is the same as the owner of the app, then +permission to access the subdomain will be granted. +Note that the subdomain is only associated if the subdomain +entry has `associated_app_id` set according to the app's id, +and will not be considered "associated" if only the index_url +happens to match the subdomain url. + +##### appdata permission +If the app has `shared_appdata` set to `true` in its metadata +object, the recipient of the share will also get write permission +to the app owner's corresponding appdata directory. The appdata +directory must exist for this to work as expected +(otherwise the permission rewrite rule fails since the uuid +can't be determined). + +### Example + +```json +{ + "recipients": [ + "user_that_gets_shared_to", + "another@example.com" + ], + "shares": [ + { + "$": "app-share", + "name": "some-app-name" + }, + { + "$": "app-share", + "uid": "app-SOME-APP-UID" + }, + { + "$": "fs-share", + "path": "/some/file/or/directory" + }, + { + "$": "fs-share", + "path": "SOME-FILE-UUID" + } + ] +} +``` + +### Parameters + +- **recipients** _- required_ + - **accepts:** `string | Array` + - **description:** + recipients for the filesystem entries being shared. + - **notes:** + - validation on `string`: email or username + - requirement of at least one value +- **shares:** _- required_ + - **accepts:** `object | Array` + - object is [type-tagged](./type-tagged.md) + - type is either [file-share](./types/file-share.md) + or [app-share](./types/app-share.md) + - **notes:** + - requirement that file/directory or app exists + - requirement of at least one entry +- **dry_run:** _- optional_ + - **accepts:** `bool` + - **description:** + when true, only validation will occur + +### Response + +- **$:** `api:share` +- **$version:** `v0.0.0` +- **status:** one of: `"success"`, `"mixed"`, `"aborted"` +- **recipients:** array of: `api:status-report` or + `heyputer:api/APIError` +- **paths:** array of: `api:status-report` or + `heyputer:api/APIError` +- **dry_run:** `true` if present + +### Request Example + +```javascript +await fetch("http://puter.localhost:4100/share", { + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + body: JSON.stringify({ + recipients: [ + "user_that_gets_shared_to", + "another@example.com" + ], + shares: [ + { + $: "app-share", + name: "some-app-name" + }, + { + $: "app-share", + uid: "app-SOME-APP-UID" + }, + { + $: "fs-share", + path: "/some/file/or/directory" + }, + { + $: "fs-share", + path: "SOME-FILE-UUID" + } + ] + }), + method: "POST", +}); +``` + +### Success Response + +```json +{ + "$": "api:share", + "$version": "v0.0.0", + "status": "success", + "recipients": [ + { + "$": "api:status-report", + "status": "success" + } + ], + "paths": [ + { + "$": "api:status-report", + "status": "success" + } + ], + "dry_run": true +} +``` + +### Error response (missing file) + +```json +{ + "$": "api:share", + "$version": "v0.0.0", + "status": "mixed", + "recipients": [ + { + "$": "api:status-report", + "status": "success" + } + ], + "paths": [ + { + "$": "heyputer:api/APIError", + "code": "subject_does_not_exist", + "message": "File or directory not found.", + "status": 404 + } + ], + "dry_run": true +} +``` + +### Error response (missing user) + +```json +{ + "$": "api:share", + "$version": "v0.0.0", + "status": "mixed", + "recipients": [ + { + "$": "heyputer:api/APIError", + "code": "user_does_not_exist", + "message": "The user `non_existing_user` does not exist.", + "username": "non_existing_user", + "status": 422 + } + ], + "paths": [ + { + "$": "api:status-report", + "status": "success" + } + ], + "dry_run": true +} +``` + +## POST `/sharelink/check` (no auth) + +### Description + +The `/sharelink/check` endpoint verifies that a token provided +by a share link is valid. + +### Example + +```javascript +await fetch(`${config.api_origin}/sharelink/check`, { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + token: '...', + }), + "method": "POST", +}); +``` + +### Parameters + +- **token:** _- required_ + - **accepts:** `string` + The token from the querystring parameter + +### Response + +A type-tagged object, either of type `api:share` or `api:error` + +### Success Response + +```json +{ + "$": "api:share", + "uid": "836671d4-ac5d-4bd3-bc0a-ec357e0d8f02", + "email": "asdf@example.com" +} +``` + +### Error Response + +```json +{ + "$": "api:error", + "message":"Field `token` is required.", + "key":"token", + "code":"field_missing" +} +``` + +## POST `/sharelink/apply` (no auth) + +### Description + +The `/sharelink/apply` endpoint applies a share to the current +user **if and only if** that user's email is confirmed and matches +the email associated with the share. + +### Example + +```javascript +await fetch(`${config.api_origin}/sharelink/apply`, { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + uid: '836671d4-ac5d-4bd3-bc0a-ec357e0d8f02', + }), + "method": "POST", +}); +``` + +### Parameters + +- **uid:** _- required_ + - **accepts:** `string` + The uid of an existing share, received using `/sharelink/check` + +### Response + +A type-tagged object, either of type `api:status-report` or `api:error` + +### Success Response + +```json +{"$":"api:status-report","status":"success"} +``` + +### Error Response + +```json +{ + "message": "This share can not be applied to this user.", + "code": "can_not_apply_to_this_user" +} +``` + +## POST `/sharelink/request` (no auth) + +### Description + +The `/sharelink/request` endpoint requests the permissions associated +with a share link to the issuer of the share (user that sent the share). +This can be used when a user is logged in, but that user's email does +not match the email associated with the share. + +### Example + +```javascript +await fetch(`${config.api_origin}/sharelink/request`, { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + uid: '836671d4-ac5d-4bd3-bc0a-ec357e0d8f02', + }), + "method": "POST", +}); +``` + +### Parameters + +- **uid:** _- required_ + - **accepts:** `string` + The uid of an existing share, received using `/sharelink/check` + +### Response + +A type-tagged object, either of type `api:status-report` or `api:error` + +### Success Response + +```json +{"$":"api:status-report","status":"success"} +``` + +### Error Response + +```json +{ + "message": "This share is already valid for this user; POST to /apply for access", + "code": "no_need_to_request" +} +``` diff --git a/doc/api/type-tagged.md b/doc/api/type-tagged.md new file mode 100644 index 0000000000000000000000000000000000000000..0f86bf2c9b3f79fb4787b89953e0e6783d6cc9f2 --- /dev/null +++ b/doc/api/type-tagged.md @@ -0,0 +1,79 @@ +# Type-Tagged Objects + +```js +{ + "$": "some-type", + "$version": "0.0.0", + + "some_property": "some value", +} +``` + +## What's a Type-Tagged Object? + +Type-Tagged objects are a convention understood by Puter's backend +to communicate meta information along with a JSON object. +The key feature of Type-Tagged Objects is the type key: `"$"`. + +## Why Type-Tagged Objects? + +The primary reason: to have a consistent convention we can use +anywhere. + +- Since other services rarely use `$` in their property names, + we can safely use this without introducing reserved words and + re-mapping property names. +- Some places we use this convention might not need it, but + staying consistent means API end-users can + [do more with less code](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself). + +## Specification + +- The `"$"` key indicates a type (or class) of object +- Any other key beginning with `$` is a **meta-key** +- Other keys are not allowed to contain `$` +- `"$version"` must follow [semver](https://semver.org/) +- Keys with multiple `"$"` symbols are reserved for future use + +## Alternative Representations + +Puter's API will always send results in the format described +above, which is called the "Standard Representation" + +Any endpoint which accepts a Type-Tagged Object will also +accept these alternative representations: + +### Structured Representation + +Depending on the architecture of your client, this format +may be more convenient to work with: +```json +{ + "$": "$meta-body", + "type": "some-type", + "meta": { "version": "0.0.0" }, + "body": { "some_property": "some value" } +} +``` + +### Array Representation + +In the array representation, meta values go at the end. +```json +["some-type", + { "some_property": "some value" }, + { "version": "0.0.0" } +] +``` + +If the second element of the list is not an object, it +will implicitly be placed in a property called value. +The following are equivalent: + +```json +["some-type", "hello"] +``` + +```json +["some-type", { "value": "hello" }] +``` \ No newline at end of file diff --git a/doc/api/types/app-share.md b/doc/api/types/app-share.md new file mode 100644 index 0000000000000000000000000000000000000000..cb774a61ef52e6ae51eefa4b5312632b71eaaa39 --- /dev/null +++ b/doc/api/types/app-share.md @@ -0,0 +1,26 @@ +# `{"$": "app-share"}` - File Share + +## Structure +- **name:** name of the app +- **uid:** name of the app + +## Notes +- One of `name` or `uid` **must** be specified + +## Examples + +Share app by name +```json +{ + "$": "app-share", + "name": "some-app-name" +} +``` + +Share app by uid +```json +{ + "$": "app-share", + "uid": "app-0a7337f7-0f8a-49ca-b71a-38d39304fe04" +} +``` diff --git a/doc/api/types/file-share.md b/doc/api/types/file-share.md new file mode 100644 index 0000000000000000000000000000000000000000..57d98231b50ddfd08cc447d354f4cb3fe66ccdd8 --- /dev/null +++ b/doc/api/types/file-share.md @@ -0,0 +1,32 @@ +# `{"$": "file-share"}` - File Share + +## Structure +- **path:** file or directory's path or uuid +- **access:** one of: `"read"`, `"write"` (default: `"read"`) + +## Examples + +Share with read access +```json +{ + "$": "file-share", + "path": "/some/path" +} +``` + +Share with write access +```json +{ + "$": "file-share", + "path": "/some/path", + "access": "write" +} +``` + +Using a UUID +```json +{ + "$": "file-share", + "path": "b912c381-0c0b-466c-95a6-f9a4fc680a7d" +} +``` diff --git a/doc/contributors/comment_prefixes.md b/doc/contributors/comment_prefixes.md new file mode 100644 index 0000000000000000000000000000000000000000..1a2aa2c92e139ee00f3d870c09df9c38f8d5d5ab --- /dev/null +++ b/doc/contributors/comment_prefixes.md @@ -0,0 +1,33 @@ +# Comment Prefixes + +Comments have prefixes using +[Conventional: Comments](https://conventionalcomments.org/) +as a **loose** guideline, and using this markdown file as a +the actual guideline. + +This document will be updated on an _as-needed_ basis. + +## The rules + +- A comment line always looks like this: + - A whitespace character + - Optional prefix matching `/[a-z-]+\([a-z-]a+\):/` + - A whitespace character + - The comment +- Formalized prefixes must follow the rules below +- Any other prefix can be used. After some uses it + might be good to formalize it, but that's not a hard rule. + +## Formalized prefixes + +- `todo:` is interchangable with the famous `TODO:`, **except:** + when lowercase (`todo:`) it can include a scope: `todo(security):`. +- `track:` is used to track common patterns. + - Anything written after `track:` must be registered in + [track-comments.md](../devmeta/track-comments.md) +- `wet:` is usesd to track anything that doesn't adhere + to the DRY principle; the following message should describe + where similar code is +- `compare():` is used to note differences between other + implementations of a similar idea +- `name:` pedantic commentary on the name of something diff --git a/doc/contributors/email_testing.md b/doc/contributors/email_testing.md new file mode 100644 index 0000000000000000000000000000000000000000..0bc8da00f5c8d0467ffe12c9c7c15f523cf8cf1a --- /dev/null +++ b/doc/contributors/email_testing.md @@ -0,0 +1,105 @@ +# Local Email Testing + +This guide describes how to set up and use [MailHog](https://github.com/mailhog/MailHog) for local email testing in Puter development. MailHog provides a local email server that captures outgoing emails for testing purposes without actually sending them to real recipients. + +## Setup + +### 1. Configure Puter + +Add the following configuration to your `volatile/config/config.json` file: + +```json +"email": { + "host": "localhost", + "port": 1025 +} +``` + +### 2. Install MailHog + +Download and run MailHog on your local machine: + +```bash +# Install MailHog +wget https://github.com/mailhog/MailHog/releases/download/v1.0.1/MailHog_linux_amd64 +chmod +x MailHog_linux_amd64 +./MailHog_linux_amd64 +``` + +### 3. Install Nodemailer + +Install Nodemailer to send test emails to the SMTP server: + +```bash +npm install nodemailer +``` + +## Using MailHog + +### Access Web Interface + +Once MailHog is running, access the web interface at: +[http://127.0.0.1:8025/](http://127.0.0.1:8025/) + +All captured emails and their recipients will be displayed in this interface. + +### Testing Your MailHog Setup with Nodemailer + +You can verify that your MailHog instance is working correctly by creating a simple test script using Nodemailer. This allows you to send test emails that will be captured by MailHog without actually delivering them to real recipients. + +Here's a sample script you can use to test your MailHog setup: + +```javascript +import nodemailer from "nodemailer"; + +// Configure transporter to use MailHog +const transporter = nodemailer.createTransport({ + host: "localhost", // MailHog SMTP server address + port: 1025, // Default MailHog SMTP port + secure: false // No SSL/TLS required for MailHog +}); + +// Define a test email +const mailOptions = { + from: "no-reply@example.com", + to: "test@example.com", + subject: "Hello from Nodemailer!", + text: "This is a test email sent using Nodemailer." +}; + +// Send the test email +transporter.sendMail(mailOptions) + .then(info => console.log("Email sent:", info.response)) + .catch(error => console.error("Error:", error)); +``` + +After sending an email with this script, you can view it in the MailHog web interface: + +### How Puter Uses Nodemailer + +Puter itself uses Nodemailer for sending emails through its `EmailService` class located in `/src/backend/src/services/EmailService.js`. This service handles various email templates for: + +- Account verification +- Password recovery +- Two-factor authentication notifications +- File sharing notifications +- App approval notifications +- And more + +The service creates a Nodemailer transport using the configuration from your `config.json` file, which is why setting up MailHog correctly is important for testing Puter's email functionality during development. + +Email in MailHog interface + +## Troubleshooting + +If you encounter issues with MailHog: + +1. Check if MailHog is running: + ```bash + ps aux | grep MailHog + ``` + +2. Ensure the correct port configurations in both MailHog and your application. + +3. Check for any error messages in the MailHog console output. + diff --git a/doc/contributors/extensions.md b/doc/contributors/extensions.md new file mode 100644 index 0000000000000000000000000000000000000000..56e8f2e62449968aeef992156b4833b9b7eaa5c4 --- /dev/null +++ b/doc/contributors/extensions.md @@ -0,0 +1,38 @@ +# Puter Extensions + +## Quickstart + +Create and edit this file: `mods/mods_enabled/hello-puter.js` + +```javascript +const { UserActorType, AppUnderUserActorType } = use.core; + +extension.get('/hello-puter', (req, res) => { + const actor = req.actor; + let who = 'unknown'; + if ( actor.type instanceof UserActorType ) { + who = actor.type.user.username; + } + if ( actor.type instanceof AppUnderUserActorType ) { + who = actor.type.app.name + ' on behalf of ' + actor.type.user.username; + } + res.send(`Hello, ${who}!`); +}); +``` + +## Events + +// + +This is subject to change as we make efforts to simplify the process. + +### Step 1: Configure a Mod Directory + +Add this to your config: +```json +"mod_directories": [ + "{source}/../mods/mods_available" +] +``` + +This adds the `mods/mods_available` directory to this diff --git a/doc/contributors/extensions/README.md b/doc/contributors/extensions/README.md new file mode 100644 index 0000000000000000000000000000000000000000..17a250f2b8e990f16c08e8c30ae4aa9bc0a3e67c --- /dev/null +++ b/doc/contributors/extensions/README.md @@ -0,0 +1,89 @@ +# Puter Extensions + +## Quickstart + +Create and edit this file: `mods/mods_enabled/hello-puter.js` + +```javascript +// You can get definitions exposed by Puter via `use` +const { UserActorType, AppUnderUserActorType } = use.core; + +// Endpoints can be registered directly on an extension +extension.get('/hello-puter', (req, res) => { + const actor = req.actor; + + + // Make a string "who" which says: + // "", or: + // " acting on behalf of " + let who = 'unknown'; + if ( actor.type instanceof UserActorType ) { + who = actor.type.user.username; + } + if ( actor.type instanceof AppUnderUserActorType ) { + who = actor.type.app.name + + ' on behalf of ' + + actor.type.user.username; + } + + res.send(`Hello, ${who}!`); +}); + +// Extensions can listen to events and manipulate Puter's behavior +extension.on('core.email.validate', event => { + if ( event.email.includes('evil') ) { + event.allow = false; + } +}); +``` + +### Scope of `extension` and `use` + +It is important to know that the `extension` global is temporary and does not +exist after your extension is loaded. If you wish to access the extension +object within a callback you will need to first bind it to a variable in +your extension's scope. + +```javascript +const ext = extension; +extension.on('some-event', () => { + // This would throw an error + // extension.something(); + + // This works + ext.example(); +}) +``` + +The same is true for `use`. Calls to `use` should happen at the top of +the file, just like imports in ES6. + +## Database Access + +A database access object is provided to the extension via `extension.db`. +You **must** scope `extension` to another variable (`ext` in this example) +in order to access `db` from callbacks. + +```javascript +const ext = extension; + +extension.get('/user-count', { noauth: true, mw: [] }, (req, res) => { + const [count] = await ext.db.read( + 'SELECT COUNT(*) as c FROM `user`' + ); +}); +``` + +The database access object has the following methods: +- `read(query, params)` - read from the database using a prepared statement. If read-replicas are enabled, this will use a replica. +- `write(query, params)` - write to the database using a prepared statement. If read-replicas are enabled, this will write to the primary. +- `pread(query, params)` - read from the database using a prepared statement. If read-replicas are enabled, this will read from the primary. +- `requireRead(query, params)` - read from the database using a prepared statement. If read-replicas are enabled, this will try reading from the replica first. If there are no results, a second attempt will be made on the primary. + +## Events + +See [events.md](./events.md) + +## Definitions + +See [definitions.md](./definitions.md) diff --git a/doc/contributors/extensions/definitions.md b/doc/contributors/extensions/definitions.md new file mode 100644 index 0000000000000000000000000000000000000000..f057551bac5356858f2c4783d4ccdbcecfa5f3a4 --- /dev/null +++ b/doc/contributors/extensions/definitions.md @@ -0,0 +1,14 @@ +## Definitions + +### `core.config` - Configuration + +Puter's configuration object. This includes values from `config.json` or their +defaults, and computed values like `origin` and `api_origin`. + +```javascript +const config = use('core.config'); + +extension.get('/get-origin', { noauth: true }, (req, res) => { + res.send(config.origin); +}) +``` \ No newline at end of file diff --git a/doc/contributors/extensions/events.json.js b/doc/contributors/extensions/events.json.js new file mode 100644 index 0000000000000000000000000000000000000000..fe90d5eb76722279e4f8af52dafeaab9836f7972 --- /dev/null +++ b/doc/contributors/extensions/events.json.js @@ -0,0 +1,830 @@ +export default [ + { + properties: { + completionId: { + type: 'any', + mutability: 'mutable', + summary: 'completionId', + notes: [], + }, + allow: { + type: 'boolean', + mutability: 'mutable', + summary: 'whether the operation is allowed', + notes: [], + }, + intended_service: { + type: 'any', + mutability: 'mutable', + summary: 'intended service', + notes: [], + }, + parameters: { + type: 'any', + mutability: 'mutable', + summary: 'parameters', + notes: [], + }, + }, + }, + { + id: 'ai.prompt.complete', + description: ` + This event is emitted for ai prompt complete operations. + `, + properties: { + intended_service: { + type: 'any', + mutability: 'mutable', + summary: 'intended service', + notes: [], + }, + parameters: { + type: 'any', + mutability: 'mutable', + summary: 'parameters', + notes: [], + }, + result: { + type: 'any', + mutability: 'mutable', + summary: 'result', + notes: [], + }, + model_used: { + type: 'any', + mutability: 'mutable', + summary: 'model used', + notes: [], + }, + service_used: { + type: 'any', + mutability: 'mutable', + summary: 'service used', + notes: [], + }, + }, + }, + { + id: 'ai.prompt.cost-calculated', + description: ` + This event is emitted for ai prompt cost calculated operations. + `, + }, + { + id: 'ai.prompt.validate', + description: ` + This event is emitted when a validate is being validated. + The event can be used to block certain validates from being validated. + `, + properties: { + completionId: { + type: 'any', + mutability: 'mutable', + summary: 'completionId', + notes: [], + }, + allow: { + type: 'boolean', + mutability: 'mutable', + summary: 'whether the operation is allowed', + notes: [ + 'If set to false, the ai will be considered invalid.', + ], + }, + intended_service: { + type: 'any', + mutability: 'mutable', + summary: 'intended service', + notes: [], + }, + parameters: { + type: 'any', + mutability: 'mutable', + summary: 'parameters', + notes: [], + }, + }, + }, + { + id: 'app.new-icon', + description: ` + This event is emitted for app new icon operations. + `, + properties: { + data_url: { + type: 'any', + mutability: 'no-effect', + summary: 'data url', + notes: [], + }, + }, + }, + { + id: 'app.rename', + description: ` + This event is emitted for app rename operations. + `, + properties: { + data_url: { + type: 'any', + mutability: 'no-effect', + summary: 'data url', + notes: [], + }, + }, + }, + { + id: 'apps.invalidate', + description: ` + This event is emitted when a invalidate is being validated. + The event can be used to block certain invalidates from being validated. + `, + properties: { + apps: { + type: 'any', + mutability: 'no-effect', + summary: 'apps', + notes: [], + }, + }, + }, + { + id: 'captcha.check', + description: ` + This event is emitted for captcha check operations. + `, + properties: { + required: { + type: 'any', + mutability: 'no-effect', + summary: 'required', + notes: [], + }, + }, + }, + { + id: 'core.email.validate', + description: ` + This event is emitted when an email is being validated. + The event can be used to block certain emails from being validated. + `, + properties: { + email: { + type: 'string', + mutability: 'no-effect', + summary: 'the email being validated', + notes: [ + 'The email may have already been cleaned.', + ], + }, + allow: { + type: 'boolean', + mutability: 'mutable', + summary: 'whether the email is allowed', + notes: [ + 'If set to false, the email will be considered invalid.', + ], + }, + }, + }, + { + id: 'core.fs.create.directory', + description: ` + This event is emitted when a directory is created. + `, + properties: { + node: { + type: 'FSNodeContext', + mutability: 'no-effect', + summary: 'the directory that was created', + }, + context: { + type: 'Context', + mutability: 'no-effect', + summary: 'current context', + }, + }, + }, + { + id: 'core.request.measured', + description: ` + This event is emitted when a requests incoming and outgoing bytes + have been measured. + `, + example: { + language: 'javascript', + code: /*javascript*/` + extension.on('core.request.measured', data => { + const measurements = data.measurements; + // measurements = { sz_incoming: integer, sz_outgoing: integer } + + const actor = data.actor; // instance of Actor + + console.log('\x1B[36;1m === MEASUREMENT ===\x1B[0m\n', { + actor: data.actor.uid, + measurements: data.measurements + }); + }); + `, + }, + }, + { + id: 'credit.check-available', + description: ` + This event is emitted for credit check available operations. + `, + properties: { + available: { + type: 'any', + mutability: 'no-effect', + summary: 'available', + notes: [], + }, + cost_uuid: { + type: 'string', + mutability: 'no-effect', + summary: 'cost uuid', + notes: [], + }, + }, + }, + { + id: 'credit.funding-update', + description: ` + This event is emitted when a funding-update is updated. + `, + properties: { + available: { + type: 'any', + mutability: 'no-effect', + summary: 'available', + notes: [], + }, + cost_uuid: { + type: 'string', + mutability: 'no-effect', + summary: 'cost uuid', + notes: [], + }, + }, + }, + { + id: 'credit.record-cost', + description: ` + This event is emitted for credit record cost operations. + `, + properties: { + available: { + type: 'any', + mutability: 'no-effect', + summary: 'available', + notes: [], + }, + cost_uuid: { + type: 'string', + mutability: 'no-effect', + summary: 'cost uuid', + notes: [], + }, + }, + }, + { + id: 'driver.create-call-context', + description: ` + This event is emitted when a create-call-context is created. + `, + properties: { + usages: { + type: 'any', + mutability: 'no-effect', + summary: 'usages', + notes: [], + }, + }, + }, + { + id: 'email.validate', + description: ` + This event is emitted when a validate is being validated. + The event can be used to block certain validates from being validated. + `, + properties: { + allow: { + type: 'boolean', + mutability: 'mutable', + summary: 'whether the operation is allowed', + notes: [ + 'If set to false, the email will be considered invalid.', + ], + }, + email: { + type: 'any', + mutability: 'mutable', + summary: 'email', + notes: [ + 'The email may have already been cleaned.', + ], + }, + }, + }, + { + id: 'fs.create.directory', + description: ` + This event is emitted when a directory is created. + `, + }, + { + id: 'fs.create.file', + description: ` + This event is emitted when a file is created. + `, + properties: { + context: { + type: 'Context', + mutability: 'no-effect', + summary: 'current context', + notes: [], + }, + }, + }, + { + id: 'fs.create.shortcut', + description: ` + This event is emitted when a shortcut is created. + `, + }, + { + id: 'fs.create.symlink', + description: ` + This event is emitted when a symlink is created. + `, + }, + { + id: 'fs.move.file', + description: ` + This event is emitted for fs move file operations. + `, + properties: { + moved: { + type: 'any', + mutability: 'no-effect', + summary: 'moved', + notes: [], + }, + old_path: { + type: 'string', + mutability: 'no-effect', + summary: 'path to the affected resource', + notes: [], + }, + }, + }, + { + id: 'fs.pending.file', + description: ` + This event is emitted for fs pending file operations. + `, + }, + { + id: 'fs.storage.progress.copy', + description: ` + This event reports progress of a copy operation. + `, + properties: { + context: { + type: 'Context', + mutability: 'no-effect', + summary: 'current context', + notes: [], + }, + meta: { + type: 'object', + mutability: 'no-effect', + summary: 'additional metadata for the operation', + notes: [], + }, + item_path: { + type: 'string', + mutability: 'no-effect', + summary: 'path to the affected resource', + notes: [], + }, + }, + }, + { + id: 'fs.storage.upload-progress', + description: ` + This event reports progress of a upload-progress operation. + `, + }, + { + id: 'fs.write.file', + description: ` + This event is emitted when a file is updated. + `, + properties: { + context: { + type: 'Context', + mutability: 'no-effect', + summary: 'current context', + notes: [], + }, + }, + }, + { + id: 'ip.validate', + description: ` + This event is emitted when a validate is being validated. + The event can be used to block certain validates from being validated. + `, + properties: { + res: { + type: 'any', + mutability: 'mutable', + summary: 'res', + notes: [], + }, + end_: { + type: 'any', + mutability: 'mutable', + summary: 'end ', + notes: [], + }, + end: { + type: 'any', + mutability: 'mutable', + summary: 'end', + notes: [], + }, + }, + }, + { + id: 'outer.fs.write-hash', + description: ` + This event is emitted when a write-hash is updated. + `, + properties: { + uuid: { + type: 'string', + mutability: 'no-effect', + summary: 'uuid', + notes: [], + }, + }, + }, + { + id: 'outer.gui.item.added', + description: ` + This event is emitted for outer gui item added operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.item.moved', + description: ` + This event is emitted for outer gui item moved operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.item.pending', + description: ` + This event is emitted for outer gui item pending operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.item.updated', + description: ` + This event is emitted when a updated is updated. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.notif.ack', + description: ` + This event is emitted for outer gui notif ack operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.notif.message', + description: ` + This event is emitted for outer gui notif message operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + notification: { + type: 'any', + mutability: 'no-effect', + summary: 'notification', + notes: [], + }, + }, + }, + { + id: 'outer.gui.notif.persisted', + description: ` + This event is emitted for outer gui notif persisted operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.notif.unreads', + description: ` + This event is emitted for outer gui notif unreads operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.submission.done', + description: ` + This event is emitted for outer gui submission done operations. + `, + properties: { + response: { + type: 'any', + mutability: 'no-effect', + summary: 'response', + notes: [], + }, + }, + }, + { + id: 'outer.gui.usage.update', + description: ` + This event is emitted when a update is updated. + `, + }, + { + id: 'outer.thread.notify-subscribers', + description: ` + This event is emitted for outer thread notify subscribers operations. + `, + properties: { + uid: { + type: 'string', + mutability: 'no-effect', + summary: 'uid', + notes: [], + }, + action: { + type: 'any', + mutability: 'no-effect', + summary: 'action', + notes: [], + }, + data: { + type: 'any', + mutability: 'no-effect', + summary: 'data', + notes: [], + }, + }, + }, + { + id: 'puter.signup', + description: ` + This event is emitted for puter signup operations. + `, + properties: { + ip: { + type: 'any', + mutability: 'mutable', + summary: 'ip', + notes: [], + }, + user_agent: { + type: 'any', + mutability: 'mutable', + summary: 'user agent', + notes: [], + }, + body: { + type: 'any', + mutability: 'mutable', + summary: 'body', + notes: [], + }, + }, + }, + { + id: 'request.measured', + description: ` + This event is emitted for request measured operations. + `, + properties: { + req: { + type: 'any', + mutability: 'no-effect', + summary: 'req', + notes: [], + }, + res: { + type: 'any', + mutability: 'no-effect', + summary: 'res', + notes: [], + }, + }, + }, + { + id: 'request.will-be-handled', + description: ` + This event is emitted for request will be handled operations. + `, + properties: { + res: { + type: 'any', + mutability: 'mutable', + summary: 'res', + notes: [], + }, + end_: { + type: 'any', + mutability: 'mutable', + summary: 'end ', + notes: [], + }, + end: { + type: 'any', + mutability: 'mutable', + summary: 'end', + notes: [], + }, + }, + }, + { + id: 'sns', + description: ` + This event is emitted for sns operations. + `, + properties: { + message: { + type: 'any', + mutability: 'no-effect', + summary: 'message', + notes: [], + }, + }, + }, + { + id: 'template-service.hello', + description: ` + This event is emitted for template-service hello operations. + `, + }, + { + id: 'usages.query', + description: ` + This event is emitted for usages query operations. + `, + properties: { + usages: { + type: 'any', + mutability: 'no-effect', + summary: 'usages', + notes: [], + }, + }, + }, + { + id: 'user.email-changed', + description: ` + This event is emitted for user email changed operations. + `, + properties: { + new_email: { + type: 'any', + mutability: 'no-effect', + summary: 'new email', + notes: [], + }, + }, + }, + { + id: 'user.email-confirmed', + description: ` + This event is emitted for user email confirmed operations. + `, + properties: { + email: { + type: 'any', + mutability: 'no-effect', + summary: 'email', + notes: [], + }, + }, + }, + { + id: 'user.save_account', + description: ` + This event is emitted for user save_account operations. + `, + properties: { + user: { + type: 'User', + mutability: 'no-effect', + summary: 'user associated with the operation', + notes: [], + }, + }, + }, + { + id: 'web.socket.connected', + description: ` + This event is emitted for web socket connected operations. + `, + properties: { + user: { + type: 'User', + mutability: 'mutable', + summary: 'user associated with the operation', + notes: [], + }, + }, + }, + { + id: 'web.socket.user-connected', + description: ` + This event is emitted for web socket user connected operations. + `, + properties: { + user: { + type: 'User', + mutability: 'mutable', + summary: 'user associated with the operation', + notes: [], + }, + }, + }, + { + id: 'wisp.get-policy', + description: ` + This event is emitted for wisp get policy operations. + `, + properties: { + policy: { + type: 'Policy', + mutability: 'mutable', + summary: 'policy information for the operation', + notes: [], + }, + }, + }, +]; diff --git a/doc/contributors/extensions/events.md b/doc/contributors/extensions/events.md new file mode 100644 index 0000000000000000000000000000000000000000..3b38a6c7e50f7410291108fd3fa883c89d372ea1 --- /dev/null +++ b/doc/contributors/extensions/events.md @@ -0,0 +1,761 @@ +#### Property `completionId` + +completionId +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `allow` + +whether the operation is allowed +- **Type**: boolean +- **Mutability**: mutable +- **Notes**: + +#### Property `intended_service` + +intended service +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `parameters` + +parameters +- **Type**: any +- **Mutability**: mutable +- **Notes**: + + +### `ai.prompt.complete` + +This event is emitted for ai prompt complete operations. + +#### Property `intended_service` + +intended service +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `parameters` + +parameters +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `result` + +result +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `model_used` + +model used +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `service_used` + +service used +- **Type**: any +- **Mutability**: mutable +- **Notes**: + + +### `ai.prompt.cost-calculated` + +This event is emitted for ai prompt cost calculated operations. + + +### `ai.prompt.validate` + +This event is emitted when a validate is being validated. +The event can be used to block certain validates from being validated. + +#### Property `completionId` + +completionId +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `allow` + +whether the operation is allowed +- **Type**: boolean +- **Mutability**: mutable +- **Notes**: + - If set to false, the ai will be considered invalid. + +#### Property `intended_service` + +intended service +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `parameters` + +parameters +- **Type**: any +- **Mutability**: mutable +- **Notes**: + + +### `app.new-icon` + +This event is emitted for app new icon operations. + +#### Property `data_url` + +data url +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `app.rename` + +This event is emitted for app rename operations. + +#### Property `data_url` + +data url +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `apps.invalidate` + +This event is emitted when a invalidate is being validated. +The event can be used to block certain invalidates from being validated. + +#### Property `apps` + +apps +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `captcha.check` + +This event is emitted for captcha check operations. + +#### Property `required` + +required +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `core.email.validate` + +This event is emitted when an email is being validated. +The event can be used to block certain emails from being validated. + +#### Property `email` + +the email being validated +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + - The email may have already been cleaned. + +#### Property `allow` + +whether the email is allowed +- **Type**: boolean +- **Mutability**: mutable +- **Notes**: + - If set to false, the email will be considered invalid. + + +### `core.fs.create.directory` + +This event is emitted when a directory is created. + +#### Property `node` + +the directory that was created +- **Type**: FSNodeContext +- **Mutability**: no-effect + +#### Property `context` + +current context +- **Type**: Context +- **Mutability**: no-effect + + +### `core.request.measured` + +This event is emitted when a requests incoming and outgoing bytes +have been measured. + +#### Example + +```javascript +extension.on('core.request.measured', data => { + const measurements = data.measurements; + // measurements = { sz_incoming: integer, sz_outgoing: integer } + + const actor = data.actor; // instance of Actor + + console.log(' === MEASUREMENT === +', { + actor: data.actor.uid, + measurements: data.measurements + }); +}); +``` + +### `credit.check-available` + +This event is emitted for credit check available operations. + +#### Property `available` + +available +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `cost_uuid` + +cost uuid +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + + +### `credit.funding-update` + +This event is emitted when a funding-update is updated. + +#### Property `available` + +available +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `cost_uuid` + +cost uuid +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + + +### `credit.record-cost` + +This event is emitted for credit record cost operations. + +#### Property `available` + +available +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `cost_uuid` + +cost uuid +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + + +### `driver.create-call-context` + +This event is emitted when a create-call-context is created. + +#### Property `usages` + +usages +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `email.validate` + +This event is emitted when a validate is being validated. +The event can be used to block certain validates from being validated. + +#### Property `allow` + +whether the operation is allowed +- **Type**: boolean +- **Mutability**: mutable +- **Notes**: + - If set to false, the email will be considered invalid. + +#### Property `email` + +email +- **Type**: any +- **Mutability**: mutable +- **Notes**: + - The email may have already been cleaned. + + +### `fs.create.directory` + +This event is emitted when a directory is created. + + +### `fs.create.file` + +This event is emitted when a file is created. + +#### Property `context` + +current context +- **Type**: Context +- **Mutability**: no-effect +- **Notes**: + + +### `fs.create.shortcut` + +This event is emitted when a shortcut is created. + + +### `fs.create.symlink` + +This event is emitted when a symlink is created. + + +### `fs.move.file` + +This event is emitted for fs move file operations. + +#### Property `moved` + +moved +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `old_path` + +path to the affected resource +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + + +### `fs.pending.file` + +This event is emitted for fs pending file operations. + + +### `fs.storage.progress.copy` + +This event reports progress of a copy operation. + +#### Property `context` + +current context +- **Type**: Context +- **Mutability**: no-effect +- **Notes**: + +#### Property `meta` + +additional metadata for the operation +- **Type**: object +- **Mutability**: no-effect +- **Notes**: + +#### Property `item_path` + +path to the affected resource +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + + +### `fs.storage.upload-progress` + +This event reports progress of a upload-progress operation. + + +### `fs.write.file` + +This event is emitted when a file is updated. + +#### Property `context` + +current context +- **Type**: Context +- **Mutability**: no-effect +- **Notes**: + + +### `ip.validate` + +This event is emitted when a validate is being validated. +The event can be used to block certain validates from being validated. + +#### Property `res` + +res +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `end_` + +end +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `end` + +end +- **Type**: any +- **Mutability**: mutable +- **Notes**: + + +### `outer.fs.write-hash` + +This event is emitted when a write-hash is updated. + +#### Property `uuid` + +uuid +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.item.added` + +This event is emitted for outer gui item added operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.item.moved` + +This event is emitted for outer gui item moved operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.item.pending` + +This event is emitted for outer gui item pending operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.item.updated` + +This event is emitted when a updated is updated. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.notif.ack` + +This event is emitted for outer gui notif ack operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.notif.message` + +This event is emitted for outer gui notif message operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `notification` + +notification +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.notif.persisted` + +This event is emitted for outer gui notif persisted operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.notif.unreads` + +This event is emitted for outer gui notif unreads operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.submission.done` + +This event is emitted for outer gui submission done operations. + +#### Property `response` + +response +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `outer.gui.usage.update` + +This event is emitted when a update is updated. + + +### `outer.thread.notify-subscribers` + +This event is emitted for outer thread notify subscribers operations. + +#### Property `uid` + +uid +- **Type**: string +- **Mutability**: no-effect +- **Notes**: + +#### Property `action` + +action +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `data` + +data +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `puter.signup` + +This event is emitted for puter signup operations. + +#### Property `ip` + +ip +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `user_agent` + +user agent +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `body` + +body +- **Type**: any +- **Mutability**: mutable +- **Notes**: + + +### `request.measured` + +This event is emitted for request measured operations. + +#### Property `req` + +req +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + +#### Property `res` + +res +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `request.will-be-handled` + +This event is emitted for request will be handled operations. + +#### Property `res` + +res +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `end_` + +end +- **Type**: any +- **Mutability**: mutable +- **Notes**: + +#### Property `end` + +end +- **Type**: any +- **Mutability**: mutable +- **Notes**: + + +### `sns` + +This event is emitted for sns operations. + +#### Property `message` + +message +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `template-service.hello` + +This event is emitted for template-service hello operations. + + +### `usages.query` + +This event is emitted for usages query operations. + +#### Property `usages` + +usages +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `user.email-changed` + +This event is emitted for user email changed operations. + +#### Property `new_email` + +new email +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `user.email-confirmed` + +This event is emitted for user email confirmed operations. + +#### Property `email` + +email +- **Type**: any +- **Mutability**: no-effect +- **Notes**: + + +### `user.save_account` + +This event is emitted for user save_account operations. + +#### Property `user` + +user associated with the operation +- **Type**: User +- **Mutability**: no-effect +- **Notes**: + + +### `web.socket.connected` + +This event is emitted for web socket connected operations. + +#### Property `user` + +user associated with the operation +- **Type**: User +- **Mutability**: mutable +- **Notes**: + + +### `web.socket.user-connected` + +This event is emitted for web socket user connected operations. + +#### Property `user` + +user associated with the operation +- **Type**: User +- **Mutability**: mutable +- **Notes**: + + +### `wisp.get-policy` + +This event is emitted for wisp get policy operations. + +#### Property `policy` + +policy information for the operation +- **Type**: Policy +- **Mutability**: mutable +- **Notes**: + + diff --git a/doc/contributors/extensions/gen.js b/doc/contributors/extensions/gen.js new file mode 100644 index 0000000000000000000000000000000000000000..39f0250e5ec91f68633d2528aa4b479dc61782a6 --- /dev/null +++ b/doc/contributors/extensions/gen.js @@ -0,0 +1,38 @@ +import dedent from 'dedent'; +import events from './events.json.js'; + +const mdlib = {}; +mdlib.h = (out, n, str) => { + out(`${'#'.repeat(n)} ${str}\n\n`); +}; + +const N_START = 3; + +const out = str => process.stdout.write(str); +for ( const event of events ) { + mdlib.h(out, N_START, `\`${event.id}\``); + out(`${dedent(event.description) }\n\n`); + + for ( const k in event.properties ) { + const prop = event.properties[k]; + mdlib.h(out, N_START + 1, `Property \`${k}\``); + out(`${prop.summary }\n`); + out(`- **Type**: ${prop.type}\n`); + out(`- **Mutability**: ${prop.mutability}\n`); + if ( prop.notes ) { + out('- **Notes**:\n'); + for ( const note of prop.notes ) { + out(` - ${note}\n`); + } + } + out('\n'); + } + + if ( event.example ) { + mdlib.h(out, N_START + 1, 'Example'); + out(`\`\`\`${event.example.language}\n${dedent(event.example.code)}\n\`\`\`\n`); + } + + out('\n'); + +} diff --git a/doc/contributors/extensions/manual_overrides.json.js b/doc/contributors/extensions/manual_overrides.json.js new file mode 100644 index 0000000000000000000000000000000000000000..eb8b614c6c7ccee32ac1ca237bd6b97172a69095 --- /dev/null +++ b/doc/contributors/extensions/manual_overrides.json.js @@ -0,0 +1,68 @@ +export default [ + { + id: 'core.email.validate', + description: ` + This event is emitted when an email is being validated. + The event can be used to block certain emails from being validated. + `, + properties: { + email: { + type: 'string', + mutability: 'no-effect', + summary: 'the email being validated', + notes: [ + 'The email may have already been cleaned.', + ], + }, + allow: { + type: 'boolean', + mutability: 'mutable', + summary: 'whether the email is allowed', + notes: [ + 'If set to false, the email will be considered invalid.', + ], + }, + }, + }, + { + id: 'core.request.measured', + description: ` + This event is emitted when a requests incoming and outgoing bytes + have been measured. + `, + example: { + language: 'javascript', + code: /*javascript*/` + extension.on('core.request.measured', data => { + const measurements = data.measurements; + // measurements = { sz_incoming: integer, sz_outgoing: integer } + + const actor = data.actor; // instance of Actor + + console.log('\\x1B[36;1m === MEASUREMENT ===\\x1B[0m\\n', { + actor: data.actor.uid, + measurements: data.measurements + }); + }); + `, + }, + }, + { + id: 'core.fs.create.directory', + description: ` + This event is emitted when a directory is created. + `, + properties: { + node: { + type: 'FSNodeContext', + mutability: 'no-effect', + summary: 'the directory that was created', + }, + context: { + type: 'Context', + mutability: 'no-effect', + summary: 'current context', + }, + }, + }, +]; \ No newline at end of file diff --git a/doc/contributors/image.png b/doc/contributors/image.png new file mode 100644 index 0000000000000000000000000000000000000000..0dcf9986fddc9b006b41d9125293de41234ab61e --- /dev/null +++ b/doc/contributors/image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:696052fb523972a3fe55f76ab7421495467f330d56d11380b24cd34f17065572 +size 115031 diff --git a/doc/contributors/structure.md b/doc/contributors/structure.md new file mode 100644 index 0000000000000000000000000000000000000000..9b0ef2b141ce1f6fe96b639ed9ed663f5ba3f94f --- /dev/null +++ b/doc/contributors/structure.md @@ -0,0 +1,64 @@ +# Repository Structure and Tooling + +Puter has many of its parts in a single [monorepo](https://en.wikipedia.org/wiki/Monorepo), +rather than a single repository for each cohesive part. +We feel this makes it easier for new contributors to develop Puter since you don't +need to figure out how to tie the parts together or how to work with Git submodules. +It also makes it easier for us to maintain project-wide conventions and tooling. + +Some tools, like [puter-cli](https://github.com/HeyPuter/puter-cli), exist in separate +repositories. The `puter-cli` tool is used externally and can communicate with Puter's +API on our production (puter.com) instance or your own instance of Puter, so there's +not really any advantage to putting it in the monorepo. + +## Top-Level directories + +### The `doc` directory + +The top-level `doc` directory contains the file you're reading right now. +Its scope is documentation for using and contributing to Puter in general, +and linking to more specific documentation in other places. + +All `doc` directories will have a `README.md` which should be considered as +the index file for the documentation. All documentation under a `doc` +directory should be accessible via a path of links starting from `README.md`. + +### The `src` directory + +Every directory under `/tools` is [an npm "workspaces" module](https://docs.npmjs.com/cli/v8/using-npm/workspaces). Every direct child of this directory (generally) has a `package.json` and a `src` directory. + +Some of these modules are core pieces of Puter: +- **Puter's backend** is [`/src/backend`](/src/backend) + - See [key locations in backend documentation](/src/backend/doc/contributors/structure.md) +- **Puter's GUI** is [`/src/gui`](/src/gui) + +Some of these modules are apps: +- **Puter's Terminal**: [`/src/terminal`](/src/terminal) +- **Puter's Shell**: [`/src/phoenix`](/src/phoenix) + +Some of these modules are libraries: +- **common javascript**: [`/src/putility`](/src/putility) +- **runtime import mechanism**: [`/src/useapi`](/src/useapi) +- **Puter's "puter.js" browser SDK**: [`/src/puter-js`](/src/puter-js) + +### The `volatile` directory + +When you're running Puter with development instructions (i.e. `npm start`), +Puter's configuration directory will be `volatile/config` and Puter's +runtime directory will be `volatile/runtime`, instead of the standard +`/etc/puter` and `/var/puter` directories in production installations. + +We should probably rename this directory, actually, but it would inconvenience +a lot of people right now if we did. + +### The `tools` directory + +Every directory under `/tools` is [an npm "workspaces" module](https://docs.npmjs.com/cli/v8/using-npm/workspaces). + +This is where `run-selfhosted.js` is. That's the entrypoint for `npm start`. + +These tools are underdocumented and may not behave well if they're not executed +from the correct working directory (which is different for different tools). +Consider this a work-in-progress. If you want to use or contribute to anything +under this directory, for now you should +[tag @KernelDeimos on the community Discord](https://discord.gg/PQcx7Teh8u). diff --git a/doc/contributors/vscode.md b/doc/contributors/vscode.md new file mode 100644 index 0000000000000000000000000000000000000000..7674b13a6c4cdc73d3ed9026fa2bd403967ac0bb --- /dev/null +++ b/doc/contributors/vscode.md @@ -0,0 +1,2 @@ +### `vscode` +- `es6-string-html` diff --git a/doc/devlog.md b/doc/devlog.md new file mode 100644 index 0000000000000000000000000000000000000000..eceac0f96f5bfcb33b72ce66d4b5357ed2606177 --- /dev/null +++ b/doc/devlog.md @@ -0,0 +1,103 @@ +## 2024-10-16 + +### Considerations for Mountpoints Feature + +- `_storage_upload` takes paramter `uuid` instead of `path` + - S3 bucket strategy needs the UUID + - If we do hashes, 10MB chunks should be fine + - we're already able to smooth out bursty traffic using the + EWA algorithm +- Use of `systemFSEntryService` + - Is that normalized? Does everything go through this interface? +- Storage interface has methods like `post_insert` + - as far as I can tell this doesn't pose any issue +- + +### Brainstorming Migration Strategies + +#### Interface boundary at HL<->LL filesystem methods + +-- **tags:** brainstorming + +From the perspectice of a trait-oriented implementation, +which is not how LL/HL filesystem operations are currently implemented, +the LL-class operations are implemented in separate traits. + +The composite trait containing all of these traits would be the trait +that represents a filesystem implementation itself. + +Other filesystem interfaces that I've seen, such as FUSE and 9p, +all usually have a monolithic interface - that is to say, an interface +which includes all of the filesystem operations, rather than several +interfaces each implementing a single filesystem operaiton. + +Something about the fact that the LL-class operations are in separate +classes makes it difficult to reason about how to move. +Is it simply that multiple files in a directory is just more +annoying to think about? Maybe, but there must be something more. + +Perhaps it's that there are several references. Each implementation +(that is, implemenation of a single filesystem operation) could have +any number of different references across any number of different files. +This would not be the case with a monolithic interface. + +I think the best of both worlds would be to have an interface representing +the entire filesystem and, in one place, link of of the individual +operation implementations to compose a filesystem implementation + +### Filesystem Brainstorming + +Puter's backend uses a service architecture. Each service is an instance +of a class extending "Service". A service can listen to events of the +backend's lifecycle, interact with other services, and interact with +external interfaces such as APIs and databases. + +Puter's current filesystem, let's call it PuterFSv1, exists as the result +of multiple services working together. We have LocalDiskStorageService +which mimics an S3 bucket on a local system, and we have +DatabaseFSEntryService which manages information about files, directories, +and their relationships within the database, and therefore depends on +DatabaseAccessService. + +It is now time to introduce a MountpointService. This will allow another +service or a user's configuration to assign an instance of a filesystem +implementation (such as PuterFSv1) to a specific path. + +The trouble here is that PuterFSv1 is composed of services, and the nature +of a service is such that it exists for the lifecycle of the application. +The class for a particular service can be re-used and registered with +multiple names (creating multiple services with the same implementation +but perhaps different configuration), but that's only a clean scenario when +there is just one service. PuterFSv1, on the other hand, is like an +imaginary service composed of other services. + +The following possibilities then should be discussed: +- CompositeService base class for a service that is composed of + more than one service. +- Refactor filesystem to not use service architecture. +- Each filesystem service can manage state and configuration + for multiple mountpoints + (I don't like this idea; it feels messy. I wonder what software + principles this violates) + +We can take advantage of traits/interfaces here. +PuterFSv1 depends on two interfaces: +- An S3-like data storage implementation +- An fsentry storage implementation + +Counterintuitively from what I first thought, "Refactor the filesystem" +actually looks like the best solution, and it doens't even look like it +will be that difficult. In fact, it'll likely make the filesystem easier +to maintain and more robust as a result. + +Additionally, we can introduce PuterFSv2, which will introduce storing +data in chunks identified by their hashes, and associated hashes with +fsentries. + +PuterFSService will be a new service which registers 'PuterFSv1' with +FilesystemService. + +An instance of a filesystem needs to be separate from a mountpoint. +For example, PuterFSv1 will usually have only one instance but it may +be mounted several different times. `/some-user` on Puter's VFS could +be a mountpoint for `/some-user` in the instance of PuterFSv1. diff --git a/doc/devmeta/track-comments.md b/doc/devmeta/track-comments.md new file mode 100644 index 0000000000000000000000000000000000000000..c7be9a824cd380a88d9ec155e25128bf5d0eea15 --- /dev/null +++ b/doc/devmeta/track-comments.md @@ -0,0 +1,62 @@ +# Track Comments + +Comments beginning with `// track:`. See +[comment_prefixes.md](../contributors/comment_prefixes.md) + +## Track Comment Registry + +- `track: type check`: + A condition that's used to check the type of an imput. +- `track: adapt` + A value can by adapted from another type at this line. +- `track: bounds check`: + A condition that's used to check the bounds of an array + or other list-like entity. +- `track: ruleset` + A series of conditions that early-return or `continue` +- `track: object description in comment` + A comment above the creation of some object which + could potentially have a `description` property. + This is especially relevant if the object is stored + in some kind of registry where multiple objects + could be listed in the console. +- `track: slice a prefix` + A common pattern where a prefix string is "sliced off" + of another string to obtain a significant value, such + as an indentifier. +- `track: actor type` + The sub-type of an Actor object is checked. +- `track: scoping iife` + An immediately-invoked function expression specifically + used to reduce scope clutter. +- `track: good candidate for sequence` + Some code involves a series of similar steps, + or there's a common behavior that should happen + in between. The Sequence class is good for this so + it might be a worthy migration. +- `track: opposite condition of sibling` + A sibling class, function, method, or other construct of + source code has a boolean expression which always evaluates + to the opposite of the one below this track comment. +- `track: null check before processing` + An object could be undefined or null, additional processing + occurs after a null check, and the unprocessed object is not + relevant to the rest of the code. If the code for obtaining + the object and processing it is moved to a function outside, + then the null check should result in a early return of null; + this code with the track comment may have additional logic + for the null/undefined case. +- `track: manual safe object` + This code manually creates a new "client-safe" version of + some object that's in scope. This could be either to pass + onto the browser or to pass to something like the + notification service. +- `track: common operations on multiple items` + A patterm which emerges when multiple variables have + common operations done upon them in sequence. + It may be applicable to write an iterator in the + future, or something will come up that require + these to be handled with a modular approach instead. +- `track: checkpoint` + A location where some statement about the state of the + software must hold true. diff --git a/doc/docmeta.md b/doc/docmeta.md new file mode 100644 index 0000000000000000000000000000000000000000..9b67f5edbfa1ac18dbaece33fe9c571816352945 --- /dev/null +++ b/doc/docmeta.md @@ -0,0 +1,45 @@ +# Meta Documentation + +Guidelines for documentation. + +## How documentation is organized + +This documentation exists in the Puter repository. +You may be reading this on the GitHub wiki instead, which we generate +from the repository docs. These docs are always under a directory +named `doc/`. + +From [./contributors/structure.md](./contributors/structure.md): +> The top-level `doc` directory contains the file you're reading right now. +> Its scope is documentation for using and contributing to Puter in general, +> and linking to more specific documentation in other places. +> +> All `doc` directories will have a `README.md` which should be considered as +> the index file for the documentation. All documentation under a `doc` +> directory should be accessible via a path of links starting from `README.md`. + +### Documentation Structure + +The top-level `doc` directory contains the following subdirectories: + +- `api/` - API documentation for Puter services +- `contributors/` - Documentation for contributors to the Puter project +- `devmeta/` - Meta documentation for developers +- `i18n/` - Internationalization documentation +- `planning/` - Project planning documentation +- `self-hosters/` - Documentation for self-hosting Puter +- `uncategorized/` - Miscellaneous documentation + +As well as some files: + +- `README.md` - Documentation overview optimized for humans. +- `AI.md` - Documentation overview optimized for AI/LLM agents. + +Module-specific documentation follows a similar structure, with each module having its own `doc` directory. For contributor-specific documentation within a module, use a `contributors` subdirectory within the module's `doc` directory. + +## Docs Styleguide + +### "is" and "is not" + +- When "A is B", bold "is": "A **is** B" (`A **is** B`) +- When "A is not B", bold "not": "A is **not** B" (`A is **not** B`) diff --git a/doc/i18n/README.ar.md b/doc/i18n/README.ar.md new file mode 100644 index 0000000000000000000000000000000000000000..bb1c3a9f4b434183befc87b00d650ac0bc588784 --- /dev/null +++ b/doc/i18n/README.ar.md @@ -0,0 +1,129 @@ +

Puter.com، الحاسوب السحابي الشخصي: جميع ملفاتك وتطبيقاتك وألعابك في مكان واحد يمكن الوصول إليه من أي مكان في أي وقت.

+ +

نظام تشغيل الإنترنت! مجاني ومفتوح المصدر وقابل للاستضافة الذاتية.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « عرض توضيحي مباشر » +
+
+ Puter.com + · + مجموعة أدوات التطوير + · + ديسكورد + · + ريديت + · + إكس (تويتر) +

+ +

لقطة شاشة

+ +
+ +## بيوتر + +
+

بيوتر هو نظام تشغيل إنترنت متقدم ومفتوح المصدر، مصمم ليكون غنيًا بالميزات وسريعًا بشكل استثنائي وقابلًا للتوسع بدرجة كبيرة. يمكن استخدام بيوتر كـ:

+ +
    +
  • سحابة شخصية تعطي الأولوية للخصوصية لحفظ جميع ملفاتك وتطبيقاتك وألعابك في مكان آمن واحد، يمكن الوصول إليه من أي مكان وفي أي وقت.
  • +
  • منصة لبناء ونشر المواقع الإلكترونية وتطبيقات الويب والألعاب
  • +
  • بديل لـ Dropbox وGoogle Drive وOneDrive وغيرها، مع واجهة جديدة وميزات قوية.
  • +
  • بيئة سطح مكتب عن بُعد للخوادم ومحطات العمل.
  • +
  • مشروع ومجتمع ودود ومفتوح المصدر للتعلم عن تطوير الويب والحوسبة السحابية والأنظمة الموزعة والكثير غير ذلك!
  • +
+
+ +
+ +## البدء + +### 💻 التطوير المحلي + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +سيؤدي هذا إلى تشغيل Puter على http://puter.localhost:4100 (أو المنفذ التالي المتاح). + +
+ +### 🐳 دوكر + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 دوكر كومبوز + +#### لينكس/ماك + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+ +#### ويندوز + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` + +
+ +### ☁️ موقع Puter.com + +متاح Puter كخدمة مستضافة على[**puter.com**](https://puter.com)الموقع + +
+ +## متطلبات النظام + +- **Operating Systems:** لينكس، ماك، ويندوز +- **RAM** ٢ جيجابايت كحد أدنى (يوصى بـ ٤ جيجابايت) +- **Disk Space:** ١ جيجابايت مساحة حرة +- **Node.js:** الإصدار ١٦+ (يوصى بالإصدار ٢٢+) +- **npm:** أحدث إصدار مستقر + +
+ +## الدعم + +تواصل مع المشرفين والمجتمع من خلال هذه القنوات: + +- تقرير عن خطأ أو طلب ميزة؟ الرجاء [فتح مشكلة](https://github.com/HeyPuter/puter/issues/new/choose) + +- دسكورد: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- إكس (تويتر): [x.com/HeyPuter](https://x.com/HeyPuter) +- ريديت: [/reddit.com/r/puter](https://www.reddit.com/r/puter/) +- ماستودون: [mastodon.social/@puter](https://mastodon.social/@puter) +- مشاكل أمنية؟ [security@puter.com](mailto:security@puter.com) +- البريد الإلكتروني للمشرفين [hi@puter.com](mailto:hi@puter.com) + +نحن دائمًا سعداء لمساعدتك في أي أسئلة قد تكون لديك. لا تتردد في السؤال! + +
+ +## الترخيص + +هذا المستودع، بما في ذلك جميع محتوياته ومشاريعه الفرعية ووحداته ومكوناته، مرخص تحت [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) ما لم ينص على خلاف ذلك صراحةً. قد تخضع المكتبات الخارجية المدرجة في هذا المستودع لتراخيصها الخاصة. + +
diff --git a/doc/i18n/README.bn.md b/doc/i18n/README.bn.md new file mode 100644 index 0000000000000000000000000000000000000000..29a60af05832db71df5bd1cf0beaf5eb4e94c2fb --- /dev/null +++ b/doc/i18n/README.bn.md @@ -0,0 +1,124 @@ +

Puter.com, ব্যক্তিগত ক্লাউড কম্পিউটার: আপনার সমস্ত ফাইল, অ্যাপস, এবং গেম এক জায়গায়, যেকোনো সময়, যেকোনো স্থান থেকে অ্যাক্সেসযোগ্য।

+ +

ইন্টারনেট ওএস! ফ্রি, ওপেন-সোর্স, এবং সেল্ফ-হোস্টেবল।

+ +

+ GitHub রেপোর আকার GitHub রিলিজ GitHub লাইসেন্স +

+ +

+ « লাইভ ডেমো » +
+
+ Puter.com + · + এসডিকে + · + ডিসকর্ড + · + রেডিট + · + X (টুইটার) +

+ +

স্ক্রিনশট

+ +
+ +## Puter + +Puter একটি উন্নত, ওপেন-সোর্স ইন্টারনেট অপারেটিং সিস্টেম যা বৈশিষ্ট্যপূর্ণ, অত্যন্ত দ্রুত এবং উচ্চ মাত্রায় সম্প্রসারণযোগ্য। Puter ব্যবহার করা যেতে পারে: + +- একটি প্রাইভেসি-প্রথম পার্সোনাল ক্লাউড হিসাবে যা আপনার সমস্ত ফাইল, অ্যাপস এবং গেমসকে এক জায়গায় নিরাপদে রাখে, যেকোনো সময় যেকোনো স্থান থেকে অ্যাক্সেসযোগ্য। +- ওয়েবসাইট, ওয়েব অ্যাপ এবং গেম তৈরি ও প্রকাশ করার একটি প্ল্যাটফর্ম হিসাবে। +- ড্রপবক্স, গুগল ড্রাইভ, ওয়ানড্রাইভ ইত্যাদির বিকল্প হিসাবে একটি নতুন ইন্টারফেস এবং শক্তিশালী বৈশিষ্ট্য সহ। +- সার্ভার এবং ওয়ার্কস্টেশনের জন্য একটি রিমোট ডেস্কটপ এনভায়রনমেন্ট হিসাবে। +- ওয়েব ডেভেলপমেন্ট, ক্লাউড কম্পিউটিং, ডিস্ট্রিবিউটেড সিস্টেম এবং আরও অনেক কিছু শিখতে একটি বন্ধুত্বপূর্ণ, ওপেন-সোর্স প্রকল্প এবং কমিউনিটি হিসাবে! + +
+ +## শুরু করার জন্য + +## 💻 লোকাল ডেভেলপমেন্ট + +```bash +Copy code +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` +এটি Puter কে http://puter.localhost:4100 (অথবা পরবর্তী উপলব্ধ পোর্টে) চালু করবে। + +
+ +## 🐳 ডকার + +```bash +Copy code +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` +
+ +## 🐙 ডকার কম্পোজ + +## লিনাক্স/ম্যাকওএস + +```bash +Copy code +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +## উইন্ডোজ + +```powershell +Copy code +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +## ☁️ Puter.com +Puter [**puter.com**](https://puter.com) এ হোস্টেড সার্ভিস হিসেবে উপলব্ধ। + +
+ +## সিস্টেম রিকোয়ারমেন্টস + +- **অপারেটিং সিস্টেম:** লিনাক্স, ম্যাকওএস, উইন্ডোজ +- **র‍্যাম:** ২জিবি ন্যূনতম (৪জিবি প্রস্তাবিত) +- **ডিস্ক স্পেস:** ১জিবি ফ্রি স্পেস +- **Node.js:** সংস্করণ ১৬+ (সংস্করণ ২২+ প্রস্তাবিত) +- **npm:** সর্বশেষ স্থিতিশীল সংস্করণ + +
+ +## সাপোর্ট + +মেইনটেইনার এবং কমিউনিটির সাথে এই চ্যানেলগুলির মাধ্যমে সংযোগ করুন: + +- বাগ রিপোর্ট বা ফিচার রিকোয়েস্ট? অনুগ্রহ করে একটি ইস্যু খুলুন। +- ডিসকর্ড: discord.com/invite/PQcx7Teh8u +- X (টুইটার): x.com/HeyPuter +- রেডিট: reddit.com/r/puter/ +- মাস্টডন: mastodon.social/@puter +- সিকিউরিটি ইস্যু? security@puter.com +- মেইনটেইনারদের ইমেইল করুন hi@puter.com এ + +আপনার যেকোনো প্রশ্নের জন্য আমরা সবসময় সাহায্য করতে প্রস্তুত। জিজ্ঞাসা করতে দ্বিধা করবেন না! + +
+ +## লাইসেন্স + +এই রিপোজিটরি, এর সমস্ত বিষয়বস্তু, সাব-প্রকল্প, মডিউল, এবং কম্পোনেন্ট সহ [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) লাইসেন্সের অধীনে লাইসেন্সকৃত, যদি অন্যথায় স্পষ্টভাবে উল্লেখ না করা হয়। এই রিপোজিটরিতে অন্তর্ভুক্ত তৃতীয় পক্ষের লাইব্রেরিগুলি তাদের নিজস্ব লাইসেন্সের অধীনে হতে পারে। + +
diff --git a/doc/i18n/README.da.md b/doc/i18n/README.da.md new file mode 100644 index 0000000000000000000000000000000000000000..225aa66d51bf21e293f09a7ebdd534964b605af5 --- /dev/null +++ b/doc/i18n/README.da.md @@ -0,0 +1,127 @@ +

Puter.com, Den Personlige Cloudcomputer: Alle dine filer, apps og spil på ét sted tilgængelige fra hvor som helst til enhver tid.

+ +

Internet OS'et! Gratis, Open-Source og kan selvhostes.

+ +

+ GitHub repo størrelse GitHub Udgivelse GitHub Licens +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

skærmbillede

+ +
+ +## Puter + +Puter er et avanceret, open-source internetoperativsystem designet til at være funktionsrigt, exceptionelt hurtigt og meget udvideligt. Puter kan bruges som: + +- En privatlivsfokuseret personlig sky til at opbevare alle dine filer, apps og spil på ét sikkert sted, tilgængeligt hvor som helst og når som helst. +- En platform til at bygge og publicere hjemmesider, webapplikationer og spil. +- Et alternativ til Dropbox, Google Drive, OneDrive osv. med et friskt interface og kraftfulde funktioner. +- Et fjernskrivebordsmiljø for servere og arbejdsstationer. +- Et venligt, open-source projekt og fællesskab til at lære om webudvikling, cloud computing, distribuerede systemer og meget mere! + +
+ +## Kom godt i gang + + +### 💻 Lokal Udvikling + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Dette vil starte Puter på http://puter.localhost:4100 (eller den næste tilgængelige port). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter er tilgængelig som en hosted tjeneste på [**puter.com**](https://puter.com). + +
+ +## Systemkrav + +- **Operativsystemer:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB anbefales) +- **Diskplads:** 1GB fri plads +- **Node.js:** Version 16+ (Version 22+ anbefales) +- **npm:** Seneste stabile version + +
+ +## Support + +Kom i kontakt med vedligeholderne og fællesskabet gennem disse kanaler: + +- Bugrapport eller funktionønske? Åbn [venligst en sag](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Sikkerhedsspørgsmål? [security@puter.com](mailto:security@puter.com) +- Send email til vedligeholdere på [hi@puter.com](mailto:hi@puter.com) + +Vi er altid glade for at hjælpe dig med eventuelle spørgsmål, du måtte have. Tøv ikke med at spørge! + +
+ + +## Licens + +Dette repository, inklusive alt dets indhold, underprojekter, moduler og komponenter, er licenseret under [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), medmindre andet er udtrykkeligt angivet. Tredjepartsbiblioteker inkluderet i dette repository kan være underlagt deres egne licenser. + +
diff --git a/doc/i18n/README.de.md b/doc/i18n/README.de.md new file mode 100644 index 0000000000000000000000000000000000000000..26073e18c50fa643d6bebaaa69a154b4072e130d --- /dev/null +++ b/doc/i18n/README.de.md @@ -0,0 +1,127 @@ +

Puter.com, Der persönliche Cloud-Computer: Alle Ihre Dateien, Apps und Spiele an einem Ort, jederzeit und überall zugänglich.

+ +

Das Internet-Betriebssystem! Kostenlos, Open-Source und selbst hostbar.

+ +

+ GitHub Repo-Größe GitHub Veröffentlichung GitHub Lizenz +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

Bildschirmfoto

+ +
+ +## Puter + +Puter ist ein fortschrittliches, Open-Source-Internet-Betriebssystem, das funktionsreich, außergewöhnlich schnell und hochgradig erweiterbar konzipiert wurde. Puter kann verwendet werden als: + +- Eine datenschutzfreundliche persönliche Cloud, um alle Ihre Dateien, Apps und Spiele an einem sicheren Ort aufzubewahren, jederzeit und überall zugänglich. +- Eine Plattform zum Erstellen und Veröffentlichen von Websites, Webanwendungen und Spielen. +- Eine Alternative zu Dropbox, Google Drive, OneDrive usw. mit einer frischen Benutzeroberfläche und leistungsstarken Funktionen. +- Eine Remote-Desktop-Umgebung für Server und Workstations. +- Ein freundliches, Open-Source-Projekt und eine Community, um mehr über Webentwicklung, Cloud Computing, verteilte Systeme und vieles mehr zu lernen! + +
+ +## Erste Schritte + + +### 💻 Lokale Entwicklung + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Dies startet Puter unter http://puter.localhost:4100 (oder dem nächsten verfügbaren Port). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter ist als gehosteter Dienst unter [**puter.com**](https://puter.com) verfügbar. + +
+ +## Systemanforderungen + +- **Betriebssysteme:** Linux, macOS, Windows +- **RAM:** Mindestens 2GB (4GB empfohlen) +- **Festplattenspeicher:** 1GB freier Speicherplatz +- **Node.js:** Version 16+ (Version 22+ empfohlen) +- **npm:** Neueste stabile Version + +
+ +## Unterstützung + +Verbinden Sie sich mit den Maintainern und der Community über diese Kanäle: + +- Fehlerbericht oder Funktionsanfrage? Bitte [öffnen Sie ein Issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Sicherheitsprobleme? [security@puter.com](mailto:security@puter.com) +- E-Mail an die Maintainer: [hi@puter.com](mailto:hi@puter.com) + +Wir helfen Ihnen gerne bei allen Fragen, die Sie haben könnten. Zögern Sie nicht zu fragen! + +
+ + +## Lizenz + +Dieses Repository, einschließlich aller Inhalte, Unterprojekte, Module und Komponenten, ist unter [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) lizenziert, sofern nicht ausdrücklich anders angegeben. In diesem Repository enthaltene Bibliotheken von Drittanbietern können ihren eigenen Lizenzen unterliegen. + +
diff --git a/doc/i18n/README.en.md b/doc/i18n/README.en.md new file mode 100644 index 0000000000000000000000000000000000000000..6011f58ad649d71c07262023f993f873a611e76e --- /dev/null +++ b/doc/i18n/README.en.md @@ -0,0 +1,127 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

The Internet OS! Free, Open-Source, and Self-Hostable.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter is an advanced, open-source internet operating system designed to be feature-rich, exceptionally fast, and highly extensible. Puter can be used as: + +- A privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time. +- A platform for building and publishing websites, web apps, and games. +- An alternative to Dropbox, Google Drive, OneDrive, etc. with a fresh interface and powerful features. +- A remote desktop environment for servers and workstations. +- A friendly, open-source project and community to learn about web development, cloud computing, distributed systems, and much more! + +
+ +## Getting Started + + +### 💻 Local Development + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +This will launch Puter at http://puter.localhost:4100 (or the next available port). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter is available as a hosted service at [**puter.com**](https://puter.com). + +
+ +## System Requirements + +- **Operating Systems:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB recommended) +- **Disk Space:** 1GB free space +- **Node.js:** Version 16+ (Version 22+ recommended) +- **npm:** Latest stable version + +
+ +## Support + +Connect with the maintainers and community through these channels: + +- Bug report or feature request? Please [open an issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Security issues? [security@puter.com](mailto:security@puter.com) +- Email maintainers at [hi@puter.com](mailto:hi@puter.com) + +We are always happy to help you with any questions you may have. Don't hesitate to ask! + +
+ + +## License + +This repository, including all its contents, sub-projects, modules, and components, is licensed under [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) unless explicitly stated otherwise. Third-party libraries included in this repository may be subject to their own licenses. + +
diff --git a/doc/i18n/README.es.md b/doc/i18n/README.es.md new file mode 100644 index 0000000000000000000000000000000000000000..d14bc7c34e6d8412ad9d61904f02d0bd47061a14 --- /dev/null +++ b/doc/i18n/README.es.md @@ -0,0 +1,171 @@ +

Puter.com, El Computador Personal en Nube: Todos tus archivos, apps y juegos en un solo lugar accesible desde cualquier lugar en cualquier momento

+ +

El Sistema Operativo de Internet! Gratis, de Código abierto, y Autohospedable.

+ +

+ « DEMO EN VIVO » +
+
+ Puter.com + · + App Store + · + Developers + · + CLI + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter es un sistema operativo en internet avanzado y de código abierto, diseñado para ser rico en funcionalidades, excepcionalmente rápido y altamente extensible. Puter puede ser usado como: + +- Una nube personal privada para almacenar todos tus archivos, aplicaciones y juegos en un lugar seguro, accesible y desde cualquier lugar en cualquier momento. +- Una plataforma para construir y publicar páginas web, aplicativos sobre la web y juegos. +- Una alternativa a Dropbox, Google Drive, OneDrive, etc. con una interfaz fresca y llena de funcionalidades. +- Un entorno de escritorio remoto para servidores y estaciones de trabajo. +- Un proyecto y comunidad abiertas y amigables para aprender sobre desarrollo web, computación en la nube, sistemas distribuidos y mucho más! + +
+ +## Primeros Pasos + + +### 💻 Desarrollo Local + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible). + +Si esto no funciona, consulta [First Run Issues](./doc/self-hosters/first-run-issues.md) para obtener pasos de solución de problemas. +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` +✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible). + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible). +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible). + +
+ +### 🚀 Auto-Hospedaje + +Para guías detalladas sobre cómo auto-hospedar Puter, incluyendo opciones de configuración y mejores prácticas, consulta nuestra [Documentación de Auto-Hospedaje](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md). + +### ☁️ Puter.com + +Puter está disponible como servicio alojado en [**puter.com**](https://puter.com). + +
+ +## Requerimientos del sistema + +- **Sistemas operativos:** Linux, macOS, Windows +- **RAM:** 2GB mínimo (4GB recomendados) +- **Almacenamiento:** 1GB de espacio libre +- **Node.js:** Versión 16+ (Versión 23+ recomendada) +- **npm:** Última version estable + +
+ +## Soporte + +Conéctate con los mantenedores y la comunidad a través de estos canales: + +- Reporte de bug o solicitud de funcionalidad? Por favor [abrir un issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Problemas de seguridad? [security@puter.com](mailto:security@puter.com) +- Envia un email a los mantenedores en [hi@puter.com](mailto:hi@puter.com) + +Estamos siempre felices de ayudar con cualquier pregunta que puedas tener. No dudes en preguntar! + +
+ + +## Licencia + +Este repositorio, incluyendo todo su contenido, sub-proyectos, modulos y componentes, esta licenciado bajo [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) a menos que se indique explícitamente lo contrario. Librerías de terceros incluidos en este repositorio pueden estar sujetas a sus propias licencias. + +
+ +## Traducciones + +- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md) +- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md) +- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md) +- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md) +- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md) +- [English](https://github.com/HeyPuter/puter/blob/main/README.md) +- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md) +- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md) +- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md) +- [German/ Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md) +- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md) +- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md) +- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md) +- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md) +- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md) +- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md) +- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md) +- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md) +- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md) +- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md) +- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md) +- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md) +- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md) +- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md) +- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md) +- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md) +- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md) +- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md) +- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md) +- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md) +- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md) \ No newline at end of file diff --git a/doc/i18n/README.fa.md b/doc/i18n/README.fa.md new file mode 100644 index 0000000000000000000000000000000000000000..923534fccb83d23c9ba9d9cab4478f1fc14645e7 --- /dev/null +++ b/doc/i18n/README.fa.md @@ -0,0 +1,133 @@ +

Puter.com، رایانش ابری شخصی: همه فایل‌ها، برنامه‌ها و بازی‌های شما در یک مکان قابل دسترسی از هر جا و در هر زمان.

+ +

سیستم‌عامل اینترنت! رایگان، متن‌باز، و قابل میزبانی شخصی.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « نسخه نمایشی زنده » +
+
+ Puter.com + · + مستندات توسعه‌دهندگان + · + دیسکورد + · + ردیت + · + ایکس (توییتر) +

+ +

عکس صفحه

+ +
+ +## پیوتر + +
+

پیوتر یک سیستم عامل تحت وب پیشرفته‌ی متن‌باز است که به منظور ایجاد ویژگی‌های متنوع، سرعت بسیار بالا، و مقیاس‌پذیری طراحی شده است. از پیوتر می‌توان به‌عنوان:

+ +
    +
  • یک فضای ابری شخصی که بر حریم خصوصی تمرکز دارد و تمام فایل‌ها، برنامه‌ها، و بازی‌های شما را در یک مکان امن ذخیره می‌کند، قابل دسترسی از هر جا و در هر زمان.
  • +
  • پلتفرمی برای ساخت و انتشار وب‌سایت‌ها، اپلیکیشن‌های وب، و بازی‌ها.
  • +
  • جایگزینی برای Dropbox، Google Drive، OneDrive، و سایر موارد، با یک رابط کاربری مدرن و قابلیت‌های قدرتمند.
  • +
  • یک محیط دسکتاپ از راه دور برای سرورها و ایستگاه‌های کاری.
  • +
  • یک پروژه و جامعه‌ی متن‌باز دوستانه برای یادگیری توسعه وب، رایانش ابری، سیستم‌های توزیع‌شده، و موارد دیگر نام برد!
  • +
+
+ +
+ +## نحوه‌ی استفاده + +### 💻 توسعه‌ی محلی + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +این کار پیوتر را در http://puter.localhost:4100 (یا پورت در دسترس بعدی) اجرا می‌کند. + +
+ +### 🐳 داکر + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 داکر کامپوز + + +#### لینوکس/مک +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### ویندوز + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ وبگاه Puter.com + +پیوتر به‌عنوان یک سرویس میزبانی‌شده در وبگاه [**puter.com**](https://puter.com) موجود است. + + +## پیش‌نیازهای سیستم + +- **سیستم‌عامل‌ها:** لینوکس، مک، ویندوز +- **RAM** حداقل ۲ گیگابایت (پیشنهاد: ۴ گیگابایت) +- **فضای دیسک:** ۱ گیگابایت فضای خالی +- **Node.js:** نسخه ۱۶+ (پیشنهاد: نسخه ۲۲+) +- **npm:** آخرین نسخه پایدار + +
+ +## پشتیبانی + +با مدیران و انجمن از طریق این کانال‌ها در تماس باشید: + +- گزارش اشکال یا درخواست ویژگی؟ لطفاً [Isuue باز کنید](https://github.com/HeyPuter/puter/issues/new/choose) + +- دیسکورد: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) + +- ایکس (توییتر): [x.com/HeyPuter](https://x.com/HeyPuter) + +- ردیت: [/reddit.com/r/puter](https://www.reddit.com/r/puter/) + +- ماستودون: [mastodon.social/@puter](https://mastodon.social/@puter) + +- مشکلات امنیتی؟ [security@puter.com](mailto:security@puter.com) + +- ایمیل مدیران: [hi@puter.com](mailto:hi@puter.com) + + +ما همیشه از پاسخگویی به سوالات شما خرسند هستیم. در سوال پرسیدن درنگ نکنید! + +## گواهی + +این مخزن، شامل تمام محتویات، پروژه‌های فرعی، ماژول‌ها و اجزای آن، تحت مجوز [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) است مگر آنکه خلاف آن به‌طور صریح ذکر شده باشد. کتابخانه‌های خارجی ممکن است گواهی‌های جداگانه داشته باشند. + +
diff --git a/doc/i18n/README.fi.md b/doc/i18n/README.fi.md new file mode 100644 index 0000000000000000000000000000000000000000..787476007a12c2271448781e7de55903ea402122 --- /dev/null +++ b/doc/i18n/README.fi.md @@ -0,0 +1,126 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+

Internetin käyttöjärjestelmä! Ilmainen, avoimen lähdekoodin ja itse isännöitävä.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

näyttökuva

+ +
+ +## Puter + +Puter on kehittynyt, avoimen lähdekoodin internetin käyttöjärjestelmä, joka on suunniteltu olemaan ominaisuuksiltaan rikas, poikkeuksellisen nopea ja erittäin laajennettava. Puteria voidaan käyttää: + +- Yksityisyyttä kunnioittavana henkilökohtaisena pilvenä, johon voit tallentaa kaikki tiedostosi, sovelluksesi ja pelisi turvallisesti yhdessä paikassa, josta ne ovat saatavilla missä tahansa ja milloin tahansa. +- Alustana verkkosivustojen, web-sovellusten ja pelien rakentamiseen ja julkaisemiseen. +- Vaihtoehtona Dropboxille, Google Drivelle, OneDrivelle jne. tuoreella käyttöliittymällä ja tehokkailla ominaisuuksilla. +- Etätyöpöytäympäristönä palvelimille ja työasemille. +- Ystävällisenä, avoimen lähdekoodin projektina ja yhteisönä, jossa voit oppia verkkokehityksestä, pilvipalveluista, hajautetuista järjestelmistä ja paljon muusta! + +
+ +## Aloittaminen + + +### 💻 Paikallinen kehitys + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Tämä käynnistää Puterin osoitteessa http://puter.localhost:4100 (tai seuraavassa vapaassa portissa). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter on saatavilla isännöitynä palveluna osoitteessa [**puter.com**](https://puter.com). + +
+ +## Järjestelmävaatimukset + +- **Käyttöjärjestelmät:** Linux, macOS, Windows +- **RAM:** Vähintään 2GB (Suositeltu 4GB) +- **Levytila:** 1GB vapaata tilaa +- **Node.js:** Versio 16+ (Suositeltu versio 22+) +- **npm:** Uusin vakaa versio + +
+ +## Tuki + +Ota yhteyttä ylläpitäjiin ja yhteisöön näiden kanavien kautta: + +- Onko sinulla virheraportti tai ominaisuuspyyntö? Ole hyvä ja [avaa uusi issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Turvallisuusongelmat? [security@puter.com](mailto:security@puter.com) +- Ota yhteyttä ylläpitäjiin sähköpostitse osoitteessa [hi@puter.com](mailto:hi@puter.com) + +Olemme aina valmiita auttamaan sinua kaikissa kysymyksissäsi. Älä epäröi kysyä! + +
+ + +## Lisenssi + +Tämä repository, mukaan lukien kaikki sen sisältö, aliprojektit, moduulit ja komponentit, on lisensoitu [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt)-lisenssillä, ellei toisin mainita. Tämän repositoryn mukana tulevat kolmannen osapuolen kirjastot voivat olla omien lisenssiensä alaisia. + +
diff --git a/doc/i18n/README.fr.md b/doc/i18n/README.fr.md new file mode 100644 index 0000000000000000000000000000000000000000..08d79eeb9c501cffff9609dcccde57f13cb4b8db --- /dev/null +++ b/doc/i18n/README.fr.md @@ -0,0 +1,125 @@ +

Puter.com, L'ordinateur cloud personnel : Tous vos fichiers, applications et jeux en un seul endroit accessible de partout à tout moment.

+ +

L'OS Internet ! Gratuit, open-source et auto-hébergeable.

+ +

+ Taille du dépôt GitHub Version GitHub Licence GitHub +

+

+ « DÉMO EN DIRECT » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

capture d'écran

+ +
+ +## Puter + +Puter est un système d'exploitation internet avancé, open-source, conçu pour être riche en fonctionnalités, extrêmement rapide et hautement extensible. Puter peut être utilisé comme : + +- Un cloud personnel axé sur la confidentialité pour garder tous vos fichiers, applications et jeux en un seul endroit sécurisé, accessible de partout à tout moment. +- Une plateforme pour créer et publier des sites web, des applications web et des jeux. +- Une alternative à Dropbox, Google Drive, OneDrive, etc. avec une interface renouvelée et des fonctionnalités puissantes. +- Un environnement de bureau à distance pour serveurs et stations de travail. +- Un projet et une communauté open-source accueillants pour apprendre le développement web, l'informatique en nuage, les systèmes distribués, et bien plus encore ! + +
+ +## Démarrage + + +### 💻 Développement Local + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Cela lancera Puter à http://puter.localhost:4100 (ou au port disponible suivant). + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 Docker Compose + +#### Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter est disponible en tant que service hébergé sur [**puter.com**](https://puter.com). + +
+ +## Configuration système requise +- **Systèmes d'exploitation:** Linux, macOS, Windows +- **RAM:** Minimum 2 Go (4 Go recommandés) +- **Espace disque:** 1 Go d'espace libre +- **Node.js:** Version 16+ (Version 22+ recommandée) +- **npm:** Dernière version stable + +
+ +## Support + +Connectez-vous avec les mainteneurs et la communauté via ces canaux : + +- Un bug ou une demande de fonctionnalité ? Veuillez [ouvrir une issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Problèmes de sécurité ? [security@puter.com](mailto:security@puter.com) +- Email des mainteneurs à [hi@puter.com](mailto:hi@puter.com) + +Nous sommes toujours heureux de vous aider avec toutes les questions que vous pourriez avoir. N'hésitez pas à nous demander ! + +
+ + +## License + +Ce dépôt, y compris tout son contenu, sous-projets, modules et composants, est licencié sous [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) sauf indication contraire explicite. Les bibliothèques tierces incluses dans ce dépôt peuvent être soumises à leurs propres licences. + +
+ diff --git a/doc/i18n/README.he.md b/doc/i18n/README.he.md new file mode 100644 index 0000000000000000000000000000000000000000..00f83ccfb00cf52839b64b9dd726691586568065 --- /dev/null +++ b/doc/i18n/README.he.md @@ -0,0 +1,130 @@ +

Puter.com, 
+הענן הפרטי: כל הקבצים, האפליקציות והמשחקים שלך במקום אחד נגיש מכל מקום ובכל זמן.

+ +

מערכת ההפעלה של האינטרנט! חינמית, קוד פתוח וניתנת לאחסון עצמאי.

+ +

+ GitHub גודל ספרית GitHub גרסא GitHub רישיון +

+

+ « הדגמה לייב » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

צילום מסך

+ +
+ +## Puter + +
+

+ מערכת ההפעלה Puter הינה ספרית קוד פתוח, מתקדמת, עשירה בתכנים, מהירה במיוחד וניתנת להרחבה. + אפשר להישתמש ב Puter כ:

+
    +
  • ענן אישי עם פרטיות מקסימלית, לשמירת הקבצים, האפליקציות והמשחקים שלך במקום מאובטח אחד, נגיש מכל מקום ובכל זמן.
  • +
  • פלטפורמה לבניית ופרסום אתרים, אפליקציות ומשחקים.
  • +
  • אלטרנטיבה ל-Dropbox, Google Drive, OneDrive וכו' עם ממשק מרענן ותכנים חזקים.
  • +
  • סביבה לעבודה מרחוק לשרתים ותחנות עבודה.
  • +
  • פרוייקט ידידותי, קוד פתוח וקהילה ללמידה על פיתוח אינטרנט, פיתוח בענן, מערכות מבוזרות ועוד הרבה!
  • +
      +
+ +
+ +## בוא נתחיל + +### 💻 פיתוח מקומי (Localhost) + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +פקודה זו תפעיל את Puter בכתובת http://puter.localhost:4100 (או בפורט הפנוי הבא). + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 Docker Compose + +#### Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` + +
+ +### ☁️ Puter.com + +מערכת ההפעלה Puter זמינה כשירות אחסון ב- [**puter.com**](https://puter.com). + +
+ +## דרישות מערכת + +- **מערכות הפעלה:** Linux, macOS, Windows +- **RAM:** לפחות 2GB, מומלץ 4GB +- **מקום פנוי בדיסק:** 1GB +- **Node.js:** גרסה 16+ (מומלץ גרסה 22+) +- **npm:** הגרסה היציבה האחרונה + +
+ +## תמיכה + +צור קשר עם המפתחים והקהילה דרך הערוצים הבאים: + +- דיווח על באג או בקשה לתוכן? אנא [פתח פניה](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter](https://www.reddit.com/r/puter.) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- בעיות אבטחה? [security@puter.com](mailto:security@puter.com) +- שלח אימייל למפתחים ב [hi@puter.com](mailto:hi@puter.com) + +אנחנו תמיד שמחים לעזור עם כל שאלה שיש. אל תהסס לשאול! + +
+ +## רישיון + +ספריה זו, כולל כל התכנים שלה, תתי הפרויקטים, המודולים והרכיבים שלה, מורשית תחת [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) אלא אם נאמר אחרת במפורש. לספריות צד שלישי הכלולות בספרייה זו עשויות להיות רישיונות משלהן. + +
diff --git a/doc/i18n/README.hi.md b/doc/i18n/README.hi.md new file mode 100644 index 0000000000000000000000000000000000000000..3001bd977ef7cbc885cbe812c871450042ee1779 --- /dev/null +++ b/doc/i18n/README.hi.md @@ -0,0 +1,182 @@ +

Puter.com, The Personal Cloud Computer: आपकी सारी फाइलें, ऐप्स, और गेम एक ही जगह, जिसे कहीं से भी कभी भी एक्सेस किया जा सकता है।

+ +

इंटरनेट ओएस! फ्री, ओपन-सोर्स, और सेल्फ-होस्टेबल।

+ +

+ « लाइव डेमो » +
+
+ Puter.com + · + ऐप स्टोर + · + डेवलपर्स + · + CLI + · + Discord + · + Reddit + · + X +

+ +

screenshot

+ +
+ +## Puter क्या है? + +Puter एक एडवांस्ड, ओपन-सोर्स इंटरनेट ऑपरेटिंग सिस्टम है जिसे फीचर-रिच, तेज़ और एक्सटेंडेबल बनाने के लिए डिज़ाइन किया गया है। Puter का उपयोग आप निम्नलिखित चीजों के लिए कर सकते हैं: + +- एक प्राइवेसी-फर्स्ट पर्सनल क्लाउड, जो आपकी सभी फाइलों, ऐप्स और गेम्स को एक सेफ जगह पर रखता है, जिसे आप कहीं से भी कभी भी एक्सेस कर सकते हैं। +- वेबसाइट्स, वेब ऐप्स और गेम्स बनाने और पब्लिश करने का एक प्लेटफ़ॉर्म। +- Dropbox, Google Drive, OneDrive आदि का एक शानदार और पावरफुल इंटरफ़ेस वाला विकल्प। +- सर्वर और वर्कस्टेशन के लिए एक रिमोट डेस्कटॉप एनवायरनमेंट। +- एक फ्रेंडली ओपन-सोर्स प्रोजेक्ट और कम्युनिटी, जहां आप वेब डेवलपमेंट, क्लाउड कंप्यूटिंग, डिस्ट्रीब्यूटेड सिस्टम्स और बहुत कुछ सीख सकते हैं। + +
+ +## शुरुआत कैसे करें? + +### 💻 लोकल डेवलपमेंट + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +✨ यह Puter को + http://puter.localhost:4100 (या अगले उपलब्ध पोर्ट) पर लॉन्च करेगा। + + +अगर यह काम नहीं करता, तो [First Run Issues](./doc/self-hosters/first-run-issues.md) देखें। + + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +✨ यह Puter को + http://puter.localhost:4100 (या अगले उपलब्ध पोर्ट) पर लॉन्च करेगा। + +
+ +### 🐙 Docker Compose + +#### Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +✨ यह http://puter.localhost:4100 + (या अगले उपलब्ध पोर्ट) पर उपलब्ध होगा। + +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` + +✨ यह Puter को + http://puter.localhost:4100 (or the next available port). (या अगले उपलब्ध पोर्ट) पर लॉन्च करेगा। + +
+ +### 🚀 सेल्फ-होस्टिंग + +सेल्फ-होस्टिंग के लिए विस्तृत गाइड, कॉन्फ़िगरेशन ऑप्शन्स और बेस्ट प्रैक्टिसेज जानने के लिए हमारी [Self-Hosting Documentation](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md) देखें। + +
+ +### ☁️ Puter.com + +Puter [**puter.com**](https://puter.com) पर एक होस्टेड सर्विस के रूप में भी उपलब्ध है। + +
+ +## सिस्टम आवश्यकताएँ + +* **ऑपरेटिंग सिस्टम्स:** Linux, macOS, Windows +* **RAM:** कम से कम 2GB (4GB रिकमेंडेड) +* **डिस्क स्पेस:** 1GB फ्री स्पेस +* **Node.js:** वर्जन 16+ (वर्जन 23+ रिकमेंडेड) +* **npm:** लेटेस्ट स्टेबल वर्जन + +
+ +## सपोर्ट + +नीचे दिए गए माध्यमों से आप मेंटेनर्स और कम्युनिटी से जुड़ सकते हैं: + +* बग रिपोर्ट या फीचर रिक्वेस्ट? [यहाँ issue खोलें](https://github.com/HeyPuter/puter/issues/new/choose)। +* Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +* X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +* Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +* Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +* सिक्योरिटी इशूज़? [security@puter.com](mailto:security@puter.com) +* ईमेल करें: [hi@puter.com](mailto:hi@puter.com) + +आपके किसी भी सवाल में मदद करने के लिए हम हमेशा तैयार हैं! + +
+ +## लाइसेंस + +यह रिपॉज़िटरी और इसके सभी कंटेंट्स [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) लाइसेंस के अंतर्गत आते हैं जब तक कि कुछ और स्पष्ट रूप से ना लिखा हो। इसमें शामिल थर्ड-पार्टी लाइब्रेरीज़ अपने-अपने लाइसेंस के अधीन हो सकती हैं। + +
+ +## अनुवाद + +Puter के डॉक्यूमेंटेशन कई भाषाओं में उपलब्ध हैं, जिनमें शामिल हैं: + +- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md) +- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md) +- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md) +- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md) +- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md) +- [English](https://github.com/HeyPuter/puter/blob/main/README.md) +- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md) +- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md) +- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md) +- [German/ Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md) +- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md) +- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md) +- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md) +- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md) +- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md) +- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md) +- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md) +- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md) +- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md) +- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md) +- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md) +- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md) +- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md) +- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md) +- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md) +- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md) +- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md) +- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md) +- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md) +- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md) +- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md) + diff --git a/doc/i18n/README.hu.md b/doc/i18n/README.hu.md new file mode 100644 index 0000000000000000000000000000000000000000..e89b17c8113fe3aa23e77b8c77c26b606edab781 --- /dev/null +++ b/doc/i18n/README.hu.md @@ -0,0 +1,128 @@ +

Puter.com, A személyi felhő számítógép:  Minden fájl, alkalmazás és játék egy helyen elérhető bárhonnan, bármikor.

+ +

Az internetes oprendszer! Ingyenes, nyílt-forráskódú, saját szerveren futtatható.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « ÉLŐ DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter +A Puter egy fejlett, nyílt forráskódú internetes operációs rendszer, amelyet úgy terveztek, hogy funkciókban gazdag, kivételesen gyors és nagymértékben bővíthető legyen. A Puter a következőképpen használható: + +- Egy adatvédelmet előtérbe helyező személyes felhő, amely minden fájlt, alkalmazást és játékot egy biztonságos helyen tart. Bárhonnan és bármikor elérhető. +- Egy platform weboldalak, web-appok, és játékok készítéséhez/közzétételéhez. +- A Dropbox, Google Drive, OneDrive (stb.) alternatívája megújult felülettel és hatékony funkciókkal. +- Egy távoli desktop-környezet szervereknek és workstation-öknek. +- Egy barátságos, nyílt forráskódú projekt és közösség, amely a webfejlesztéssel, a felhőalapú számítástechnikával, elosztott rendszerekkel és sok más érdekes témával foglalkozik! + +
+ +## Első lépések + + +### 💻 Helyi (lokális) fejlesztés + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Ezzel a http://puter.localhost:4100 -on futtatjuk Putert. (vagy a legközelebbi elérhető porton). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +A Puter elérhető hostolt szolgáltatásként a [**puter.com**](https://puter.com) címen. + +
+ +## Rendszerkövetelmények + +- **Operációs rendszerek:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB ajánlott) +- **Tárhely:** 1GB szabad tárhely +- **Node.js:** 16+ (22+ verzió ajánlott) +- **npm:** legújabb stabil verzió + +
+ +## Támogatás + +Lépj kapcsolatba a fejlesztőkkel és a közösséggel az alábbi platformokon: + +- Észrevételeid/javaslataid vannak? Az [alábbi linken](https://github.com/HeyPuter/puter/issues/new/choose) megoszthatod velünk. +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Biztonsági hibák? [security@puter.com](mailto:security@puter.com) +- A fejlesztőket a [hi@puter.com](mailto:hi@puter.com) email címen érheted el. + + +Mindig örömmel segítünk bármilyen felmerülő kérdésben. Bátran kérdezz tőlünk! + +
+ + +## License + + +Ez a repo, beleértve annak minden tartalmát, alprojektjeit, moduljait és komponenseit, az [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) licenc alatt áll, hacsak másképp nem rendelkeznek róla. A repoban szereplő harmadik fél által fejlesztett könyvtárak saját licencfeltételek alá eshetnek. + +
diff --git a/doc/i18n/README.hy.md b/doc/i18n/README.hy.md new file mode 100644 index 0000000000000000000000000000000000000000..f91ac481d43f294d69647189a73e1f0d77afb6a4 --- /dev/null +++ b/doc/i18n/README.hy.md @@ -0,0 +1,127 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

Ինտերնետ ՕՀ! Անվճար, բաց կոդով և ինքնահոսթ հնարավորությամբ։

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « Օնլայն դեմո » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter-ը առաջադեմ, բաց կոդով ինտերնետային օպերացիոն համակարգ է, որը նախագծված է լինել ֆունկցիոնալ հարուստ, բացառիկ արագ և բարձր ընդլայնելի։ Puter-ը կարող է օգտագործվել հետևյալ կերպ․ + +- Անձնական ամպային համակարգ՝ առաջնային գաղտնիությամբ, որը թույլ է տալիս պահել ձեր բոլոր ֆայլերը, հավելվածները և խաղերը մեկ անվտանգ վայրում, որը հասանելի է ցանկացած վայրից և ցանկացած ժամանակ։ +- Պլատֆորմ կայքերի, վեբ հավելվածների և խաղերի ստեղծման և հրապարակման համար։ +- Dropbox, Google Drive, OneDrive և այլ ծառայությունների այլընտրանք՝ նոր ինտերֆեյսով և հզոր գործառույթներով։ +- Հեռավոր աշխատասեղանի միջավայր սերվերների և աշխատանքային կայանների համար։ +- Պարզ, բաց կոդով նախագիծ և համայնք՝ վեբ ծրագրավորման, ամպային հաշվարկների, բաշխված համակարգերի և այլ թեմաների մասին սովորելու համար։ + +
+ +## Սկսել + + +### 💻 Լոկալ ծրագրավորում + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Սա կգործակի Puter-ը հետևյալ հասցեով՝ http://puter.localhost:4100 (կամ հաջորդ հասանելի պորտով)։ + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter-ը հասանելի է որպես հյուրընկալվող ծառայություն [**puter.com**](https://puter.com). + +
+ +## System Requirements + +- **Օպերացիոն համակարգ:** Linux, macOS, Windows +- **Օպերատիվ հիշողություն:** 2GB նվազագույնը (4GB խորհուրդ է տրվում) +- **Համակարգչի հիշողություն:** 1GB ազատ տարածություն +- **Node.js:** Տարբերակ 16+ (Տարբերակ 22+ խորհուրդ է տրվում) +- **npm:** Վերջին կայուն տարբերակը + +
+ +## Աջակցություն + +Կապվեք համակարգողների և համայնքի հետ այս կայքերի միջոցով՝ + +- Սխալների կամ գործառույթի հարցում՝ (https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Անվտանգության խնդիրներ՝ [security@puter.com](mailto:security@puter.com) +- Email maintainers at [hi@puter.com](mailto:hi@puter.com) + +Մենք միշտ ուրախ ենք օգնել ձեզ ցանկացած հարցում։ Մի կաշկանդվեք հարցնել։ + +
+ + +## Լիցենզիա + +Այս պահոցարանը, ներառյալ բոլոր իր բովանդակությունը, ենթա-պրոյեկտները, մոդուլները և բաղադրիչները, լիցենզավորվում են [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) լիցենզիայի տակ, եթե այլ կերպ հստակ նշված չէ։ Այս պահոցարանում ներառված երրորդ կողմի գրադարանները կարող են ենթարկվել իրենց սեփական լիցենզիաներին։ + +
diff --git a/doc/i18n/README.id.md b/doc/i18n/README.id.md new file mode 100644 index 0000000000000000000000000000000000000000..d194ca3004bafc22cc1ff972a9c8582cea792b69 --- /dev/null +++ b/doc/i18n/README.id.md @@ -0,0 +1,127 @@ +

Puter.com, Komputer Cloud Pribadi: Semua file, aplikasi, dan permainan Anda berada di satu tempat yang dapat diakses dari mana saja kapan saja.

+ +

Sistem Operasi Internet! Gratis, Sumber Terbuka, dan Dapat Dihosting Sendiri.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter adalah sistem operasi internet canggih, open-source, yang dirancang untuk menjadi kaya fitur, sangat cepat, dan sangat dapat diperluas. Puter dapat digunakan sebagai: + +- Cloud pribadi yang mengutamakan privasi untuk menyimpan semua file, aplikasi, dan permainan Anda di satu tempat yang aman, yang dapat diakses dari mana saja kapan saja. +- Platform untuk membangun dan mempublikasikan situs web, aplikasi web, dan permainan. +- Alternatif untuk Dropbox, Google Drive, OneDrive, dll. Dengan antarmuka baru dan fitur-fitur canggih. +- Lingkungan desktop jarak jauh untuk server dan workstation. +- Proyek dan komunitas open-source yang ramah untuk belajar tentang pengembangan web, komputasi gemawan (cloud), sistem terdistribusi, dan banyak lagi! + +
+ +## Memulai + + +### 💻 Pengembangan Lokal + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Ini akan menjalankan Puter di http://puter.localhost:4100 (atau di port berikutnya yang tersedia) + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter tersedia sebagai layanan yang telah dihosting di [**puter.com**](https://puter.com). + +
+ +## Persyaratan Sistem + +- **Sistem Operasi:** Linux, macOS, Windows +- **RAM:** 2GB minimal (rekomendasi 4GB) +- **Penyimpanan:** 1GB ruang tersedia +- **Node.js:** Version 16+ (rekomendasi versi 22+) +- **npm:** Versi stabil termutakhir + +
+ +## Dukuangan + +Terhubung dengan maintainer dan komunitas melalui saluran-saluran berikut: + +- Laporan bug atau permintaan fitur? Silakan [buat issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Isu keamanan? [security@puter.com](mailto:security@puter.com) +- Email maintainers di [hi@puter.com](mailto:hi@puter.com) + +Kami selalu senang membantu Anda dengan pertanyaan apa pun yang Anda miliki. Jangan ragu untuk bertanya! + +
+ + +## Lisensi + +Repositori ini, termasuk semua isinya, sub-proyek, modul, dan komponen, dilisensikan di bawah [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) kecuali dinyatakan sebaliknya secara eksplisit. Perpustakaan pihak ketiga yang termasuk dalam repositori ini mungkin tunduk pada lisensinya sendiri. + +
diff --git a/doc/i18n/README.it.md b/doc/i18n/README.it.md new file mode 100644 index 0000000000000000000000000000000000000000..966575cb7789a0f021b673dda60e39a25265d146 --- /dev/null +++ b/doc/i18n/README.it.md @@ -0,0 +1,128 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

Il sistema operativo di Internet! Gratuito, Open-Source e Auto-Hostabile.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter è un sistema operativo di Internet avanzato e open-source, progettato per essere ricco di funzionalità, eccezionalmente veloce e altamente estensibile. Puter può essere utilizzato come: + +- Un cloud personale che tiene conto della privacy per conservare tutti i file, le app e i giochi in un luogo sicuro, accessibile da qualsiasi luogo e in qualsiasi momento. +- Una piattaforma per creare e pubblicare siti web, app e giochi. +- Un'alternativa a Dropbox, Google Drive, OneDrive, ecc. con un'interfaccia nuova e funzioni potenti. +- Un ambiente desktop remoto per server e workstation. +- Un progetto e una comunità open-source amichevole per imparare lo sviluppo web, il cloud computing, i sistemi distribuiti e molto altro ancora! + +
+ +## Getting Started + + +### 💻 Local Development + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +In questo modo Puter verrà avviato all'indirizzo http://puter.localhost:4100 (o alla prossima porta disponibile). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter è disponibile come servizio in hosting su [**puter.com**](https://puter.com). + +
+ +## Requisiti di Sistema + +- **Sistema Operativo:** Linux, macOS, Windows +- **RAM:** 2GB minimi (4GB raccomandati) +- **Spazio su Disco:** 1GB liberi +- **Node.js:** Versione 16+ (Versione 22+ raccomandati) +- **npm:** Ultima versione stabile + +
+ +## Supporto + +Collegatevi con i maintainers e la comunità attraverso questi canali: + +- Segnalazione di bug o richiesta di funzionalità? Perfavore [aprire una issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Problemi di sicurezza? [security@puter.com](mailto:security@puter.com) +- Email maintainers a [hi@puter.com](mailto:hi@puter.com) + +Siamo sempre felici di aiutarvi con qualsiasi domanda. Non esitate a chiedere! + +
+ + +## Licenza + +Questo repository, compresi tutti i suoi contenuti, sottoprogetti, moduli e componenti, è concesso in licenza [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), a meno che non sia esplicitamente indicato diversamente. Le librerie di terze parti incluse in questo repository possono essere soggette alle loro licenze. + +
+ diff --git a/doc/i18n/README.jp.md b/doc/i18n/README.jp.md new file mode 100644 index 0000000000000000000000000000000000000000..eee44282f9336ce296584877ae5029b90d01d082 --- /dev/null +++ b/doc/i18n/README.jp.md @@ -0,0 +1,123 @@ + +

Puter.com, あなたのファイル、アプリ、ゲームをどこからでもアクセス可能にするパーソナルクラウドコンピュータ

+ +

インターネットOS!無料、オープンソース、セルフホスト可能。

+ +

+ GitHub リポジトリサイズ GitHub リリース GitHub ライセンス +

+

+ « ライブデモ » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

スクリーンショット

+ +
+ +## Puter + +Puterは、機能豊富で非常に高速、そして高い拡張性を持つ、先進的なオープンソースのインターネットオペレーティングシステムです。Puterは以下の用途に利用できます: + +- プライバシーを最優先するパーソナルクラウドとして、あなたのファイル、アプリ、ゲームを一か所で安全に管理し、どこからでもアクセス可能に。 +- ウェブサイト、ウェブアプリ、ゲームの作成と公開のためのプラットフォーム。 +- Dropbox、Google Drive、OneDriveなどの代替として、新しいインターフェースと強力な機能を提供。 +- サーバーやワークステーションのためのリモートデスクトップ環境。 +- ウェブ開発、クラウドコンピューティング、分散システムなどを学ぶための、フレンドリーでオープンなコミュニティとプロジェクト。 + +
+ +## はじめに + + +### 💻 ローカル開発 + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +これでPuterが http://puter.localhost:4100 (または次に利用可能なポート)で起動します。 + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 Docker Compose + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puterは[**puter.com**](https://puter.com)でホストサービスとして利用可能です。 + +
+ +## システム要件 + +- **オペレーティングシステム:** Linux, macOS, Windows +- **RAM:** 最小2GB(推奨4GB) +- **ディスクスペース:** 1GBの空き容量 +- **Node.js:** バージョン16以上(推奨バージョン22以上) +- **npm:** 最新の安定バージョン + +
+ +## サポート + +メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう: + +- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。 +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- セキュリティの問題? [security@puter.com](mailto:security@puter.com) +- メンテナーへのメールは [hi@puter.com](mailto:hi@puter.com) まで + +質問があれば、いつでもお気軽にお問い合わせください! + +
+ +## ライセンス + +このリポジトリ、ならびにそのすべてのコンテンツ、サブプロジェクト、モジュール、コンポーネントは、[AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt)の下でライセンスされています。明示的に異なるライセンスが示されている場合を除きます。このリポジトリに含まれるサードパーティのライブラリは、それぞれのライセンスが適用される場合があります。 + +
diff --git a/doc/i18n/README.ko.md b/doc/i18n/README.ko.md new file mode 100644 index 0000000000000000000000000000000000000000..e5aae263935740b759a318766d999ede50ea9ac2 --- /dev/null +++ b/doc/i18n/README.ko.md @@ -0,0 +1,127 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

Puter: 인터넷 OS! 무료이고 오픈소스이며 자체 호스팅이 가능합니다.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « 시연 영상 » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter는 오픈소스 인터넷 운영 체제로, 매우 빠르고 확장성이 뛰어나며 새로운 인터페이스와 다양한 기능을 갖추고 있습니다. Puter는 다음과 같이 사용될 수 있습니다: + +- 모든 파일, 앱, 게임을 한 곳에 안전하게 보관하고 언제 어디서나 접근할 수 있는 프라이버시 중심의 개인 클라우드로 사용할 수 있습니다. +- 웹사이트, 웹 앱, 게임을 구축하고 배포하는 플랫폼으로 활용할 수 있습니다. +- Dropbox, Google Drive, OneDrive 등의 대안으로 사용할 수 있으며 보다 발전된 기능과 인터페이스를 제공합니다. +- 서버와 워크스테이션을 위한 원격 데스크톱 환경으로 활용할 수 있습니다. +- 웹 개발, 클라우드 컴퓨팅, 분산 시스템 등에 대해 배울 수 있는 친근한 오픈소스 프로젝트이자 커뮤니티입니다! + +
+ +## 시작하기 + + +### 💻 로컬 환경 개발 + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +위처럼 실행할 시 Puter는 http://puter.localhost:4100 (또는 사용 가능한 다음 포트)에서 실행됩니다. + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter는 [**puter.com**](https://puter.com)에서 호스팅 서비스로 이용할 수 있습니다. + +
+ +## 시스템 요구사항 + +- **Operating Systems:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB recommended) +- **Disk Space:** 1GB free space +- **Node.js:** Version 16+ (Version 22+ recommended) +- **npm:** Latest stable version + +
+ +## 지원 + +다음 채널을 통해 관리자 및 커뮤니티와 소통하세요: + +- 버그 신고나 기능 요청이 있으신가요? [이슈를 열어주세요.](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- 보안 관련 문제는 [security@puter.com](mailto:security@puter.com) 으로 연락주세요. +- 관리자에게 이메일 보내기: [hi@puter.com](mailto:hi@puter.com) + +어떤 질문이든 기꺼이 도와드리겠습니다. 언제든 물어보세요! + +
+ + +## 라이선스 + +이 저장소는 모든 내용, 하위 프로젝트, 모듈 및 구성 요소를 포함하여 명시적으로 달리 명시되지 않는 한 [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) 라이선스 하에 제공됩니다. 이 저장소에 포함된 제3자 라이브러리는 해당 라이브러리의 고유 라이선스를 따를 수 있습니다. + +
diff --git a/doc/i18n/README.ml.md b/doc/i18n/README.ml.md new file mode 100644 index 0000000000000000000000000000000000000000..94b8f02b90854b538cf7037fa4a74ffd45dcd723 --- /dev/null +++ b/doc/i18n/README.ml.md @@ -0,0 +1,128 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

ഇന്റർനെറ്റ് ഓപ്പറേറ്റിംഗ് സിസ്റ്റം!
സൗജന്യവും, ഓപ്പൺ സോഴ്സും സ്വയം ഹോസ്റ്റ് ചെയ്യാൻ പറ്റുന്നതും

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « ലൈവ് ഡെമോ » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## പ്യൂട്ടർ (Puter) + +ഫീച്ചറുകളാൽ സമ്പുഷ്ടവും അസാധാരണമാംവിധം വേഗതയേറിയതും,വളരെ വിപുലീകരിക്കാവുന്നതുമായ ഒരു നൂതന, ഓപ്പൺ സോഴ്‌സ് ഇന്റർനെറ്റ് ഓപ്പറേറ്റിംഗ് സിസ്റ്റമാണ് പ്യൂട്ടർ. പ്യൂട്ടർ ഇനിപ്പറയുന്ന രീതിയിൽ ഉപയോഗിക്കാം: + +- നിങ്ങളുടെ എല്ലാ ഫയലുകളും ആപ്പുകളും ഗെയിമുകളും ഒരു സുരക്ഷിത സ്ഥലത്ത് സൂക്ഷിക്കുന്നതിനുള്ള, സ്വകാര്യതയ്ക്ക് മുൻഗണന കൊടുക്കുന്ന ആദ്യത്തെ വ്യക്തിഗത ക്ലൗഡ്, എവിടെ നിന്നും എപ്പോൾ വേണമെങ്കിലും ആക്‌സസ് ചെയ്യാൻ കഴിയും. +- വെബ്‌സൈറ്റുകൾ, വെബ് ആപ്പുകൾ, ഗെയിമുകൾ എന്നിവ നിർമ്മിക്കുന്നതിനും പ്രസിദ്ധീകരിക്കുന്നതിനുമുള്ള ഒരു പ്ലാറ്റ്ഫോം. +- പുതിയ ഇന്റർഫേസും, ശക്തമായ ഫീച്ചറുകളും അടങ്ങിയ, ഡ്രോപ്പ്‌ബോക്‌സ്, ഗൂഗിൾ ഡ്രൈവ്, വൺഡ്രൈവ് മുതലായവയ്‌ക്കുള്ള ബദൽ. +- സെർവറുകൾക്കും വർക്ക്സ്റ്റേഷനുകൾക്കും, ഒരു വിദൂര ഡെസ്ക്ടോപ്പ് പരിസ്ഥിതി. +- വെബ് ഡെവലപ്മെന്റ്, ക്ലൗഡ് കംപ്യൂട്ടിംഗ്, ഡിസ്ട്രിബ്യൂട്ടഡ് സിസ്റ്റങ്ങൾ എന്നിവയെ കുറിച്ചും, അതിലേറെ കാര്യങ്ങളെ കുറിച്ചും അറിയാനുള്ള സൗഹൃദപരവും ഓപ്പൺ സോഴ്സുമായ പ്രോജക്റ്റും കമ്മ്യൂണിറ്റിയും! + +
+ +## തുടങ്ങാനായി + + +### 💻 ലോക്കൽ ഡെവലപ്മെന്റ് + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + + +ഇത് http://puter.localhost:4100 (അല്ലെങ്കിൽ അടുത്ത ലഭ്യമായ പോർട്ടിൽ) എന്നതിൽ Puter സമാരംഭിക്കും + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +പ്യൂട്ടർ [**puter.com**](https://puter.com) എന്നതിൽ ഹോസ്റ്റ് ചെയ്‌ത സേവനമായി ലഭ്യമാണ്. + +
+ +## സിസ്റ്റത്തിന്റെ ആവശ്യകതകൾ + +- **ഓപ്പറേറ്റിംഗ് സിസ്റ്റങ്ങൾ:** ലിനക്സ്, മാക്ക് ഒഎസ്, വിൻഡോസ് +- **RAM:** 2GB കുറഞ്ഞത് (4GB ശുപാർശ ചെയ്യുന്നു) +- **ഡിസ്ക് സ്പേസ്:** 1GB ഒഴിഞ്ഞ ഇടം +- **Node.js:** Version 16+ (Version 22+ ശുപാർശ ചെയ്യുന്നു) +- **npm:** ഏറ്റവും പുതിയ സ്ഥിരതയുള്ള പതിപ്പ് + +
+ +## പിന്തുണ + +ഈ ചാനലുകളിലൂടെ പരിപാലിക്കുന്നവരുമായും കമ്മ്യൂണിറ്റിയുമായും ബന്ധപ്പെടുക: + +- ബഗ്ഗ് റിപ്പോർട്ടോ, ഫീച്ചർ റിക്ക്വസ്റ്റോ ഉണ്ടോ? ദയവുചെയ്ത് [ഒരു ഇഷ്യൂ തുടങ്ങുക](https://github.com/HeyPuter/puter/issues/new/choose). +- ഡിസ്കോർഡ്: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- എക്സ് (ട്വിറ്റർ): [x.com/HeyPuter](https://x.com/HeyPuter) +- റെഡ്ഡിറ്റ്: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- മാസ്റ്റഡൺ: [mastodon.social/@puter](https://mastodon.social/@puter) +- സുരക്ഷാ പ്രശ്നങ്ങളുണ്ടോ? [security@puter.com](mailto:security@puter.com) +- ഇമെയിൽ മെയിന്റൈനർമാർ: [hi@puter.com](mailto:hi@puter.com) + +നിങ്ങൾക്ക് ഉണ്ടായേക്കാവുന്ന ഏത് ചോദ്യങ്ങളിലും നിങ്ങളെ സഹായിക്കുന്നതിൽ ഞങ്ങൾക്ക് എപ്പോഴും സന്തോഷമുണ്ട്. ചോദിക്കാൻ മടിക്കേണ്ട! + +
+ + +## ലൈസൻസ് + +ഈ ശേഖരം, അതിന്റെ എല്ലാ ഉള്ളടക്കങ്ങളും, ഉപപദ്ധതികളും, മൊഡ്യൂളുകളും, ഘടകങ്ങളും ഉൾപ്പെടെ, [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) എന്നതിന് കീഴിൽ ലൈസൻസുള്ളതാണ്. ഈ ശേഖരത്തിൽ ഉൾപ്പെടുത്തിയിരിക്കുന്ന മൂന്നാം കക്ഷി ലൈബ്രറികൾ അവരുടെ സ്വന്തം ലൈസൻസുകൾക്ക് വിധേയമായിരിക്കാം. + +
diff --git a/doc/i18n/README.my.md b/doc/i18n/README.my.md new file mode 100644 index 0000000000000000000000000000000000000000..872826fa8b65fe4d13e324de5c30746026effddf --- /dev/null +++ b/doc/i18n/README.my.md @@ -0,0 +1,127 @@ +

Puter.com, The Personal Cloud Computer: Semua fail, apl, dan permainan anda di satu tempat yang boleh diakses dari mana sahaja pada bila-bila masa.

+ +

Sistem Operasi Internet! Percuma, Sumber Terbuka, dan Boleh Dihoskan Sendiri.

+ +

+ Saiz repo GitHub Terbitan GitHub Lesen GitHub +

+

+ « DEMO SECARA LANGSUNG » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter ialah sistem operasi internet sumber terbuka yang maju dan direka untuk kaya dengan ciri kefungsian, kepantasan luar biasa dan kebolehluasan yang tinggi. Puter boleh digunakan sebagai: + +- Storan awan peribadi yang mendahulukan privasi untuk menyimpan semua fail, aplikasi dan permainan anda di satu tempat yang selamat dan boleh diakses dari mana sahaja pada bila-bila masa. +- Platform untuk membina dan menerbitkan laman web, aplikasi web dan permainan. +- Alternatif kepada Dropbox, Google Drive, OneDrive, dan lain-lain dengan antara muka yang baharu dan ciri kefungsian berkuasa tinggi. +- Persekitaran desktop awan untuk server dan stesen kerja. +- Projek dan komuniti sumber terbuka yang mesra untuk mempelajari pembangunan laman web, pengkomputeran awan, sistem teragih, dan banyak lagi! + +
+ +## Mulakan + + +### 💻 Pembangunan Lokal + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Ini akan melancarkan Puter di http://puter.localhost:4100 (atau port seterusnya yang tersedia). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter tersedia sebagai perkhidmatan terhos di [**puter.com**](https://puter.com). + +
+ +## Keperluan Sistem + +- **Sistem Operasi:** Linux, macOS, Windows +- **RAM:** Minimum 2GB (sebaiknya 4GB) +- **Ruang Storan:** 1GB ruang kosong +- **Node.js:** Versi 16+ (sebaiknya Versi 22+) +- **npm:** Versi stabil yang terkini + +
+ +## Sokongan + +Berhubung dengan penyelenggara dan komuniti melalui saluran berikut: + +- Laporan pepijat atau permintaan ciri? Sila [buka isu baharu](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Isu keselamatan? [security@puter.com](mailto:security@puter.com) +- Emel penyelenggara melalui [hi@puter.com](mailto:hi@puter.com) + +Kami sentiasa gembira untuk membantu anda dengan apa-apa soalan. Jangan takut untuk bertanya! + +
+ + +## Lesen + +Repositori ini, termasuklah kandungannya, subprojek, modul dan komponen, dilesenkan di bawah [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) melainkan dinyatakan sebaliknya. *Library* pihak ketiga yang terkandung dalam repositori ini tertakluk kepada lesen mereka sendiri. + +
diff --git a/doc/i18n/README.nl.md b/doc/i18n/README.nl.md new file mode 100644 index 0000000000000000000000000000000000000000..64436b79d2832e57fc0371e31fb98cf872dcf6db --- /dev/null +++ b/doc/i18n/README.nl.md @@ -0,0 +1,127 @@ +

Puter.com, De Persoonlijke Cloud Computer: Al je bestanden, apps en games op één plek, overal en altijd toegankelijk.

+ +

Het Internet OS! Gratis, Open-Source en Zelf te Hosten.

+ +

+ GitHub repo grootte GitHub Release GitHub Licentie +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter is een geavanceerd, open-source internetbesturingssysteem ontworpen om functierijk, uitzonderlijk snel en zeer uitbreidbaar te zijn. Puter kan worden gebruikt als: + +- Een privacy-gerichte persoonlijke cloud om al je bestanden, apps en games op één veilige plek te bewaren, overal en altijd toegankelijk. +- Een platform voor het bouwen en publiceren van websites, web-apps en games. +- Een alternatief voor Dropbox, Google Drive, OneDrive, etc. met een frisse interface en krachtige functies. +- Een externe desktopomgeving voor servers en werkstations. +- Een vriendelijk, open-source project en gemeenschap om te leren over webontwikkeling, cloud computing, gedistribueerde systemen en veel meer! + +
+ +## Aan de slag + + +### 💻 Lokale Ontwikkeling + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Dit zal Puter starten op http://puter.localhost:4100 (of de eerstvolgende beschikbare poort). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter is beschikbaar als een gehoste service op [**puter.com**](https://puter.com). + +
+ +## Systeemvereisten + +- **Besturingssystemen:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB aanbevolen) +- **Schijfruimte:** 1GB vrije ruimte +- **Node.js:** Versie 16+ (Versie 22+ aanbevolen) +- **npm:** Laatste stabiele versie + +
+ +## Ondersteuning + +Verbind met de onderhouders en de gemeenschap via deze kanalen: + +- Bug rapport of functieverzoek? [Open een issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Beveiligingsproblemen? [security@puter.com](mailto:security@puter.com) +- E-mail onderhouders op [hi@puter.com](mailto:hi@puter.com) + +We helpen je graag met al je vragen. Aarzel niet om te vragen! + +
+ + +## Licentie + +Deze repository, inclusief alle inhoud, subprojecten, modules en componenten, is gelicentieerd onder [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) tenzij expliciet anders vermeld. Bibliotheken van derden die in deze repository zijn opgenomen, kunnen onderworpen zijn aan hun eigen licenties. + +
\ No newline at end of file diff --git a/doc/i18n/README.pa.md b/doc/i18n/README.pa.md new file mode 100644 index 0000000000000000000000000000000000000000..dc6db86a832bce39a00f86bc04b470a1e3628d8c --- /dev/null +++ b/doc/i18n/README.pa.md @@ -0,0 +1,182 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

ਇੰਟਰਨੇਟ ਓਐਸ! ਮੁਫ਼ਤ, ਖੁੱਲ੍ਹੇ ਸਰੋਤ ਵਾਲਾ, ਅਤੇ ਆਪ ਸਵੈ-ਹੋਸਟ ਕਰ ਸਕਦੇ ਹੋ।

+ +

+ « LIVE DEMO » +
+
+ Puter.com + · + ਐਪ ਸਟੋਰ + · + ਡਿਵੈਲਪਰ + · + CLI + · + Discord + · + Reddit + · + X +

+ +

screenshot

+ +
+ +## Puter + +Puter ਇੱਕ ਵਿਕਸਤ, ਖੁੱਲ੍ਹਾ-ਸਰੋਤ ਇੰਟਰਨੇਟ ਓਪਰੇਟਿੰਗ ਸਿਸਟਮ ਹੈ ਜੋ ਫੀਚਰ-ਭਰਪੂਰ, ਬਹੁਤ ਤੇਜ਼, ਅਤੇ ਵਧੀਆ ਤਰੀਕੇ ਨਾਲ ਵਧਾਏ ਜਾਣ ਵਾਲਾ ਬਣਾਇਆ ਗਿਆ ਹੈ। Puter ਇਸ ਤਰ੍ਹਾਂ ਵਰਤਿਆ ਜਾ ਸਕਦਾ ਹੈ: + +- ਇੱਕ ਪਰਾਈਵੇਸੀ-ਪਹਿਲਾਂ ਨਿੱਜੀ ਕਲਾਊਡ ਵਜੋਂ ਜਿੱਥੇ ਤੁਹਾਡੀਆਂ ਸਾਰੀਆਂ ਫਾਈਲਾਂ, ਐਪਸ, ਅਤੇ ਗੇਮਜ਼ ਇੱਕ ਸੁਰੱਖਿਅਤ ਜਗ੍ਹਾ 'ਤੇ, ਕਿਸੇ ਵੀ ਸਮੇਂ-ਕਿਤੇ ਵੀ ਤੋਂ ਪਹੁੰਚਯੋਗ। +- ਵੈਬਸਾਈਟਾਂ, ਵੈਬ ਐਪਸ, ਅਤੇ ਗੇਮ ਬਣਾਉਣ ਅਤੇ ਪ੍ਰਕਾਸ਼ਿਤ ਕਰਨ ਲਈ ਇੱਕ ਪਲੇਟਫਾਰਮ। +- Dropbox, Google Drive, OneDrive ਆਦਿ ਦਾ ਇੱਕ ਆਧੁਨਿਕ ਵਿਕਲਪ, ਨਵੀਂ ਇੰਟਰਫੇਸ ਅਤੇ ਸ਼ਕਤੀਸ਼ਾਲੀ ਫੀਚਰਾਂ ਨਾਲ। +- ਸਰਵਰਾਂ ਅਤੇ ਵਰਕਸਟੇਸ਼ਨਾਂ ਲਈ ਰਿਮੋਟ ਡੈਸਕਟਾਪ Environment। +- ਵੈਬ ਡਿਵੈਲਪਮੈਂਟ, ਕਲਾਊਡ ਕੰਪਿਊਟਿੰਗ, ਡਿਸਟ੍ਰੀਬਿਊਟਡ ਸਿਸਟਮ ਅਤੇ ਹੋਰ ਬਹੁਤ ਕੁਝ ਸਿੱਖਣ ਲਈ ਇੱਕ ਮਿੱਤਰਤਾਪੂ, ਖੁੱਲ੍ਹੇ-ਸਰੋਤ ਵਾਲਾ ਪ੍ਰੋਜੈਕਟ ਅਤੇ ਸਮੂਹ! + +
+ +## Getting Started + +### 💻 Local Development + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` +**→** ਇਹ Puter ਨੂੰ ਇਸ ਪਤੇ 'ਤੇ ਚਲਾਉਣਾ ਚਾਹੀਦਾ ਹੈ + http://puter.localhost:4100 (ਜਾਂ ਅਗਲਾ ਉਪਲਬਧ ਪੋਰਟ). + +ਜੇ ਇਹ ਕੰਮ ਨਹੀਂ ਕਰਦਾ, ਤਾੰ [First Run Issues](./doc/self-hosters/first-run-issues.md) ਵੇਖੋ +ਟ੍ਰਬਲਸ਼ੂਟਿੰਗ ਲਈ। + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` +**→** ਇਹ Puter ਨੂੰ ਇਸ ਪਤੇ 'ਤੇ ਚਲਾਉਣਾ ਚਾਹੀਦਾ ਹੈ + http://puter.localhost:4100 (ਜਾਂ ਅਗਲਾ ਉਪਲਬਧ ਪੋਰਟ). + +
+ +### 🐙 Docker Compose + +#### Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +**→** ਇਹ ਇਸ ਪਤੇ 'ਤੇ ਉਪਲਬਧ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ + http://puter.localhost:4100 (ਜਾਂ ਅਗਲਾ ਉਪਲਬਧ ਪੋਰਟ). + +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +**→** ਇਹ Puter ਨੂੰ ਇਸ ਪਤੇ 'ਤੇ ਚਲਾਉਣਾ ਚਾਹੀਦਾ ਹੈ + http://puter.localhost:4100 (ਜਾਂ ਅਗਲਾ ਉਪਲਬਧ ਪੋਰਟ). + +
+ +### 🚀 Self-Hosting + +Puter ਨੂੰ ਖੁਦ ਹੋਸਟ ਕਰਨ ਲਈ, ਕਨਫਿਗੁਰੇਸ਼ਨ ਵਿਕਲਪ ਅਤੇ ਬਿਹਤਰੀਨ ਕਾਇਦੇ, ਸਾਰੇ ਵਿਸਥਾਰ ਲਈ ਸਾਡੇ [Self-Hosting Documentation](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md) ਵੇਖੋ। + +
+ +### ☁️ Puter.com + +Puter [**puter.com**](https://puter.com) 'ਤੇ ਇੱਕ ਹੋਸਟ ਕੀਤੀ ਸੇਵਾ ਵਜੋਂ ਉਪਲਬਧ ਹੈ। + +
+ +## System Requirements + +- **Operating Systems:** Linux, macOS, Windows +- **RAM:** ਘੱਟੋ-ਘੱਟ 2GB (4GB ਸਿਫ਼ਾਰਸ਼ੀ) +- **Disk Space:** 1GB ਖਾਲੀ ਜਗ੍ਹਾ +- **Node.js:** Version 20.19.5+ (23+ ਸਿਫ਼ਾਰਸ਼ੀ) +- **npm:** ਨਵੀਨਤਮ ਸਥਿਰ ਵਰਜਨ + +
+ +## Support + +ਮੈਂਟੇਨਰਾਂ ਅਤੇ ਕਮਿਊਨਿਟੀ ਨਾਲ ਇੱਥੇ ਸੰਪਰਕ ਕਰੋ: + +- ਬੱਗ ਜਾਂ ਫੀਚਰ ਰਿਕਵੇਸਟ? ਕਿਰਪਾ ਕਰਕੇ [issue ਖੋਲ੍ਹੋ](https://github.com/HeyPuter/puter/issues/new/choose)। +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- ਸੁਰੱਖਿਆ ਮਸਲੇ? [security@puter.com](mailto:security@puter.com) +- ਮੇਂਟੇਨਰਾਂ ਨੂੰ ਈਮੇਲ ਕਰੋ [hi@puter.com](mailto:hi@puter.com) + +ਅਸੀਂ ਹਮੇਸ਼ਾ ਤੁਹਾਡੀਆਂ ਕਿਸੇ ਵੀ ਪ੍ਰਸ਼ਨਾਂ ਵਿੱਚ ਮਦਦ ਕਰਨ ਲਈ ਤਿਆਰ ਹਾਂ। ਬੇਝਿਝਕ ਪੁੱਛੋ! + +
+ +## License + +ਇਹ ਰਿਪੋਜ਼ਟਰੀ, ਆਪਣੇ ਸਾਰੇ ਸਮੱਗਰੀ, ਸਬ-ਪ੍ਰੋਜੈਕਟ, ਮੋਡੀਊਲ, ਅਤੇ ਕੰਪੋਨੈਂਟ ਸਮੇਤ, [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) ਅਧੀਨ ਲਾਇਸੈਂਸਡ ਹੈ ਜੇ ਤਕ ਹੋਰ ਸਪਸ਼ਟ ਤੌਰ 'ਤੇ ਨਹੀਂ ਕਿਹਾ ਗਿਆ। ਤੀਜੀ ਪੱਖ ਦੀਆਂ ਲਾਇਬ੍ਰੇਰੀਆਂ ਆਪਣੇ ਲਾਇਸੈਂਸਾਂ ਅਨੁਸਾਰ ਹੋ ਸਕਦੀਆਂ ਹਨ। + +
+ +## Translations + +- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md) +- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md) +- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md) +- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md) +- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md) +- [English](https://github.com/HeyPuter/puter/blob/main/README.md) +- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md) +- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md) +- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md) +- [German / Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md) +- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md) +- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md) +- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md) +- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md) +- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md) +- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md) +- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md) +- [Malay / Bahasa Malaysia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.my.md) +- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md) +- [Punjabi / ਪੰਜਾਬੀ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pa.md) +- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md) +- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md) +- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md) +- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md) +- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md) +- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md) +- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md) +- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md) +- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md) +- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md) +- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md) +- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md) +- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md) + +## Links to Other READMEs +### Backend +- [PuterAI Module](./src/backend/doc/modules/puterai/README.md) +- [Metering Service](./src/backend/src/services/MeteringService/README.md) +- [Extensions Development Guide](./extensions/README.md) diff --git a/doc/i18n/README.pl.md b/doc/i18n/README.pl.md new file mode 100644 index 0000000000000000000000000000000000000000..9634ddeb05868e67ae8fa97979af6351b0aa4542 --- /dev/null +++ b/doc/i18n/README.pl.md @@ -0,0 +1,119 @@ +

Puter.com, Osobisty Komputer Chmurowy: Wszystkie twoje pliki, aplikacje i gry w jednym miejscu, dostępne z dowolnego miejsca o dowolnej porze.

+

System Operacyjny Internet! Darmowy, Open-Source i Możliwy do Samodzielnego Hostowania.

+

+ Rozmiar repozytorium GitHub Wydanie GitHub Licencja GitHub +

+

+ « DEMO NA ŻYWO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+

zrzut ekranu

+
+ +## Puter + +Puter to zaawansowany, open-source'owy internetowy system operacyjny, zaprojektowany tak, aby był bogaty w funkcje, wyjątkowo szybki i wysoce rozszerzalny. Puter może być używany jako: + +- Prywatna chmura osobista do przechowywania wszystkich plików, aplikacji i gier w jednym bezpiecznym miejscu, dostępnym z dowolnego miejsca o dowolnej porze. +- Platforma do budowania i publikowania stron internetowych, aplikacji webowych i gier. +- Alternatywa dla Dropbox, Google Drive, OneDrive itp. ze świeżym interfejsem i potężnymi funkcjami. +- Zdalne środowisko pulpitu dla serwerów i stacji roboczych. +- Przyjazny, open-source'owy projekt i społeczność do nauki o tworzeniu stron internetowych, chmurze obliczeniowej, systemach rozproszonych i wielu innych! + +
+ +## Rozpoczęcie pracy +## 💻 Lokalne środowisko developerskie + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` +To uruchomi Puter na http://puter.localhost:4100 (lub na następnym dostępnym porcie). + +
+ +## 🐳 Docker + +```bash +Copy code +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` +
+ +## 🐙 Docker Compose +## Linux/macOS + +```bash +Copy code +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +## Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +## ☁️ Puter.com +Puter jest dostępny jako usługa hostowana na [**puter.com**](https://puter.com). + +
+ +## Wymagania systemowe + +- **Systemy operacyjne:** Linux, macOS, Windows +- **RAM:** Minimum 2GB (zalecane 4GB) +- **Przestrzeń dyskowa:** 1GB wolnego miejsca +- **Node.js:** Wersja 16+ (zalecana wersja 22+) +- **npm:** Najnowsza stabilna wersja + +
+ +## Wsparcie + +Skontaktuj się z opiekunami i społecznością przez te kanały: + +- Raport o błędzie lub prośba o funkcję? Proszę otworzyć zgłoszenie. +- Discord: discord.com/invite/PQcx7Teh8u +- X (Twitter): x.com/HeyPuter +- Reddit: reddit.com/r/puter/ +- Mastodon: mastodon.social/@puter +- Problemy z bezpieczeństwem? security@puter.com +- Email do opiekunów: hi@puter.com + +Zawsze chętnie pomożemy Ci z wszelkimi pytaniami, jakie możesz mieć. Nie wahaj się pytać! +
+ +## Licencja + +To repozytorium, w tym cała jego zawartość, podprojekty, moduły i komponenty, jest licencjonowane na podstawie [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), chyba że wyraźnie zaznaczono inaczej. Biblioteki stron trzecich zawarte w tym repozytorium mogą podlegać własnym licencjom. + +
+ + + + + diff --git a/doc/i18n/README.pt.md b/doc/i18n/README.pt.md new file mode 100644 index 0000000000000000000000000000000000000000..aef2afd5d35656202e6f063d801047f8233d0a07 --- /dev/null +++ b/doc/i18n/README.pt.md @@ -0,0 +1,173 @@ +

Puter.com, O Computador Pessoal em Nuvem: Todos os seus arquivos, aplicativos e jogos em um único lugar, acessíveis de qualquer lugar e a qualquer hora.

+ +

O Sistema Operacional da Internet! Gratuito, de Código Aberto e Auto-Hospedável.

+ +

+ « DEMONSTRAÇÃO AO VIVO » +
+
+ Puter.com + · + App Store + · + Developers + · + CLI + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter é um sistema operacional de internet avançado e de código aberto, projetado para ser rico em recursos, excepcionalmente rápido e altamente extensível. Puter pode ser usado como: + +- Um serviço de nuvem pessoal com foco na privacidade para manter todos os seus arquivos, aplicativos e jogos em um local seguro, acessível de qualquer lugar e a qualquer hora. +- Uma plataforma para construir e publicar websites, aplicativos web e jogos. +- Uma alternativa ao Dropbox, Google Drive, OneDrive, etc., com uma interface renovada e recursos poderosos. +- Um ambiente de desktop remoto para servidores e estações de trabalho. +- Um projeto e comunidade de código aberto e amigável para aprender sobre desenvolvimento web, computação em nuvem, sistemas distribuídos e muito mais! + +
+ +## Iniciando o Projeto + + +### 💻 Desenvolvimento Local +``` +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível). + + +Se isso não funcionar, consulte [First Run Issues](./doc/self-hosters/first-run-issues.md) para solucionar os problemas. + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` +✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível). + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível). + +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível). + +
+ +### 🚀 Auto-Hospedagem + +Para guia detalhados sobre como auto-hospedar o Puter, incluindo opções de configuração e melhores práticas, consulte nossa [Documentação de Auto-Hospedagem](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md). + +### ☁️ Puter.com + +O Puter está disponível como um serviço hospedado em [**puter.com**](https://puter.com). + +
+ +## Requerimentos do sistema + +- **Sistema operacional:** Linux, macOS, Windows +- **RAM:** 2GB mínimo (4GB recomendado) +- **Espaço de disco:** 1GB de espaço disponível +- **Node.js:** Versão 16+ (Versão 23+ recomendada) +- **npm:** Última versão estável + +
+ +## Suporte + +Conecte-se com os mantenedores e a comunidade através destes canais: + +- Relato de bug ou solicitação de recurso? Por favor, [abra um tópico](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Problemas de segurança? [security@puter.com](mailto:security@puter.com) +- Envie um email para os mantenedores em [hi@puter.com](mailto:hi@puter.com) + +Estamos sempre felizes em ajudá-lo com quaisquer perguntas que você possa ter. Não hesite em perguntar! + +
+ + +## Licença + +Este repositório, incluindo todos os seus conteúdos, subprojetos, módulos e componentes, está licenciado sob [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) a menos que explicitamente indicado de outra forma. Bibliotecas de terceiros incluídas neste repositório podem estar sujeitas às suas próprias licenças. + +
+ +## Traduções + +- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md) +- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md) +- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md) +- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md) +- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md) +- [English](https://github.com/HeyPuter/puter/blob/main/README.md) +- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md) +- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md) +- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md) +- [German/ Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md) +- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md) +- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md) +- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md) +- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md) +- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md) +- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md) +- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md) +- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md) +- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md) +- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md) +- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md) +- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md) +- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md) +- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md) +- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md) +- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md) +- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md) +- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md) +- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md) +- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md) +- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md) \ No newline at end of file diff --git a/doc/i18n/README.ro.md b/doc/i18n/README.ro.md new file mode 100644 index 0000000000000000000000000000000000000000..883c824e2d6317ba806eab0a6673736142579e83 --- /dev/null +++ b/doc/i18n/README.ro.md @@ -0,0 +1,125 @@ +

Puter.com, Calculatorul Personal Cloud: Toate fișierele, aplicațiile și jocurile dumneavoastră într-un singur loc, accesibile de oriunde și oricând.

+ +

Sistemul de Operare Internet! Gratuit, Open-Source și Găzduibil Autonom.

+ +

+ Mărime GitHub repository Versiune GitHub Licență GitHub +

+

+ « DEMO LIVE » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter este un sistem de operare pe internet avansat, open-source, proiectat să fie bogat în funcții, extrem de rapid și foarte extensibil. Puter poate fi folosit ca: + +- Un cloud personal care pune pe primul loc confidențialitatea pentru a păstra toate fișierele, aplicațiile și jocurile tale într-un loc sigur, accesibil de oriunde și oricând. +- O platforma pentru a construi și publica site-uri web, aplicații web și jocuri. +- O alternativă la Dropbox, Google Drive, OneDrive, etc. cu o interfață nouă și funcționalități puternice. +- Un mediu desktop la distanță pentru servere si stații de lucru. +- Un proiect prietenos, open-source și o comunitate pentru a învăța despre dezvoltarea web, cloud computing, sisteme distribuite și multe altele! + +
+ +## Începeți + +### 💻 Dezvoltare Locală + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Aceasta va lansa Puter la adresa http://puter.localhost:4100 (sau la următorul port disponibil). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter este disponibil ca serviciu găzduit la [**puter.com**](https://puter.com). + +
+ +## Cerințe de Sistem + +- **Sisteme de Operare:** Linux, macOS, Windows +- **RAM:** 2GB minim (4GB recomandat) +- **Spațiu pe Disk:** 1GB spațiu liber +- **Node.js:** Versiunea 16+ (Versiunea 22+ recomandată) +- **npm:** Ultima versiune stabilă + +
+ +## Suport + +Conectați-vă cu cei care asigură mentenanța proiectului și comunitatea prin intermediul acestor canale: + +- Aveți o problemă sau doriți o funcționalitate nouă? Vă rugăm [să deschideți o problemă](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Probleme de securitate? [security@puter.com](mailto:security@puter.com) +- Trimiteți un email celor care asigură mentenanța proiectul la [hi@puter.com](mailto:hi@puter.com) + +Suntem întotdeauna bucuroși să vă ajutăm cu orice întrebări aveți. Nu ezitați să ne întrebați! + +
+ +## Licență + +Acest depozit, inclusiv toate conținuturile sale, sub-proiectele, modulele și componentele, sunt licențiate sub [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), cu excepția cazului în care se menționează altfel în mod explicit. Bibliotecile terțe incluse în acest depozit pot fi supuse propriilor licențe. + +
diff --git a/doc/i18n/README.ru.md b/doc/i18n/README.ru.md new file mode 100644 index 0000000000000000000000000000000000000000..2bab605c19517961eea786efa653193272dd3cc3 --- /dev/null +++ b/doc/i18n/README.ru.md @@ -0,0 +1,127 @@ +

Puter.com, персональный облачный компьютер: все ваши файлы, приложения и игры в одном месте, доступные из любой точки мира в любое время.

+ +

Интернет ОС! Бесплатная, с открытым исходным кодом и возможностью самостоятельной установки.

+ +

+ Размер репозитория GitHub Релиз GitHub Лицензия GitHub +

+

+ « ЖИВОЕ ДЕМО » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter — это передовая операционная система с открытым исходным кодом, разработанная для обеспечения широкого функционала, исключительной скорости и высокой масштабируемости. Puter можно использовать как: + +- Персональное облако с приоритетом конфиденциальности для хранения всех ваших файлов, приложений и игр в одном безопасном месте, доступном из любой точки мира в любое время. +- Платформа для создания и публикации веб-сайтов, веб-приложений и игр. +- Альтернатива Dropbox, Google Drive, OneDrive и т. д. с новым интерфейсом и мощными функциями. +- Удаленное рабочее окружение для серверов и рабочих станций. +- Дружественный проект с открытым исходным кодом и сообщество для изучения веб-разработки, облачных вычислений, распределенных систем и многого другого! + +
+ +## Начало работы + + +### 💻 Локальная разработка + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Это запустит Puter по адресу http://puter.localhost:4100 (или на следующем доступном порту). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter доступен как облачный сервис на [**puter.com**](https://puter.com). + +
+ +## Системные требования + +- **Операционные системы:** Linux, macOS, Windows +- **ОЗУ:** минимум 2 ГБ (рекомендуется 4 ГБ) +- **Место на диске:** 1 ГБ свободного места +- **Node.js:** Версия 16+ (рекомендуется версия 22+) +- **npm:** Последняя стабильная версия + +
+ +## Поддержка + +Свяжитесь с разработчиками и сообществом этими способами: + +- Отчет об ошибке или запрос функции? Пожалуйста, [откройте вопрос](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Проблемы безопасности? [security@puter.com](mailto:security@puter.com) +- Свяжитесь с разработчиками по адресу [hi@puter.com](mailto:hi@puter.com) + +Мы всегда рады помочь вам с любыми вопросами. Не стесняйтесь спрашивать! + +
+ + +## Лицензия + +Этот репозиторий, включая все его содержимое, подпроекты, модули и компоненты, лицензирован в соответствии с [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), если явно не указано иное. Сторонние библиотеки, включенные в этот репозиторий, могут подпадать под действие их собственных лицензий. + +
diff --git a/doc/i18n/README.sv.md b/doc/i18n/README.sv.md new file mode 100644 index 0000000000000000000000000000000000000000..3a981505cca68acf8e6491ae2c9cddaa2263195f --- /dev/null +++ b/doc/i18n/README.sv.md @@ -0,0 +1,125 @@ +

Puter.com, Den personliga molndatorn: Alla dina filer, appar och spel på ett ställe tillgängliga var som helst när som helst.

+ +

Internet OS! Gratis, öppen källkod och självhostad.

+ +

+ GitHub repo storlek GitHub Utgåva GitHub Licens +

+

+ « LIVE DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

skärmdump

+ +
+ +## Puter + +Puter är ett avancerat, öppen källkod internetoperativsystem designat för att vara funktionsrikt, exceptionellt snabbt och mycket utbyggbart. Puter kan användas som: + +- Ett integritetsfokuserat personligt moln för att hålla alla dina filer, appar och spel på ett säkert ställe, tillgängligt var som helst när som helst. +- En plattform för att bygga och publicera webbplatser, webbappar och spel. +- Ett alternativ till Dropbox, Google Drive, OneDrive, etc. med ett fräscht gränssnitt och kraftfulla funktioner. +- En fjärrskrivbordsmiljö för servrar och arbetsstationer. +- Ett vänligt, öppen källkod-projekt och gemenskap för att lära sig om webbutveckling, molndatorer, distribuerade system och mycket mer! + +
+ +## Komma igång + +### 💻 Lokal Utveckling + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Detta kommer att starta Puter på http://puter.localhost:4100 (eller nästa lediga port). + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 Docker Compose + +#### Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+ +#### Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` + +
+ +### ☁️ Puter.com + +Puter är tillgängligt som en värdtjänst på [**puter.com**](https://puter.com). + +
+ +## Systemkrav + +- **Operating Systems:** Linux, macOS, Windows +- **RAM:** 2GB minimum (4GB recommended) +- **Disk Space:** 1GB free space +- **Node.js:** Version 16+ (Version 22+ recommended) +- **npm:** Latest stable version + +
+ +## Support + +Anslut med underhållarna och gemenskapen genom dessa kanaler: + +- Buggrapport eller funktionsförfrågan? Vänligen [öppna ett ärende](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Säkerhetsproblem? [security@puter.com](mailto:security@puter.com) +- E-posta underhållarna på [hi@puter.com](mailto:hi@puter.com) + +Vi hjälper dig gärna med eventuella frågor du kan ha. Tveka inte att fråga! + +
+ +## Licens + +Detta arkiv, inklusive allt dess innehåll, delprojekt, moduler och komponenter, är licensierat under [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) om inte annat uttryckligen anges. Tredjepartsbibliotek som ingår i detta arkiv kan vara föremål för sina egna licenser. + +
+ diff --git a/doc/i18n/README.ta.md b/doc/i18n/README.ta.md new file mode 100644 index 0000000000000000000000000000000000000000..1aa9f1a79b5a3f763e9cb9335330f989f1033216 --- /dev/null +++ b/doc/i18n/README.ta.md @@ -0,0 +1,123 @@ +

Puter.com, The Personal Cloud Computer: உங்கள் கோப்புகள், ஆப்ஸ் மற்றும் கேம்கள் அனைத்தும் ஒரே இடத்தில் எங்கிருந்தும் எந்த நேரத்திலும் அணுகலாம்.

+ +

இன்டர்நெட் OS! இலவசம், ஓப்பன் சோர்ஸ் மற்றும் Self-Hostable

+ +

+ GitHub repo size GitHub Release GitHub உரிமம் +

+

+ « லைவ் டெமோ » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## புட்டர் (Putter) + +புட்டர்(putter) என்பது ஒரு மேம்பட்ட, திறந்த மூல இலவசமாக இணைய இயக்க முறைமையாகும், இது அம்சம் நிறைந்ததாகவும், விதிவிலக்காக வேகமாகவும், அதிக விரிவாக்கக்கூடியதாகவும் வடிவமைக்கப்பட்டுள்ளது. புட்டரை இவ்வாறு பயன்படுத்தலாம்: + +- உங்கள் கோப்புகள், பயன்பாடுகள் மற்றும் கேம்கள் அனைத்தையும் ஒரே பாதுகாப்பான இடத்தில் வைத்திருக்க, எந்த நேரத்திலும் எங்கிருந்தும் அணுகக்கூடிய தனியுரிமை-முதல் தனிப்பட்ட கிளவுட். +- இணையதளங்கள், இணைய பயன்பாடுகள் மற்றும் கேம்களை உருவாக்கி வெளியிடுவதற்கான தளம் இதுவாகும். +- புதிய இடைமுகம் மற்றும் சக்திவாய்ந்த அம்சங்களுடன் Dropbox, Google Drive, OneDrive போன்றவற்றுக்கு மாற்றீடாக உபயோகிக்க கூடியது. +- சர்வர்கள் மற்றும் பணிநிலையங்களுக்கான தொலைநிலை டெஸ்க்டாப்(desktop) சூழல். +- வலை மேம்பாடு, கிளவுட் கம்ப்யூட்டிங், விநியோகிக்கப்பட்ட அமைப்புகள் மற்றும் பலவற்றைப் பற்றி அறிந்து ஒரு நட்பு ரீதியான, திறந்த மூல திட்டம் மற்றும் சமூக அறிவியலில் சார்ந்த ஒன்று. + +
+ +## தொடங்குதல் + +### 💻 உள்ளூர் வளர்ச்சி + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` தொடக்கம் +``` + +இது புட்டரை இல் தொடங்கும் (அல்லது அடுத்து கிடைக்கும் இடம்). + +
+ +### 🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 டோக்கர் கம்போஸ் + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +புட்டர் ஹோஸ்ட் செய்யப்பட்ட சேவையாக [**puter.com**](https://puter.com) இல் கிடைக்கிறது. + +
+ +## கணினி தேவைகள் + +- **இயக்க முறைமைகள்:** Linux, macOS, Windows +- **ரேம்:** குறைந்தபட்சம் 2 ஜிபி (4 ஜிபி பரிந்துரைக்கப்படுகிறது) +- **வட்டு இடம்:** 1GB இலவச இடம் +- **Node.js:** Version 16+ (Version 22+ recommended) +- **npm:** சமீபத்திய நிலையான பதிப்பு(Latest stable version) + +
+ +## ஆதரவு + +இந்த சேனல்கள் மூலம் பராமரிப்பாளர்கள் மற்றும் சமூகத்துடன் சமூக இணைப்பாளர்: + +- பிழை அறிக்கை அல்லது மாற்றுதல் கோரிக்கை? தயவுசெய்து [சிக்கலைத் திறக்கவும்](https://github.com/HeyPuter/puter/issues/new/choose). +- கருத்து வேறுபாடு: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- பாதுகாப்பு பிரச்சினைகள்? [security@puter.com](mailto:security@puter.com) +- மின்னஞ்சல் பராமரிப்பாளர்களுக்கு [hi@puter.com](mailto:hi@puter.com) + +உங்களுக்கு ஏதேனும் கேள்விகள் இருந்தால் உங்களுக்கு உதவ நாங்கள் எப்போதும் மகிழ்ச்சியடைகிறோம். தயங்காமல் கேளுங்கள்! + +
+ +## உரிமம் + +இந்தக் களஞ்சியமானது, அதன் அனைத்து உள்ளடக்கங்கள், துணைத் திட்டங்கள், தொகுதிகள் மற்றும் கூறுகள் உட்பட, வெளிப்படையாகக் கூறப்படாவிட்டால், [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) இன் கீழ் உரிமம் பெற்றுள்ளது. . இந்தக் களஞ்சியத்தில் சேர்க்கப்பட்டுள்ள மூன்றாம் தரப்பு நூலகங்கள் அவற்றின் சொந்த உரிமங்களுக்கு உட்பட்டதாக இருக்கும். + +
diff --git a/doc/i18n/README.te.md b/doc/i18n/README.te.md new file mode 100644 index 0000000000000000000000000000000000000000..d31358a4c0611195547c91fb244bc0bf1d460590 --- /dev/null +++ b/doc/i18n/README.te.md @@ -0,0 +1,161 @@ +

Puter.com, The Personal Cloud Computer: మీ అన్ని ఫైల్‌లు, యాప్‌లు మరియు గేమ్‌లను ఒకే స్థలంలో ఎక్కడి నుండైనా ఎప్పుడైనా యాక్సెస్ చేయవచ్చు.

+ +

ఇంటర్నెట్ OS! ఉచిత, ఓపెన్ సోర్స్, and Self-Hostable.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « ప్రత్యక్ష ప్రదర్శన » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## పుటర్ (Puter) + +పుటర్ అనేది అధునాతన, ఓపెన్ సోర్స్ ఇంటర్నెట్ ఆపరేటింగ్ సిస్టమ్, ఇది ఫీచర్-రిచ్, అనూహ్యంగా వేగవంతమైన మరియు అత్యంత విస్తరించదగినదిగా రూపొందించబడింది. పుటర్‌ను ఇలా ఉపయోగించవచ్చు: + +- మీ అన్ని ఫైల్‌లు, యాప్‌లు మరియు గేమ్‌లను ఒకే సురక్షిత స్థలంలో ఉంచడానికి గోప్యత-మొదటి వ్యక్తిగత క్లౌడ్, ఎప్పుడైనా ఎక్కడి నుండైనా యాక్సెస్ చేయవచ్చు. +- వెబ్‌సైట్‌లు, వెబ్ యాప్‌లు మరియు గేమ్‌లను రూపొందించడానికి మరియు ప్రచురించడానికి ఒక వేదిక. +- తాజా ఇంటర్‌ఫేస్ మరియు శక్తివంతమైన ఫీచర్‌లతో Dropbox, Google Drive, OneDrive మొదలైన వాటికి ప్రత్యామ్నాయం. +- సర్వర్లు మరియు వర్క్‌స్టేషన్‌ల కోసం రిమోట్ డెస్క్‌టాప్ వాతావరణం. +- వెబ్ డెవలప్‌మెంట్, క్లౌడ్ కంప్యూటింగ్, డిస్ట్రిబ్యూట్ సిస్టమ్‌లు మరియు మరిన్నింటి గురించి తెలుసుకోవడానికి స్నేహపూర్వక, ఓపెన్ సోర్స్ ప్రాజెక్ట్ మరియు కమ్యూనిటీ! + +
+ +## ప్రారంభించడం + +### లోకల్ డెవలప్మెంట్ + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +ఇది http://puter.localhost:4100 (లేదా తదుపరి అందుబాటులో ఉన్న పోర్ట్) వద్ద పుటర్‌ని ప్రారంభిస్తుంది. + +ఇది పని చేయకపోతే, దీని కోసం [మొదటి రన్ సమస్యలు](./doc/first-run-issues.md) చూడండి +ట్రబుల్షూటింగ్ దశలు. + +
+ +### 🐳 డోకర్ + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +### 🐙 డోకర్ Compose + +#### లినక్స్/ macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+ +#### విండోస్ + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` + +
+ +### ☁️ Puter.com + +పుటర్ [**puter.com**](https://puter.com)లో హోస్ట్ చేయబడి ఉంది. + +
+ +## System Requirements + +- **ఆపరేటింగ్ సిస్టమ్స్:** లినక్స్, macOS, విండోస్ +- **RAM:** 2GB కనీసం(4GB recommended) +- **Disk Space:** 1GB ఖాళీ +- **Node.js:** Version 16+ (Version 22+ recommended) +- **npm:** Latest stable version + +
+ +## Support + +ఈ ఛానెల్‌ల ద్వారా నిర్వాహకులు మరియు సంఘంతో కనెక్ట్ అవ్వండి: + +- బగ్ నివేదిక లేదా ఫీచర్ అభ్యర్థన? దయచేసి [open an issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Security issues? [security@puter.com](mailto:security@puter.com) +- Email maintainers at [hi@puter.com](mailto:hi@puter.com) + +మీకు ఏవైనా సందేహాలు ఉంటే మీకు సహాయం చేయడానికి మేము ఎల్లప్పుడూ సంతోషిస్తాము. అడగడానికి సంకోచించకండి! + +
+ +## లైసెన్సు + +ఈ రిపోజిటరీ, దాని మొత్తం కంటెంట్‌లు, ఉప-ప్రాజెక్ట్‌లు, మాడ్యూల్స్ మరియు కాంపోనెంట్‌లతో సహా, [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) కింద లైసెన్స్‌ని కలిగి ఉంటుంది. . ఈ రిపోజిటరీలో చేర్చబడిన థర్డ్-పార్టీ లైబ్రరీలు వాటి స్వంత లైసెన్స్‌లకు లోబడి ఉండవచ్చు. + +
+ +## అనువాదాలు + +- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md) +- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md) +- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md) +- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md) +- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md) +- [English](https://github.com/HeyPuter/puter/blob/main/README.md) +- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md) +- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md) +- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md) +- [German/ Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md) +- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md) +- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md) +- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md) +- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md) +- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md) +- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md) +- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md) +- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md) +- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md) +- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md) +- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md) +- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md) +- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md) +- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md) +- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md) +- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md) +- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md) +- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md) +- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md) +- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md) +- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md) diff --git a/doc/i18n/README.th.md b/doc/i18n/README.th.md new file mode 100644 index 0000000000000000000000000000000000000000..bad9ae91dfb036abf10d6a220123921a0b50c97d --- /dev/null +++ b/doc/i18n/README.th.md @@ -0,0 +1,127 @@ +

Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time.

+ +

ระบบปฏิบัติการอินเทอร์เน็ต ฟรี, โอเพ่นซอร์ส, และสามารถโฮสต์ได้ด้วยตนเอง

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « การสาธิตสด » +
+
+ Puter.com + · + ชุดพัฒนาโปรแกรม + · + ดิสคอร์ด + · + เรดดิท + · + X (ทวิตเตอร์) +

+ +

screenshot

+ +
+ +## พิวเตอร์ + +พิวเตอร์ เป็นระบบปฏิบัติการอินเทอร์เน็ตขั้นสูงแบบโอเพ่นซอร์สที่ออกแบบมาให้มีฟีเจอร์ครบถ้วน ความเร็วสูง และมีความสามารถที่จะขยายได้สูง. พิวเตอร์ สามารถใช้ได้เป็น: + +- คลาวด์ส่วนตัว เพื่อเก็บไฟล์, แอพพลิเคชัน, และเกมทั้งหมดของคุณในที่เดียวที่ปลอดภัยและสามารถเข้าถึงได้ทุกที่ทุกเวลา +- แพลตฟอร์มสำหรับการสร้างและเผยแพร่เว็บไซต์, เว็บแอปพลิเคชัน, และเกม +- ทางเลือกอีกหนึ่งทางที่สามารถใช้แทน Dropbox, Google Drive, OneDrive ฯลฯ โดยที่มีอินเทอร์เฟซใหม่และฟีเจอร์ที่ทรงพลัง +- สภาพแวดล้อมสำหรับเดสก์ท็อประยะไกลที่ใช้กับเซิร์ฟเวอร์และสถานีทำงาน +- โครงการโอเพ่นซอร์สและชุมชนที่เป็นมิตรที่คุณสามารถเรียนรู้เกี่ยวกับการพัฒนาเว็บ, คลาวด์คอมพิวติ้ง, ระบบกระจาย, และอีกมากมาย + +
+ +## การเริ่มต้นใช้งาน + + +### 💻 การพัฒนาภายในเครื่อง + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +พิวเตอร์ จะถูกเปิดใช้งานที่ http://puter.localhost:4100 (หรือพอร์ตถัดไปที่ว่าง). + +
+ +### 🐳 ด็อกเกอร์ + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 ด็อกเกอร์ คอมโพส + + +#### ลินุกซ์/แมคโอเอส +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### วินโดวส์ + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +สามารถใช้งาน พิวเตอร์ ได้ในรูปแบบบริการโฮสต์ที่ [**puter.com**](https://puter.com). + +
+ +## ข้อกำหนดของระบบ + +- **ระบบปฏิบัติการ:** ลินุกซ์ แมคโอเอส วินโดวส์ +- **แรม:** อย่างน้อย 2GB (แนะนำ 4GB) +- **พื้นที่เก็บข้อมูล:** พื้นที่ว่าง 1GB +- **Node.js:** เวอร์ชัน 16+ (แนะนำเวอร์ชัน 22+) +- **npm:** เวอร์ชันล่าสุดที่เสถียร + +
+ +## การช่วยเหลือ + +ติดต่อกับผู้ดูแลระบบและชุมชนผ่านช่องทางเหล่านี้: + +- พบข้อผิดพลาดหรือขอฟีเจอร์ใหม่? กรุณา [เปิดปัญหา](https://github.com/HeyPuter/puter/issues/new/choose). +- ดิสคอร์ด: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (ทวิตเตอร์): [x.com/HeyPuter](https://x.com/HeyPuter) +- เรดดิท: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- มาสตอดอน: [mastodon.social/@puter](https://mastodon.social/@puter) +- ปัญหาด้านความปลอดภัย [security@puter.com](mailto:security@puter.com) +- ส่งอีเมลถึงผู้ดูแลระบบได้ที่ [hi@puter.com](mailto:hi@puter.com) + +เรายินดีเสมอที่จะช่วยเหลือคุณกับทุกทุกคำถามที่คุณมี อย่าลังเลที่จะถาม + +
+ + +## ลิขสิทธิ์ + +ที่เก็บข้อมูลนี้ รวมถึงเนื้อหาทั้งหมด, โครงการย่อย, โมดูล, และส่วนประกอบต่างๆ ได้รับใบอนุญาตภายใต้ [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) เว้นแต่จะมีการระบุไว้เป็นอย่างอื่นอย่างชัดเจน ไลบรารีจากบุคคลที่สามที่รวมอยู่ในที่เก็บข้อมูลนี้อาจอยู่ภายใต้ใบอนุญาตของตนเอง + +
diff --git a/doc/i18n/README.tr.md b/doc/i18n/README.tr.md new file mode 100644 index 0000000000000000000000000000000000000000..7fa6bd6a2e6e83e1cdaa7ec1cb32e355d3a9259a --- /dev/null +++ b/doc/i18n/README.tr.md @@ -0,0 +1,127 @@ +

Puter.com, Kişisel Bulut Bilgisayar: Tüm dosyalarınız, uygulamalarınız ve oyunlarınız her zaman her yerden erişilebilen tek bir yerde.

+ +

İnternet İşletim Sistemi! Ücretsiz, Açık Kaynaklı ve Kendi Kendine Barındırılabilir

+ +

+ GitHub Depo Boyutu GitHub Yayınlamak GitHub Lisans +

+

+ « CANLI DEMO » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter, zengin özelliklere sahip, son derece hızlı ve son derece genişletilebilir olacak şekilde tasarlanmış gelişmiş, açık kaynaklı bir internet işletim sistemidir. Puter şu şekilde kullanılabilir: + +- Tüm dosyalarınızı, uygulamalarınızı ve oyunlarınızı tek bir güvenli yerde tutmak için gizlilik öncelikli bir kişisel bulut, her yerden her zaman erişilebilir. +- Web siteleri, web uygulamaları ve oyunlar oluşturmak ve yayınlamak için bir platform. +- Yeni bir arayüz ve güçlü özelliklerle Dropbox, Google Drive, OneDrive vb. uygulamalara bir alternatif. +- Sunucular ve iş istasyonları için bir uzak masaüstü ortamı. +- Web geliştirme, bulut bilişim, dağıtık sistemler ve çok daha fazlası hakkında bilgi edinmek için dost canlısı, açık kaynaklı bir proje ve topluluk! + +
+ +## Başlarken + + +### 💻 Yerel Geliştirme + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Bu, Puter'ı http://puter.localhost:4100 adresinde (veya bir sonraki kullanılabilir portta) başlatacaktır. + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter, [**puter.com**](https://puter.com) adresinde barındırılan bir hizmet olarak kullanılabilir. + +
+ +## Sistem Gereksinimleri + +- **İşletim Sistemleri:** Linux, macOS, Windows +- **RAM:** 2GB Minimum (4GB önerilir) +- **Disk Alanı:** 1GB boş alan +- **Node.js:** Sürüm 16+ (Sürüm 22+ önerilir) +- **npm:** En son stabil sürüm + +
+ +## Destek + +Bakımcılarla ve toplulukla şu kanallar aracılığıyla iletişim kurabilirsiniz: + +- Hata raporu veya özellik isteği? Lütfen [yeni bir issue açın](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Güvenlik sorunları? [security@puter.com](mailto:security@puter.com) +- Bakımcılara şu adresten e-posta gönderin [hi@puter.com](mailto:hi@puter.com) + +Sorularınız varsa size her zaman yardımcı olmaktan mutluluk duyarız. Sormaktan çekinmeyin! + +
+ + +## Lisans + +Bu depo, tüm içeriği, alt projeleri, modülleri ve bileşenleri dahil olmak üzere, aksi açıkça belirtilmedikçe [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) altında lisanslanmıştır. Bu depoda yer alan üçüncü taraf kütüphaneler kendi lisanslarına tabi olabilir. + +
diff --git a/doc/i18n/README.ua.md b/doc/i18n/README.ua.md new file mode 100644 index 0000000000000000000000000000000000000000..3b2e80a355425f6969f6da655377ccb8843dbb4f --- /dev/null +++ b/doc/i18n/README.ua.md @@ -0,0 +1,125 @@ +

Puter.com, The Personal Cloud Computer: Всі ваші файли, додатки та ігри в одному місці, доступні з будь-якого куточка світу в будь-який час.

+ +

Інтернет ОС! Безкоштовна, відкрита та self-hosted.

+ +

+ Розмір репозиторію на GitHub Остання версія на GitHub Ліцензія GitHub +

+

+ « Онлайн ДЕМО » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

скріншот

+ +
+ +## Puter + +Puter — це просунута, інтернет-ОС, з відкритим кодом, створена для того, щоб бути багатофункціональною, надзвичайно швидкою та розширюваною. Puter може використовуватися як: + +- Приватний хмарний сервіс для збереження всіх ваших файлів, додатків і ігор у безпечному місці, доступному в будь-який час з будь-якого місця. +- Платформа для створення та публікації вебсайтів, вебдодатків і ігор. +- Альтернатива Dropbox, Google Drive, OneDrive і тд, з свіженьким інтерфейсом та потужними функціями. +- Віддалене робоче середовище для серверів і робочих станцій. +- Дружній, відкритий проєкт та спільнота для вивчення веброзробки, хмарних обчислень, розподілених систем і багато іншого! + +
+ +## Початок роботи + +### Локальна розробка + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +Це запустить Puter на http://puter.localhost:4100 (або на наступному доступному порті). + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter доступний як hosted service на [**puter.com**](https://puter.com). + +
+ +## Системні вимоги + +- **Операційні Системи:** Linux, macOS, Windows +- **RAM:** 2GB мінімум (4GB рекомендовано) +- **Місце на диску:** 1GB вільного місця +- **Node.js:** Version 16+ (Version 22+ рекомендовано) +- **npm:** остання "stable" версія + +
+ +## Підтримка + +Зв'язатися з розробниками та спільнотою можна через такі канали: + +- Повідомити про помилку, або запит щодо нової фічі? Будь ласка, [створіть issue](https://github.com/HeyPuter/puter/issues/new/choose). +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- Питання щодо Security? [security@puter.com](mailto:security@puter.com) +- Написати розробникам [hi@puter.com](mailto:hi@puter.com) + +Ми завжди готові допомогти Вам з будь-якими питаннями, що можуть виникнути. Не соромтеся ставити нам питання! +
+ + +## License + +Цей репорзиторій, включаючи увесь його контент, дочірні проєкти, модулі, і компоненти, ліцензовано за [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), якщо явно не вказано інше. Сторонні бібліотеки, включені в цей репозиторій, можуть підпадати під дію інших ліцензій. + +
diff --git a/doc/i18n/README.ur.md b/doc/i18n/README.ur.md new file mode 100644 index 0000000000000000000000000000000000000000..961ac3b0ea68a8e34bea139af16c047a9d043de9 --- /dev/null +++ b/doc/i18n/README.ur.md @@ -0,0 +1,121 @@ +

Puter.com, ذاتی کلاؤڈ کمپیوٹر: آپ کی تمام فائلیں، ایپس، اور کھیل ایک جگہ پر، کہیں سے بھی اور کسی بھی وقت قابل رسائی۔

+ +

انٹرنیٹ OS! مفت، اوپن سورس، اور خود میزبان.

+ +

+ GitHub repo size GitHub Release GitHub License +

+

+ « لائیو ڈیمو » +
+
+ Puter.com + · + SDK + · + ڈسکورڈ + · + ریڈڈٹ + · + ایکس (ٹوئٹر) +

+ +

اسکرین شاٹ

+ +
+ +## Puter + +ایک جدید، اوپن سورس انٹرنیٹ آپریٹنگ سسٹم ہے جو کہ خصوصیات سے بھرپور، بہت تیز، اور انتہائی توسیع پذیر ہے۔ Puter + +: کو استعمال کیا جا سکتا ہے Puter + +- ایک پرائیویسی فرسٹ ذاتی کلاؤڈ کے طور پر تاکہ آپ کی تمام فائلیں، ایپس، اور کھیل ایک محفوظ جگہ پر رکھی جا سکیں، کہیں سے بھی اور کسی بھی وقت قابل رسائی ہوں۔ +- ویب سائٹس، ویب ایپس، اور کھیل بنانے اور شائع کرنے کے لئے ایک پلیٹ فارم کے طور پر۔ +- وغیرہ کا متبادل، نئے انٹرفیس اور طاقتور خصوصیات کے ساتھ۔ Dropbox، Google Drive، OneDrive +- سرورز اور ورک اسٹیشنز کے لیے ایک ریموٹ ڈیسک ٹاپ ماحول کے طور پر۔ +- ویب ڈویلپمنٹ، کلاؤڈ کمپیوٹنگ، تقسیم شدہ نظاموں، اور بہت کچھ سیکھنے کے لیے ایک دوستانہ، اوپن سورس پروجیکٹ اور کمیونٹی! + +
+ +## شروع کرنے کا طریقہ + +### 💻 مقامی ترقی + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +یہ Puter کو http://puter.localhost:4100 (یا اگلے دستیاب پورٹ) پر لانچ کرے گا۔ + +
+🐳 Docker + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+🐙 Docker Compose +Linux/macOS + +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+Windows + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +### ☁️ Puter.com + +Puter کو [**puter.com**](https://puter.com) پر میزبان سروس کے طور پر دستیاب ہے۔ + +
+ +## نظام کی ضروریات + +- **آپریٹنگ سسٹمز:** لینکس، macOS، ونڈوز +- **RAM:** کم از کم 2GB (4GB تجویز کردہ) +- **ڈسک کی جگہ:** 1GB خالی جگہ +- **Node.js:** ورژن 16+ (ورژن 22+ تجویز کردہ) +- **npm:** تازہ ترین مستحکم ورژن + +
+ +## سپورٹ + +منتظمین اور کمیونٹی سے جڑنے کے لیے یہ چینلز استعمال کریں: + +- بگ رپورٹ یا فیچر درخواست؟ براہ کرم [ایک مسئلہ کھولیں](https://github.com/HeyPuter/puter/issues/new/choose). +- ڈسکورڈ: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- ایکس (ٹوئٹر): [x.com/HeyPuter](https://x.com/HeyPuter) +- ریڈڈٹ: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- ماسٹڈون: [mastodon.social/@puter](https://mastodon.social/@puter) +- سیکیورٹی کے مسائل؟ [security@puter.com](mailto:security@puter.com) +- منتظمین کو ای میل کریں [hi@puter.com](mailto:hi@puter.com) + +ہم ہمیشہ آپ کی مدد کے لیے خوش ہیں۔ سوالات پوچھنے میں ہچکچاہٹ نہ کریں +! +
+ +## لائسنس + +اس ریپوزٹری، بشمول اس کے تمام مواد، ذیلی پروجیکٹس، ماڈیولز، اور کمپوننٹس، کو [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) کے تحت لائسنس کیا گیا ہے جب تک کہ واضح طور پر کہیں اور نہ کہا گیا ہو۔ اس ریپوزٹری میں شامل تھرڈ پارٹی لائبریریاں اپنی لائسنس کے تابع ہو سکتی ہیں۔ + +
diff --git a/doc/i18n/README.vi.md b/doc/i18n/README.vi.md new file mode 100644 index 0000000000000000000000000000000000000000..d120dfb7847c20fc6d69ea9e18d557e472184ffe --- /dev/null +++ b/doc/i18n/README.vi.md @@ -0,0 +1,125 @@ +

Puter.com, Máy Tính Đám Mây Cá Nhân: Tất cả các tệp, ứng dụng, và trò chơi của bạn ở một nơi, có thể truy cập từ bất cứ đâu vào bất kỳ lúc nào.

+

Hệ điều hành Internet! Miễn phí, Mã nguồn mở và Có thể tự lưu trữ.

+

+ Kích thước repo GitHub Phiên bản phát hành GitHub Giấy phép GitHub +

+

+ « DEMO TRỰC TIẾP » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

chụp màn hình

+ +
+ +## Puter + +Puter là một hệ điều hành internet tiên tiến, mã nguồn mở được thiết kế để có nhiều tính năng, tốc độ vượt trội và khả năng mở rộng cao. Puter có thể được sử dụng như: + +- Một đám mây cá nhân ưu tiên quyền riêng tư để lưu trữ tất cả các tệp, ứng dụng và trò chơi của bạn ở một nơi an toàn, có thể truy cập từ bất cứ đâu, bất cứ lúc nào. +- Một nền tảng để xây dựng và xuất bản các trang web, ứng dụng web và trò chơi. +- Một sự thay thế cho Dropbox, Google Drive, OneDrive, v.v. với giao diện mới mẻ và nhiều tính năng mạnh mẽ. +- Một môi trường máy tính từ xa cho các máy chủ và máy trạm. +- Một dự án thân thiện, mã nguồn mở và cộng đồng để học hỏi về phát triển web, điện toán đám mây, hệ thống phân tán và nhiều hơn nữa! + +
+ +## Bắt Đầu + +## 💻 Phát Triển Cục Bộ + +```bash +Copy code +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` +Điều này sẽ khởi chạy Puter tại http://puter.localhost:4100 (hoặc cổng kế tiếp có sẵn). + +
+ +### 🐳 Docker + + +```bash +Copy code +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ +## 🐙 Docker Compose + +## Linux/macOS + +``` bash +Copy code +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` + +
+ +## Windows + +```powershell +Copy code +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +## ☁️ Puter.com + +Puter có sẵn dưới dạng dịch vụ lưu trữ tại [**puter.com**](https://puter.com). + +
+ +## Yêu Cầu Hệ Thống + +- **Hệ Điều Hành:** Linux, macOS, Windows +- **RAM:** Tối thiểu 2GB (Khuyến nghị 4GB) +- **Dung Lượng Ổ Cứng:** Còn trống 1GB +- **Node.js:** Phiên bản 16+ (Khuyến nghị phiên bản 22+) +- **npm:** Phiên bản ổn định mới nhất + +
+ +## Hỗ Trợ + +Kết nối với các nhà bảo trì và cộng đồng thông qua các kênh sau: + +- Báo cáo lỗi hoặc yêu cầu tính năng? Vui lòng mở một vấn đề. +- Discord: discord.com/invite/PQcx7Teh8u +- X (Twitter): x.com/HeyPuter +- Reddit: reddit.com/r/puter/ +- Mastodon: mastodon.social/@puter +- Vấn đề bảo mật? security@puter.com +- Email các nhà bảo trì tại hi@puter.com + +Chúng tôi luôn sẵn sàng giúp đỡ bạn với bất kỳ câu hỏi nào bạn có. Đừng ngần ngại hỏi! + +
+ +## Giấy Phép + +Kho lưu trữ này, bao gồm tất cả nội dung, dự án con, mô-đun và thành phần của nó, được cấp phép theo [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt), trừ khi được tuyên bố rõ ràng khác. Các thư viện của bên thứ ba được bao gồm trong kho lưu trữ này có thể phải tuân theo các giấy phép riêng của chúng. + +
diff --git a/doc/i18n/README.zh.md b/doc/i18n/README.zh.md new file mode 100644 index 0000000000000000000000000000000000000000..c3882cc176812998d96d8d4449844ce81cddbcfd --- /dev/null +++ b/doc/i18n/README.zh.md @@ -0,0 +1,138 @@ + +

Puter.com,个人云计算机:所有文件、应用程序和游戏在一个地方,随时随地可访问。

+ +

互联网操作系统!免费、开源且可自行托管。

+ +

+ GitHub repo size GitHub Release GitHub License +

+ +

+ « 在线演示 » +
+
+ Puter.com + · + SDK + · + Discord + · + Reddit + · + X (Twitter) +

+ +

screenshot

+ +
+ +## Puter + +Puter 是一个先进的开源互联网操作系统,设计为功能丰富、速度极快且高度可扩展。Puter 可用作: + +- 一个以隐私为优先的个人云,将所有文件、应用程序和游戏保存在一个安全的地方,随时随地可访问。 +- 构建和发布网站、Web 应用程序和游戏的平台。 +- Dropbox、Google Drive、OneDrive 等的替代品,具有全新的界面和强大的功能。 +- 服务器和工作站的远程桌面环境。 +- 一个友好的开源项目和社区,学习 Web 开发、云计算、分布式系统等更多内容! + +
+ +## 入门指南 + + +### 💻 本地开发 + +```bash +git clone https://github.com/HeyPuter/puter +cd puter +npm install +npm start +``` + +这将会在 http://puter.localhost:4100(或下一个可用端口)启动 Puter。 + +
+ +### 🐳 Docker + + +```bash +mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter +``` + +
+ + +### 🐙 Docker Compose + + +#### Linux/macOS +```bash +mkdir -p puter/config puter/data +sudo chown -R 1000:1000 puter +wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml +docker compose up +``` +
+ +#### Windows + + +```powershell +mkdir -p puter +cd puter +New-Item -Path "puter\config" -ItemType Directory -Force +New-Item -Path "puter\data" -ItemType Directory -Force +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml" +docker compose up +``` +
+ +## 宝塔面板Docker一键部署(推荐) + +1. 安装宝塔面板9.2.0及以上版本,前往 [宝塔面板](https://www.bt.cn/new/download.html?r=dk_puter) 官网,选择正式版的脚本下载安装 + +2. 安装后登录宝塔面板,在左侧菜单栏中点击 `Docker`,首次进入会提示安装`Docker`服务,点击立即安装,按提示完成安装 + +3. 安装完成后在应用商店中搜索`puter`,点击安装,配置域名等基本信息即可完成安装 + + +### ☁️ Puter.com + +Puter 可以作为托管服务使用,访问 [**puter.com**](https://puter.com)。 + +
+ +## 系统要求 + +- **操作系统:** Linux, macOS, Windows +- **内存:** 最低 2GB(推荐 4GB) +- **磁盘空间:** 1GB 可用空间 +- **Node.js:** 版本 16+(推荐 22+) +- **npm:** 最新稳定版本 + +
+ +## 支持 + +通过以下渠道与维护者和社区联系: + +- 有 Bug 报告或功能请求?请 [提交问题](https://github.com/HeyPuter/puter/issues/new/choose)。 +- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u) +- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter) +- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/) +- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter) +- 安全问题?请联系 [security@puter.com](mailto:security@puter.com) +- 电子邮件维护者 [hi@puter.com](mailto:hi@puter.com) + +我们随时乐意帮助您解答任何问题,欢迎随时联系! + +
+ + +## 许可证 + +本仓库,包括其所有内容、子项目、模块和组件,除非另有明确说明,否则均遵循 [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) 许可证。 本仓库中包含的第三方库可能受其各自的许可证约束。 + +
diff --git a/doc/license_header.txt b/doc/license_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..33114e1e774cc6876ca2517f2ee763713cfaa8e2 --- /dev/null +++ b/doc/license_header.txt @@ -0,0 +1,16 @@ +Copyright (C) 2024 Puter Technologies Inc. + +This file is part of Puter. + +Puter is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/doc/planning/2025-10-21_puter-fs-extension.md b/doc/planning/2025-10-21_puter-fs-extension.md new file mode 100644 index 0000000000000000000000000000000000000000..c991076ca2bf3284837fd7bd79e533b9138a3b3d --- /dev/null +++ b/doc/planning/2025-10-21_puter-fs-extension.md @@ -0,0 +1,79 @@ +## 2025-10-21 + +### Moving PuterFSProvider to an Extension + +PuterFSProvider is not trivial to move to an extension because of +relative imports (`require()`s) which represent dependencies on parts +of Puter's core which may not be available to extensions, or should +move with PuterFSProvider into an extension. + +Dependencies of PuterFS provider will be placed into the following +categories: +- **Already OK** - this is already exposed to extensions +- **Export As-Is** - this needs to be exposed to extensions +- **Belongs to PuterFS** - this needs to be moved to an + extension first or at the same time as PuterFSProvider +- **Create Extension API** - an API needs to be created or improved + to use this dependency in the corrrect way for PuterFSProvider to + be an extension + +External dependencies (such as `uuid`) and dependencies treated like +external dependencies (such as `putility`) are not included here +because they're just updates to a `package.json` file. + +#### Already OK +- Context +- APIError +- `DB_WRITE`, `DB_READ` +- streamutil +- config +- Actor +- UserActorType +- get_user +- metering service +- trace service + +#### Export As-Is +- ~~filesystem selectors~~ +- fsCapabilities +- UploadProgressTracker (utility) +- FSNodeContext +- ResourceService constants +- ParallelTasks +- FSNodeContext type context (`TYPE_FILE`, etc) +- operation frame status constants + +#### Belongs to PuterFS +- FSLockService +- FSEntryFetcher +- FSEntryService +- `update_child_paths` [^1] +- SizeService +- `storage` object from **Context** [^2] + +[^1]: FilesystemService belong's in Puter Core, but + the `update_child_paths` method is an + implementation detail of PuterFS +[^2]: LocalDiskStorageService registers this value + in the `context` using the `context-init` service. + PuterFS as an extension should emit an event where + other extensions can register a PuterFS storage + strategy. + +#### Create Extension API + +See notes below for details +- filesystem selectors +- access current operation frame +- getting/creating actors from user ID + +### New Extension APIs + +#### Filesystem Selectors + +Filesystem selectors can be implied from strings instead +of having to instantiate classes and compose them. + +Path: `"/just/a/string"` +UUID: `/^[^\/\.]/` +Child: `SOME-UUID/followed/by/a/path` diff --git a/doc/planning/alternatives-to-$.md b/doc/planning/alternatives-to-$.md new file mode 100644 index 0000000000000000000000000000000000000000..5b605c50a233b1779d683da1eca3cc601403432e --- /dev/null +++ b/doc/planning/alternatives-to-$.md @@ -0,0 +1,136 @@ +### Problem + +When sending metadata along with arbitrary JSON objects, +a collision of property names may occur. For example, the +driver system can't place a "type" property on an arbitrary +response coming from a driver because that might also be +the name of a property in the response. + + +#### Example: +```json +{ + "type": "api:thing", + "version": "v1.0.0", + "some": "info" +} +``` + +#### Awful Solution + +Reserved words. Drivers need to know their response can't have +keys like `type` or `version`. If we'd like to add more meta +keys in the future we need to verify that no existing drivers +use the new key we'd like to reserve. If we have have such features +as user-submitted drivers this will be impossibe. +A `meta` key as a single reserved word could work, which is one +of the solutions discussed below. + +#### Obvious Solution: + +The obvious solution is to return an object with a +`head` property and a `body` propery: + +```json +{ + "head": { + "type": "api:thing", + "version": "v1.0.0" + }, + "body": { + "some": "info" + } +} +``` + +I don't mind this solution. I've come up with some alternatives though, +because this solution has a couple drawbacks: +- it looks a little verbose +- it's not backwards-compatible with arbitrary JSON-object responses + +## Solutions + +### Dollar-Sign Convention + +- Objects have two classes of keys: + - "meta" keys begin with "$" + - other keys must validate against the + usual identifier rules: `/[A-Za-z_][A-Za-z0-9_]*/` +- The meta key `$` indicates the schema or class of + the object. +- Example: + ```json + { + "$": "api:thing", + "$version": "v1.0.0", + + "some": "info" + } + ``` +- what sucks about it: + - `$` might be surprising or confusing + - response is a subset of valid JSON keys + (those not including `$`) +- what's nice about it: + - backwards-compatible with arbitrary JSON-object responses + which don't already use `$` + +### Underscore Convention +- Same as above, but `_` instead of `$` + ```json + { + "_": "api:thing", + "_version": "v1.0.0", + + "some": "info" + } + ``` +- what sucks about it: + - `_` might be confusing + - response is a subset of valid JSON keys + (those not including `_`) +- what's nice about it: + - `_` is conventionally used for private property names, + so this might be a little less surprising + - backwards-compatible with arbitrary JSON-object responses + which don't already use `_` + +### Nesting Convention, simplified + +- Similar to the "obvious solution" except + metadata fields are lifted up a level. + It's relatively inconsequential if meta keys + have reserved words compared to value keys. + ```json + { + "type": "api:thing", + "version": "v1.0.0", + "value": { + "some": "info" + } + } + ``` + +### Modified Dollar/Underscore convention +- Using `_` in this example, but instead of prefixing + meta properties they all go under one key. + ```json + { + "_": { + "type": "api:thing", + "version": "v1.0.0" + }, + + "some": "info" + } + ``` +- what sucks about it: + - `_` might be confusing + - response is a subset of valid JSON keys + (those not **exactly** `_`) +- what's nice about it: + - `_` is conventionally used for private property names, + so this might be a little less surprising + - backwards-compatible with arbitrary JSON-object responses + which don't already use `_` as an exact key + - only one reserved key diff --git a/doc/planning/micro-modules.md b/doc/planning/micro-modules.md new file mode 100644 index 0000000000000000000000000000000000000000..4ee635e5754deba19964abbfd433d5f7b2c73c2a --- /dev/null +++ b/doc/planning/micro-modules.md @@ -0,0 +1,12 @@ +# Micro Modules + +**CoreModule** has a large number of services. Each service handles +a general concern, like "notifications", but increasing this granularity +a little put more could allow a lot more code re-use. + +One specific example that comes to mind is services that provide +CRUD operations for a database table. The **EntityStoreService** can +be used for a lot of these even though right now it's specifically +used for drivers. Having a common class of service like this can also +allow quickly configuring the equivalent service for providing those +CRUD operations through an API. diff --git a/doc/prod.md b/doc/prod.md new file mode 100644 index 0000000000000000000000000000000000000000..2770eb664586351100cd141fa7cf1abd919be236 --- /dev/null +++ b/doc/prod.md @@ -0,0 +1,149 @@ +# Puter in Production + +## Building + +```bash +npm run build +``` + +## Usage + +Will build Puter in the `dist` directory. Include the generated `./dist/gui.js` file in your HTML page and call `gui()` when the page is loaded: + +```html + + +``` + +## Full Production Example + +Assuming the following directory structure in production: + +``` +. +├── dist/ +│ ├── favicons/ +│ ├── images/ +│ ├── bundle.min.css +│ ├── bundle.min.js +│ ├── gui.js +│ └── ... +└── index.html +``` + +The `index.html` file below will load Puter and all the necessary meta tags, favicons, and branding assets: + +```html + + + + + Puter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Server settings + +The GUI is a single page application (SPA) and as best practice any route under root (`/*`) should preferably load the `index.html` file. However, there are situations where we want to load a custom page for a specific route: for example, the `/privacy` route may need to load a page that contains your privacy policy and has nothing to do with the GUI application. In these cases it is ok to load a custom page as long as the following essential GUI routes are loaded with the GUI (i.e. `index.html` file): +- `/app/*` +- `/action/*` + +In other words, consider the routes above as "reserved" for Puter. + +### Publish My Website + +Right-click anywhere on the desktop to display options +From the options menu, select "New". +Then, choose "Folder". +Give the folder a name according to your preference. + +After creating the folder: + +Right-click on the folder. +Select the option "Publish as Website". + +### Best Practices + +- The `title` tags and meta tags (``, `privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.` the `` tag should be escaped to `<b>` so that the browser doesn't interpret it as an HTML tag. + +- Make sure to replace all new line characters with space when dynamically adding text to the HTML page. + +- Generally, for UX and SEO reasons make sure that the tags are filled with relevant information about the state the URL is representing. E.g. is the user on the desktop or an app? diff --git a/doc/self-hosters/config-vals.json.js b/doc/self-hosters/config-vals.json.js new file mode 100644 index 0000000000000000000000000000000000000000..e82f59bcd56d4389686e2d51cb2203e2525dd8d8 --- /dev/null +++ b/doc/self-hosters/config-vals.json.js @@ -0,0 +1,84 @@ +export default [ + { + key: 'domain', + description: ` + Domain name of the Puter instance. This may be used to generate URLs + in the UI. If "allow_all_host_values" is false or undefined, the domain + will be used to validate the host header of incoming requests. + `, + example_values: [ + 'example.com', + 'subdomain.example.com', + ], + }, + { + key: 'protocol', + description: ` + The protocol to use for URLs. This should be either "http" or "https". + `, + example_values: [ + 'http', + 'https', + ], + }, + { + key: 'static_hosting_domain', + description: ` + This domain name will be used for public site URLs. For example: when + you right-click a directory and choose "Publish as Website". + This domain should point to the same server. If you have a LAN configuration + you could set this to something like + \`site.192.168.555.12.nip.io\`, replacing + \`192.168.555.12\` with a valid IP address belonging to the server. + `, + }, + { + key: 'allow_all_host_values', + description: ` + If true, Puter will accept any host header value in incoming requests. + This is useful for development, but should be disabled in production. + `, + }, + { + key: 'allow_nipio_domains', + description: ` + If true, Puter will allow requests with host headers that end in nip.io. + This is useful for development, LAN, and VPN configurations. + `, + }, + { + key: 'http_port', + description: ` + The port to listen on for HTTP requests. + `, + }, + { + key: 'enable_public_folders', + description: ` + If true, any /username/Public directory will be available to all + users, including anonymous users. + `, + }, + { + key: 'disable_temp_users', + description: ` + If true, new users will see the login/signup page instead of being + automatically logged in as a temporary user. + `, + }, + { + key: 'disable_user_signup', + description: ` + If true, the signup page will be disabled and the backend will not + accept new user registrations. + `, + }, + { + key: 'disable_fallback_mechanisms', + description: ` + A general setting to prevent any fallback behavior that might + "hide" errors. It is recommended to set this to true when + debugging, testing, or developing new features. + `, + }, +]; \ No newline at end of file diff --git a/doc/self-hosters/config.md b/doc/self-hosters/config.md new file mode 100644 index 0000000000000000000000000000000000000000..72194c62b2553cde7ab367ed020b21f210bc3517 --- /dev/null +++ b/doc/self-hosters/config.md @@ -0,0 +1,89 @@ +# Configuring Puter + +## Terminology + +- **root** - the "top level" of configuration; if a key-value pair is in/at "the root" + that means it is **not in a nested object** + (ex: values under "services" are **not** at the root). + +## Config Locations + +Running the server will generate a configuration file in one of these locations: +- `config/config.json` when [Using Docker](#using-docker) +- `volatile/config/config.json` in [Local Development](#local-development) +- `/etc/puter/config.json` on a server (or within a Docker container) + +## Editing Configuration + +For a list of all possible config values, see [config_values.md](./config_values.md) + +Instead of editing the generated `config.json`, you can make a config file +that references it. This makes it easier to maintain if you frequently update +Puter, since you can then just delete `config.json` to get new defaults. + +For example, a `local.json` might look like this: + +```json +{ + // Always include this header + "$version": "v1.1.0", + "$requires": [ + "config.json" + ], + "config_name": "local", + + // Your custom configuration + "domain": "my-puter.example.com" +} +``` + +To use `local.json` instead of `config.json` you will need to set the +environment variable `PUTER_CONFIG_PROFILE=local` in the context where +you are running Puter. + +## Sample Configuration + +The default configuration generated by Puter will look +something like the following (updated 2025-02-26): + +```json +{ + "config_name": "generated default config", + "mod_directories": [ + "{source}/../extensions" + ], + "env": "dev", + "nginx_mode": true, + "server_id": "localhost", + "http_port": "auto", + "domain": "puter.localhost", + "protocol": "http", + "contact_email": "hey@example.com", + "services": { + "database": { + "engine": "sqlite", + "path": "puter-database.sqlite" + }, + "thumbnails": { + "engine": "http" + }, + "file-cache": { + "disk_limit": 5368709120, + "disk_max_size": 204800, + "precache_size": 209715200, + "path": "./file-cache" + } + }, + "cookie_name": "...", + "jwt_secret": "...", + "url_signature_secret": "...", + "private_uid_secret": "...", + "private_uid_namespace": "...", + "": null +} +``` + +## Root-Level Parameters + +- **domain** - origin for Puter. Do **not** include URL schema (the 'http(s)://' portion) +- \ No newline at end of file diff --git a/doc/self-hosters/config_values.md b/doc/self-hosters/config_values.md new file mode 100644 index 0000000000000000000000000000000000000000..9c17f85c47db130c66f2038dca0a95875e5b2a11 --- /dev/null +++ b/doc/self-hosters/config_values.md @@ -0,0 +1,72 @@ +### `domain` + +Domain name of the Puter instance. This may be used to generate URLs +in the UI. If "allow_all_host_values" is false or undefined, the domain +will be used to validate the host header of incoming requests. + +#### Examples + +- `"domain": "example.com"` +- `"domain": "subdomain.example.com"` + +### `protocol` + +The protocol to use for URLs. This should be either "http" or "https". + +#### Examples + +- `"protocol": "http"` +- `"protocol": "https"` + +### `static_hosting_domain` + +This domain name will be used for public site URLs. For example: when +you right-click a directory and choose "Publish as Website". +This domain should point to the same server. If you have a LAN configuration +you could set this to something like +`site.192.168.555.12.nip.io`, replacing +`192.168.555.12` with a valid IP address belonging to the server. + + +### `allow_all_host_values` + +If true, Puter will accept any host header value in incoming requests. +This is useful for development, but should be disabled in production. + + +### `allow_nipio_domains` + +If true, Puter will allow requests with host headers that end in nip.io. +This is useful for development, LAN, and VPN configurations. + + +### `http_port` + +The port to listen on for HTTP requests. + + +### `enable_public_folders` + +If true, any /username/Public directory will be available to all +users, including anonymous users. + + +### `disable_temp_users` + +If true, new users will see the login/signup page instead of being +automatically logged in as a temporary user. + + +### `disable_user_signup` + +If true, the signup page will be disabled and the backend will not +accept new user registrations. + + +### `disable_fallback_mechanisms` + +A general setting to prevent any fallback behavior that might +"hide" errors. It is recommended to set this to true when +debugging, testing, or developing new features. + + diff --git a/doc/self-hosters/domains.md b/doc/self-hosters/domains.md new file mode 100644 index 0000000000000000000000000000000000000000..2334b9c086e4b7708b676fb42015e1227330eee2 --- /dev/null +++ b/doc/self-hosters/domains.md @@ -0,0 +1,85 @@ +# Configuring Domains for Self-Hosted Puter + +## Local Network Configuration + +### Prerequisite Conditions + +Ensure the hosting device has a static IP address to prevent potential connectivity issues due to IP changes. This setup will enable seamless access to Puter and its services across your local network. + +### Using `nip.io` + +We recommend this configuration for LAN setups. All you need to do is set the following +at root level in your configuration file: + +```json + "allow_nipio_domains": true +``` + +Puter requires multiple origins to work correctly. `nip.io` is a wildcard DNS for IP addresses, +so Puter can still have multiple subdomains and you don't need to configure your own DNS or +hosts file. + +### Using Hosts Files + +The hosts file is a straightforward way to map domain names to IP addresses on individual devices. It's simple to set up but requires manual changes on each device that needs access to the domains. + +#### Windows +1. Open Notepad as an administrator. +2. Open the file located at `C:\Windows\System32\drivers\etc\hosts`. +3. Add lines for your domain and subdomain with the server's IP address, in the + following format: + ``` + 192.168.1.10 puter.local + 192.168.1.10 api.puter.local + ``` + +#### For macOS and Linux: +1. Open a terminal. +2. Edit the hosts file with a text editor, e.g., `sudo nano /etc/hosts`. +3. Add lines for your domain and subdomain with the server's IP address, in the + following format: + ``` + 192.168.1.10 puter.local + 192.168.1.10 api.puter.local + ``` +4. Save and exit the editor. + + +### Using Router Configuration + +Some routers allow you to add custom DNS rules, letting you configure domain names network-wide without touching each device. + +1. Access your router’s admin interface (usually through a web browser). +2. Look for DNS or DHCP settings. +3. Add custom DNS mappings for `puter.local` and `api.puter.local` to the hosting device's IP address. +4. Save the changes and reboot the router if necessary. + +This method's availability and steps may vary depending on your router's model and firmware. + +### Using Local DNS + +Setting up a local DNS server on your network allows for flexible and scalable domain name resolution. This method works across all devices automatically once they're configured to use the DNS server. + +#### Options for DNS Software: + +- **Pi-hole**: Acts as both an ad-blocker and a DNS server. Ideal for easy setup and maintenance. +- **BIND9**: Offers comprehensive DNS server capabilities for complex setups. +- **dnsmasq**: Lightweight and suitable for smaller networks or those new to running a DNS server. + +**contributors note:** feel free to add any software you're aware of +which might help with this to the list. Also, feel free to add instructions here for specific software; our goal is for Puter to be easy to setup with tools you're already familiar with. + +#### General Steps: + +1. Choose and install DNS server software on a device within your network. +2. Configure the DNS server to resolve `puter.local` and `api.puter.local` to the IP address of your Puter hosting device. +3. Update your router's DHCP settings to distribute the DNS server's IP address to all devices on the network. + +By setting up a local DNS server, you gain the most flexibility and control over your network's domain name resolution, ensuring that all devices can access Puter and its API without manual configuration. + +## Production Configuration + +Please note the self-hosting feature is still in alpha and a public production +deployment is not recommended at this time. However, if you wish to host +publicly you can do so following the same steps you normally would to configure +a domain name and ensuring the `api` subdomain points to the server as well. diff --git a/doc/self-hosters/first-run-issues.md b/doc/self-hosters/first-run-issues.md new file mode 100644 index 0000000000000000000000000000000000000000..9475591cd9ce431c26d6aa4a13403ebf1dd94b74 --- /dev/null +++ b/doc/self-hosters/first-run-issues.md @@ -0,0 +1,74 @@ +# First Run Issues + +## "Cannot find package '@heyputer/backend'" + +Scenario: You see the following output: + +``` +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ Cannot find package '@heyputer/backend' ┃ +┃ 📝 this usually happens if you forget `npm install` ┃ +┃ Suggestions: ┃ +┃ - try running `npm install` ┃ +┃ Technical Notes: ┃ +┃ - @heyputer/backend is in an npm workspace ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +``` + +1. Ensure you have run `npm install`. +2. [Install build essentials for your distro](#installing-build-essentials), + then run `npm install` again. + +## Installing Build Essentials + +### Debian-based distros + +``` +sudo apt update +sudo apt install build-essential +``` + +### RHEL-family distros (Fedora, Rocky, etc) + +``` +sudo dnf groupinstall "Development Tools" +``` + +### "I use Arch btw" + +``` +sudo pacman -S base-devel +``` + +### Alpine + +If you're running in Puter's Alpine image then this is already installed. + +``` +sudo apk add build-base +``` + +### Gentoo + +You know what you're doing; you just wanted to see if we mentioned Gentoo. + +## "Could not load the "sharp" module using the freebsd-x64 runtime" + +In order to get it to work on FreeBSD, you will need to build sharp from source and link it to the project. + +``` +pkg install vips +git clone --depth=1 https://github.com/lovell/sharp.git +cd sharp +yarn install +sudo npm link +``` + +After `npm install` you can link the prebuilt module + +``` +# cd puter +# npm install +npm link sharp +npm start +``` diff --git a/doc/self-hosters/gen.js b/doc/self-hosters/gen.js new file mode 100644 index 0000000000000000000000000000000000000000..7ef758ab16d811c3512a55ecde2b8399060aaae6 --- /dev/null +++ b/doc/self-hosters/gen.js @@ -0,0 +1,25 @@ +import dedent from 'dedent'; +import configVals from './config-vals.json.js'; + +const mdlib = {}; +mdlib.h = (out, n, str) => { + out(`${'#'.repeat(n)} ${str}\n\n`); +}; + +const N_START = 3; + +const out = str => process.stdout.write(str); +for ( const configVal of configVals ) { + mdlib.h(out, N_START, `\`${configVal.key}\``); + out(`${dedent(configVal.description) }\n\n`); + + if ( configVal.example_values ) { + mdlib.h(out, N_START + 1, 'Examples'); + for ( const example of configVal.example_values ) { + out(`- \`"${configVal.key}": ${JSON.stringify(example)}\`\n`); + } + } + + out('\n'); + +} diff --git a/doc/self-hosters/instructions.md b/doc/self-hosters/instructions.md new file mode 100644 index 0000000000000000000000000000000000000000..2961f762d7087cea2b4d833c46534c2df5f0507c --- /dev/null +++ b/doc/self-hosters/instructions.md @@ -0,0 +1,59 @@ +# Self-Hosting Puter + +> [!WARNING] +> The self-hosted version of Puter is currently in alpha stage and should not be used in production yet. It is under active development and may contain bugs, other issues. Please exercise caution and use it for testing and evaluation purposes only. + +### Self-Hosting Differences +Currently, the self-hosted version of Puter is different in a few ways from [Puter.com](https://puter.com): +- There is no built-in way to access apps from puter.com (see below) +- Several "core" apps are missing, such as **Code** or **Draw** +- Some assets are different + +Work is ongoing to improve the **App Center** and make it available on self-hosted. +Until then, it is still possible to add apps using the **Dev Center** app. + +
+ +## Configuration + +Running the server will generate a [configuration file](./config.md) in one of these locations: +- `config/config.json` when [Using Docker](#using-docker) +- `volatile/config/config.json` in [Local Development](#local-development) +- `/etc/puter/config.json` on a server (or within a Docker container) + +### Domain Name + +To access Puter on your device, you can simply go to the address printed in +the server console (usually `puter.localhost:4100`). + +To access Puter from another device on LAN, enable the following configuration: +```json +"allow_nipio_domains": true +``` + +To access Puter from another device, a domain name must be configured, as well as +an `api` subdomain. For example, `example.local` might be the domain name pointing +to the IP address of the server running puter, and `api.example.com` must point to +this address as well. This domain must be specified in the configuration file +(usually `volatile/config/config.json`) as well. + +See [domain configuration](./domains.md) for more information. + +### Configure the Port + +- You can specify a custom port by setting `http_port` to a desired value +- If you're using a reverse-proxy such as nginx or cloudflare, you should + also set `pub_port` to the public (external) port (usually `443`) +- If you have HTTPS enabled on your reverse-proxy, ensure that + `protocol` in config.json is set accordingly + +### Default User + +By default, Puter will create a user called `default_user`. +This user will have a randomly generated password, which will be printed +in the development console. +A warning will persist in the dev console until this user's +password is changed. Please login to this user and change the password as +your first step. + +
diff --git a/doc/self-hosters/support.md b/doc/self-hosters/support.md new file mode 100644 index 0000000000000000000000000000000000000000..5f3eee130c6342ba18665a56c5724e8a105fbd8a --- /dev/null +++ b/doc/self-hosters/support.md @@ -0,0 +1,39 @@ +## Puter Support Levels for Repository Updates + +This document describes issues requiring repository changes; +which issues will be fixed by Puter's core team, and which ones +will be fixed if the community makes a contribution. + +This document is not "law". It is provided only as a helpful guide +on what to expect. + +### Level Glossary + +| Name | Description | +| ---- | ----------- | +| Core | Core developers will fix this | +| Community | We will accept contributions to fix this | +| Mixed | Core developers will fix this if it's currently a priority | + +### Issues and their Levels + +| Issue | Priority | +| ----- | -------- | +| Security vulnerability | Core | +| Breaking change to SDK or API | Core | +| Bug in service in CoreModule | Core | +| Bug in a built-in app | Core | +| Login/init failure in Docker on `release` branch | Core | +| Login/init failure in Linux or OSX | Core | +| Login/init failure in Docker on `main` branch | Mixed | +| Login/init failure with specific configuration | Mixed | +| Login/init failure in Windows | Community | + + +## Puter Support for a Particular Deployment + +If you experience issues on a self-hosted deployment we're here to +help. Some issues are related to configuration or environment, so +we may only be able to help in a limited capacity. Issues related +to data loss, data corruption, or security will have higher priority +over other issues with particular deployments. diff --git a/doc/test/playwright-test.md b/doc/test/playwright-test.md new file mode 100644 index 0000000000000000000000000000000000000000..6f5903884b0d5b7e0949b67ec6073ccf0fb3bc61 --- /dev/null +++ b/doc/test/playwright-test.md @@ -0,0 +1,54 @@ +## Summary + +Playwright test the puter-js API in browser environment. + +## Motivation + +Some features of the puter-js/puter-GUI only work in the browser environment: + +- file system + - naive-cache + - client-replica (WIP) + - wspush + +## Setup + +Install dependencies: + +```sh +cd ./tests/playwright +npm install +npx playwright install --with-deps +``` + +Initialize the client config (working directory: `./tests/playwright`): + +1. `cp ../example-client-config.yaml ../client-config.yaml` +2. Edit the `client-config.yaml` to set the `auth_token` + +## Run tests + +### CLI + +Working directory: `./tests/playwright` + +```sh +# run all tests +npx playwright test + +# run a test by name +# e.g: npx playwright test -g "mkdir in root directory is prohibited" +npx playwright test -g "mkdir in root directory is prohibited" + +# run the tests that failed in the last test run +npx playwright test --last-failed + +# open the report of the last test run in the browser +npx playwright show-report +``` + +### VSCode/Cursor + +1. Install the "Playwright Test for VSCode" extension. +2. Go to "Testing" tab in the sidebar. +3. Click buttons to run tests. diff --git a/doc/testing_with_email.md b/doc/testing_with_email.md new file mode 100644 index 0000000000000000000000000000000000000000..9797f7afb4e122a861612a2528d6a56fe3bf90f9 --- /dev/null +++ b/doc/testing_with_email.md @@ -0,0 +1,31 @@ +# Testing with Email + +Testing anything involving email is really simple using [mailhog](https://github.com/mailhog/MailHog) + +### Step 1: Configure email service + +In your `config.json` for Puter (`volatile/config/config.json` usually, `/var/puter/config.json` in containers), +add this entry to the `"services`" map: + +```javascript + "services": { + + // ... there are probably other service configs + + "email": { + "host": "localhost", + "port": 1025 + } + } +``` + +### Step 2: Install and run mailhog + +Follow the instructions on [MailHog](https://github.com/mailhog/MailHog)'s +repository, or install through your distro's package manager. + +Run the command: `mailhog`. + +You should now have an inbox at [http://127.0.0.1:8025](http://127.0.0.1:8025). + +Every email that Puter sends will show up on this page. diff --git a/doc/uncategorized/README.md b/doc/uncategorized/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e9c8223fc55735b2408d0102780a86bbea0b63cc --- /dev/null +++ b/doc/uncategorized/README.md @@ -0,0 +1,9 @@ +# Uncategorized Documentation + +Any document in this directory may be moved in the future to +a more suitable location. This is a good place to put any +documentation that needs to be written when it's unclear what +the best place for it is. This is to avoid situations where +documentation _doesn't_ get written simply because it's not clear +where it belongs (something which the author of this very document +has been guilty of at times). diff --git a/doc/uncategorized/es6-note.md b/doc/uncategorized/es6-note.md new file mode 100644 index 0000000000000000000000000000000000000000..9d7cfdf50268eff969b7ac6b60e9109dabbd83c6 --- /dev/null +++ b/doc/uncategorized/es6-note.md @@ -0,0 +1,46 @@ +# Notes about ES6 Class Syntax + +## Document Meta + +> **backend focus:** This documentation is more relevant to +> Puter's backend than frontend, but is placed here because +> it could apply to other areas in the future. + +## Expressions as Methods + +One important shortcoming in the ES6 class syntax to be aware of +is that it discourages the use of expressions as methods. + +For example: + +```javascript +class ExampleClass extends SomeBase { + intuitive_method_definition () {} + + constructor () { + this.less_intuitive = some_expr(); + } +} +``` + +Even if it is known that the return type of `some_expr` is a function, +it is still unclear whether it's being used as a callback or +as a method without other context in the code, since this is +how we typically assign instance members rather than methods. + +We solve this in Puter's backend using a **trait** called +[AssignableMethodsTrait](../../packages/backend/src/traits/AssignableMethodsTrait.js) +which allows a static member called `METHODS` to contain +method definitions. + +### Uses for Expressions as Methods + +#### Method Composition + +Method Composition is the act of composing methods from other +constituents. For example, +[Sequence](../../packages/backend/src/codex/Sequence.js) +allows composing a method from smaller functions, allowing +easier definition of "in-betwewen-each" behaviors and ways +to track which values from the arguments are actually read +during a particular call. diff --git a/doc/uncategorized/puter-mods.md b/doc/uncategorized/puter-mods.md new file mode 100644 index 0000000000000000000000000000000000000000..e9363f0ca6af72d46bd6858ca4f71304ed477b4b --- /dev/null +++ b/doc/uncategorized/puter-mods.md @@ -0,0 +1,66 @@ +# Puter Mods + +## What is a Puter Mod? + +Currently, the definition of a Puter mod is: + +> A [Module](../../packages/backend/doc/contributors/modules.md) +> which is exported by a package directory which itself exists +> within a directory specified in the `mod_directories` array +> in `config.json`. + +## Enabling Puter Mods + +### Step 1: Update Configuration + +First update the configuration (usually at `./volatile/config.json` +or `/var/puter/config.json`) to specify mod directories. + +```json +{ + "config_name": "example config", + + "mod_directories": [ + "{source}/mods/mods_enabled" + ] + + // ... other config options +} +``` + +The first path you'll want to add is +`"{source}/mods/mods_enabled"` +which adds all the mods included in Puter's official repository. +You don't need to change `{source}` unless your entry javascript +file is in a different location than the default. + +If you want to enable all the mods, you can change the path above +to `mods_available` instead and skip step 2 below. + +### Step 2: Select Mods + +To enable a Puter mod, create a symbolic link (AKA symlink) in +`mods/mods_enabled`, pointing to +a directory in `mods/mods_available`. This follows the same convention +as managing sites/mods in Apache or Nginx servers. + +For example to enable KDMOD (which you can read as "Kernel Dev" mod, +or "the mod that GitHub user KernelDeimos created to help with testing") +you would run this command: +```sh +ln -rs ./mods/mods_available/kdmod ./mods/mods_enabled/ +``` + +This will create a symlink at `./mods/mods_enabled/kdmod` pointing +to the directory `./mods/mods_available/kdmod`. + +> **note:** here are some helpful tips for the `ln` command: +> - You can remember `ln`'s first argument is the unaffected +> source file by remembering `cp` and `mv` are the same in +> this way. +> - If you don't add `-s` you get a hard link. You will rarely +> find yourself needing to do that. +> - The `-r` flag allows you to write both paths relative to +> the directory from which you are calling the command, which +> is sometimes more intuitive. + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..993a343decd9827f73ba948d8ec402e3c91b8b05 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +--- +version: "3.8" +services: + puter: + container_name: puter + image: ghcr.io/heyputer/puter:latest + pull_policy: always + # build: ./ + restart: unless-stopped + ports: + - '4100:4100' + environment: + # TZ: Europe/Paris + # CONFIG_PATH: /etc/puter + PUID: 1000 + PGID: 1000 + volumes: + - ./puter/config:/etc/puter + - ./puter/data:/var/puter + healthcheck: + test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1 + interval: 30s + timeout: 3s + retries: 3 + start_period: 30s diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..b44457c0e7f0bca287ce71f5c42cd6639f22cf56 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,194 @@ +import js from '@eslint/js'; +import stylistic from '@stylistic/eslint-plugin'; +import tseslintPlugin from '@typescript-eslint/eslint-plugin'; +import tseslintParser from '@typescript-eslint/parser'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import bangSpaceIf from './eslint/bang-space-if.js'; +import controlStructureSpacing from './eslint/control-structure-spacing.js'; +import spaceUnaryOpsWithException from './eslint/space-unary-ops-with-exception.js'; + +export const rules = { + 'no-invalid-this': 'error', + 'no-unused-vars': ['error', { + vars: 'all', + args: 'after-used', + caughtErrors: 'none', + ignoreRestSiblings: false, + ignoreUsingDeclarations: false, + reportUsedIgnorePattern: false, + argsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + + }], + curly: ['error', 'multi-line'], + '@stylistic/curly-newline': ['error', 'always'], + '@stylistic/object-curly-spacing': ['error', 'always'], + '@stylistic/indent': ['error', 4, { + CallExpression: { + arguments: 4, + }, + }], + '@stylistic/indent-binary-ops': ['error', 4], + '@stylistic/array-bracket-newline': ['error', 'consistent'], + '@stylistic/semi': ['error', 'always'], + '@stylistic/quotes': ['error', 'single', { 'avoidEscape': true }], + '@stylistic/function-call-argument-newline': ['error', 'consistent'], + '@stylistic/arrow-spacing': ['error', { before: true, after: true }], + '@stylistic/space-before-function-paren': 'error', + '@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }], + '@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }], + '@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }], + '@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }], + '@stylistic/comma-dangle': ['error', 'always-multiline'], + '@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }], + '@stylistic/dot-location': ['error', 'property'], + '@stylistic/space-infix-ops': ['error'], + 'no-undef': 'error', + 'custom/control-structure-spacing': 'error', + 'custom/bang-space-if': 'error', + '@stylistic/no-trailing-spaces': 'error', + '@stylistic/space-before-blocks': ['error', 'always'], + 'prefer-template': 'error', + '@stylistic/no-mixed-spaces-and-tabs': ['error', 'smart-tabs'], + 'custom/space-unary-ops-with-exception': ['error', { words: true, nonwords: false }], + '@stylistic/no-multi-spaces': ['error', { exceptions: { 'VariableDeclarator': true } }], + '@stylistic/type-annotation-spacing': 'error', + '@stylistic/type-generic-spacing': 'error', + '@stylistic/type-named-tuple-spacing': ['error'], + 'no-use-before-define': ['error', { + 'functions': false, + }], +}; + +const tsRules = { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }], + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], +}; + +const sharedPlugins = { + js, + '@stylistic': stylistic, + custom: { + rules: { + 'control-structure-spacing': controlStructureSpacing, + 'bang-space-if': bangSpaceIf, + 'space-unary-ops-with-exception': spaceUnaryOpsWithException, + }, + }, +}; + +const sharedJsConfig = { + rules, + plugins: sharedPlugins, +}; + +const recommendedJsConfig = { + ...sharedJsConfig, + extends: ['js/recommended'], +}; + +const createTsConfig = ({ files, project, ignores = [], globals: tsGlobals }) => ({ + files, + ignores, + languageOptions: { + parser: tseslintParser, + ...(tsGlobals ? { globals: tsGlobals } : {}), + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project, + }, + }, + plugins: { + '@typescript-eslint': tseslintPlugin, + }, + rules: tsRules, +}); + +const backendConfig = { + ...recommendedJsConfig, + files: [ + 'src/backend/**/*.{js,mjs,cjs,ts}', + 'src/putility/**/*.{js,mjs,cjs,ts}', + ], + ignores: [ + '**/*.test.js', + '**/*.test.ts', + '**/*.test.mts', + ], + languageOptions: { globals: globals.node }, +}; + +const testConfig = { + ...sharedJsConfig, + files: [ + '**/*.test.js', + '**/*.test.ts', + '**/*.test.mts', + ], + languageOptions: { globals: { ...globals.node, ...globals.vitest } }, +}; + +const extensionConfig = { + ...recommendedJsConfig, + files: ['extensions/**/*.{js,mjs,cjs,ts}'], + languageOptions: { + globals: { + extension: 'readonly', + config: 'readonly', + global_config: 'readonly', + ...globals.node, + }, + }, +}; + +const frontendConfig = { + ...recommendedJsConfig, + files: ['**/*.{js,mjs,cjs,ts}', 'src/gui/src/**/*.js'], + ignores: [ + 'src/backend/**/*.{js,mjs,cjs,ts}', + 'extensions/**/*.{js,mjs,cjs,ts}', + 'submodules/**', + '**/*.test.{js,ts,mts,mjs}', + '**/*.min.js', + '**/*.min.cjs', + '**/*.min.mjs', + '**/socket.io.js', + '**/dist/*.js', + 'src/gui/src/lib/**', + 'src/gui/dist/**', + ], + languageOptions: { + globals: { + ...globals.browser, + ...globals.jquery, + i18n: 'readonly', + puter: 'readonly', + }, + }, +}; + +export default defineConfig([ + createTsConfig({ + files: ['**/*.test.ts', '**/*.test.mts', '**/*.test.setup.ts'], + ignores: ['tests/playwright/tests/**/*.ts'], + project: './tests/tsconfig.json', + globals: { ...globals.node, ...globals.vitest }, + }), + createTsConfig({ + files: ['**/*.ts'], + ignores: ['**/*.test.ts', '**/*.test.mts', 'extensions/**/*.ts'], + project: './tsconfig.json', + }), + createTsConfig({ + files: ['extensions/**/*.ts'], + project: './extensions/tsconfig.json', + }), + backendConfig, + testConfig, + extensionConfig, + frontendConfig, +]); diff --git a/eslint/bang-space-if.js b/eslint/bang-space-if.js new file mode 100644 index 0000000000000000000000000000000000000000..95a359ab37cbff25e5b907ac9c95c01bb1318e44 --- /dev/null +++ b/eslint/bang-space-if.js @@ -0,0 +1,69 @@ +// eslint-plugin-bang-space-if/index.js +'use strict'; + +/** @type {import('eslint').ESLint.Plugin} */ +export default { + meta: { + type: 'layout', + docs: { + description: + "Require a space after a top-level '!' in an if(...) condition (e.g., `if ( ! entry )`).", + recommended: false, + }, + fixable: 'whitespace', + schema: [], // no options + }, + create (context) { + const source = context.getSourceCode(); + + // Unwrap ParenthesizedExpression layers, if any + function unwrapParens (node) { + let n = node; + // ESLint/ESTree: ParenthesizedExpression is supported by espree + while ( n && n.type === 'ParenthesizedExpression' ) { + n = n.expression; + } + return n; + } + + return { + IfStatement (ifNode) { + const testRaw = ifNode.test; + if ( ! testRaw ) return; + + const test = unwrapParens(testRaw); + if ( !test || test.type !== 'UnaryExpression' || test.operator !== '!' ) { + return; // only top-level `!` expressions + } + + // Ignore boolean-cast `!!x` cases to avoid producing `! !x` + if ( test.argument && test.argument.type === 'UnaryExpression' && test.argument.operator === '!' ) { + return; + } + + // Grab operator and argument tokens + const opToken = source.getFirstToken(test); // should be '!' + const argToken = source.getTokenAfter(opToken, { includeComments: false }); + if ( !opToken || !argToken ) return; + + // Compute current whitespace between '!' and the argument + const between = source.text.slice(opToken.range[1], argToken.range[0]); + + // We want exactly one space + if ( between === ' ' ) return; + + context.report({ + node: test, + loc: { + start: opToken.loc.end, + end: argToken.loc.start, + }, + message: "Expected a single space after top-level '!' in if(...) condition.", + fix (fixer) { + return fixer.replaceTextRange([opToken.range[1], argToken.range[0]], ' '); + }, + }); + }, + }; + }, +};;;; diff --git a/eslint/control-structure-spacing.js b/eslint/control-structure-spacing.js new file mode 100644 index 0000000000000000000000000000000000000000..a314b08657be73897b0af6af414c43199b722911 --- /dev/null +++ b/eslint/control-structure-spacing.js @@ -0,0 +1,204 @@ +export default { + meta: { + type: 'layout', + docs: { + description: 'enforce spacing inside parentheses for control structures only', + category: 'Stylistic Issues', + }, + fixable: 'whitespace', + schema: [], + messages: { + missingSpaceAfterOpen: 'Missing space after opening parenthesis in control structure.', + missingSpaceBeforeClose: 'Missing space before closing parenthesis in control structure.', + unexpectedSpaceAfterOpen: 'Unexpected space after opening parenthesis in function call.', + unexpectedSpaceBeforeClose: 'Unexpected space before closing parenthesis in function call.', + }, + }, + + create (context) { + const sourceCode = context.getSourceCode(); + + function checkControlStructureSpacing (node) { + // For control structures, we need to find the parentheses around the condition/test + let conditionNode; + + if ( node.type === 'IfStatement' || node.type === 'WhileStatement' || node.type === 'DoWhileStatement' ) { + conditionNode = node.test; + } else if ( node.type === 'ForStatement' || node.type === 'ForInStatement' || node.type === 'ForOfStatement' ) { + // For loops, we want the parentheses around the entire for clause + conditionNode = node; + } else if ( node.type === 'SwitchStatement' ) { + conditionNode = node.discriminant; + } else if ( node.type === 'CatchClause' ) { + conditionNode = node.param; + } + + if ( ! conditionNode ) return; + + // Find the opening paren - it should be right before the condition starts + const openParen = sourceCode.getTokenBefore(conditionNode, token => token.value === '('); + if ( !openParen || openParen.value !== '(' ) return; + + // Find the closing paren - it should be right after the condition ends + const closeParen = sourceCode.getTokenAfter(conditionNode, token => token.value === ')'); + if ( !closeParen || closeParen.value !== ')' ) return; + + const afterOpen = sourceCode.getTokenAfter(openParen); + const beforeClose = sourceCode.getTokenBefore(closeParen); + + { + const contentBetweenParens = sourceCode.getText().slice(openParen.range[1], closeParen.range[0]); + const isSingleCharVariable = /^\s*[a-zA-Z_$]\s*$/.test(contentBetweenParens); + + // Skip spacing requirements for single character variables + if ( isSingleCharVariable ) { + return; + } + } + + // Control structures should have spacing + if ( afterOpen && openParen.range[1] === afterOpen.range[0] ) { + context.report({ + node, + loc: openParen.loc, + messageId: 'missingSpaceAfterOpen', + fix (fixer) { + return fixer.insertTextAfter(openParen, ' '); + }, + }); + } + + if ( beforeClose && beforeClose.range[1] === closeParen.range[0] ) { + context.report({ + node, + loc: closeParen.loc, + messageId: 'missingSpaceBeforeClose', + fix (fixer) { + return fixer.insertTextBefore(closeParen, ' '); + }, + }); + } + } + + function checkForLoopSpacing (node) { + // For loops are special - we need to find the opening paren after the 'for' keyword + // and the closing paren before the body + const forKeyword = sourceCode.getFirstToken(node); + if ( !forKeyword || forKeyword.value !== 'for' ) return; + + const openParen = sourceCode.getTokenAfter(forKeyword, token => token.value === '('); + if ( ! openParen ) return; + + // The closing paren should be right before the body + const closeParen = sourceCode.getTokenBefore(node.body, token => token.value === ')'); + if ( ! closeParen ) return; + + const afterOpen = sourceCode.getTokenAfter(openParen); + const beforeClose = sourceCode.getTokenBefore(closeParen); + + if ( afterOpen && openParen.range[1] === afterOpen.range[0] ) { + context.report({ + node, + loc: openParen.loc, + messageId: 'missingSpaceAfterOpen', + fix (fixer) { + return fixer.insertTextAfter(openParen, ' '); + }, + }); + } + + if ( beforeClose && beforeClose.range[1] === closeParen.range[0] ) { + context.report({ + node, + loc: closeParen.loc, + messageId: 'missingSpaceBeforeClose', + fix (fixer) { + return fixer.insertTextBefore(closeParen, ' '); + }, + }); + } + } + + function checkFunctionCallSpacing (node) { + // Find the opening parenthesis for this function call + const openParen = sourceCode.getFirstToken(node, token => token.value === '('); + const closeParen = sourceCode.getLastToken(node, token => token.value === ')'); + + if ( !openParen || !closeParen ) return; + + const afterOpen = sourceCode.getTokenAfter(openParen); + const beforeClose = sourceCode.getTokenBefore(closeParen); + + // Function calls should NOT have spacing + if ( afterOpen && openParen.range[1] !== afterOpen.range[0] ) { + const spaceAfter = sourceCode.getText().slice(openParen.range[1], afterOpen.range[0]); + if ( /^\s+$/.test(spaceAfter) ) { + context.report({ + node, + loc: openParen.loc, + messageId: 'unexpectedSpaceAfterOpen', + fix (fixer) { + return fixer.removeRange([openParen.range[1], afterOpen.range[0]]); + }, + }); + } + } + + if ( beforeClose && beforeClose.range[1] !== closeParen.range[0] ) { + const spaceBefore = sourceCode.getText().slice(beforeClose.range[1], closeParen.range[0]); + if ( /^\s+$/.test(spaceBefore) ) { + context.report({ + node, + loc: closeParen.loc, + messageId: 'unexpectedSpaceBeforeClose', + fix (fixer) { + return fixer.removeRange([beforeClose.range[1], closeParen.range[0]]); + }, + }); + } + } + } + + return { + // Control structures that should have spacing + IfStatement (node) { + checkControlStructureSpacing(node); + }, + WhileStatement (node) { + checkControlStructureSpacing(node); + }, + DoWhileStatement (node) { + checkControlStructureSpacing(node); + }, + SwitchStatement (node) { + checkControlStructureSpacing(node); + }, + CatchClause (node) { + if ( node.param ) { + checkControlStructureSpacing(node); + } + }, + + // For loops need special handling + ForStatement (node) { + checkForLoopSpacing(node); + }, + ForInStatement (node) { + checkForLoopSpacing(node); + }, + ForOfStatement (node) { + checkForLoopSpacing(node); + }, + + // Function calls that should NOT have spacing + CallExpression (node) { + checkFunctionCallSpacing(node); + }, + NewExpression (node) { + if ( node.arguments.length > 0 || sourceCode.getLastToken(node).value === ')' ) { + checkFunctionCallSpacing(node); + } + }, + }; + }, +}; \ No newline at end of file diff --git a/eslint/mandatory.eslint.config.js b/eslint/mandatory.eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..27e2befccb7c2bb12b1d9e2f3a42e464603d9d17 --- /dev/null +++ b/eslint/mandatory.eslint.config.js @@ -0,0 +1,86 @@ +import tseslintPlugin from '@typescript-eslint/eslint-plugin'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; + +const backendLanguageOptions = { + globals: { + // Current, intentionally supported globals + extension: 'readonly', + config: 'readonly', + global_config: 'readonly', + + // Older not entirely ideal globals + use: 'readonly', // <-- older import mechanism + def: 'readonly', // <-- older import mechanism + kv: 'readonly', // <-- should be passed/imported + ll: 'readonly', // <-- questionable + + // Language/environment globals + ...globals.node, + }, +}; + +const mandatoryRules = { + 'no-undef': 'error', + 'no-use-before-define': ['error', { + 'functions': false, + }], + 'no-invalid-this': 'error', +}; + +export default defineConfig([ + { + ignores: [ + 'src/backend/src/modules/apps/AppInformationService.js', // TEMPORARY - SHOULD BE FIXED! + 'src/backend/src/services/worker/WorkerService.js', // TEMPORARY - SHOULD BE FIXED! + 'src/backend/src/public/**/*', // We may be able to delete this! I don't think it's used + + // These files run in the worker environment, so these rules don't apply + 'src/backend/src/services/worker/dist/**/*.{js,cjs,mjs}', + 'src/backend/src/services/worker/src/**/*.{js,cjs,mjs}', + 'src/backend/src/services/worker/template/puter-portable.js', + ], + }, + { + plugins: { + '@typescript-eslint': tseslintPlugin, + }, + }, + { + files: [ + 'src/backend/**/*.{js,mjc,cjs}', + 'extensions/**/*.{js,mjc,cjs}', + ], + ignores: [ + 'src/backend/src/services/database/sqlite_setup/**/*.js', + ], + rules: mandatoryRules, + languageOptions: { + ...backendLanguageOptions, + }, + }, + { + files: [ + 'src/backend/src/services/database/sqlite_setup/**/*.js', + ], + rules: mandatoryRules, + languageOptions: { + globals: { + read: 'readonly', + write: 'readonly', + log: 'readonly', + ...globals.node, + }, + }, + }, + { + files: [ + 'src/backend/**/*.{ts}', + 'extensions/**/*.{ts}', + ], + rules: mandatoryRules, + languageOptions: { + ...backendLanguageOptions, + }, + }, +]); diff --git a/eslint/space-unary-ops-with-exception.js b/eslint/space-unary-ops-with-exception.js new file mode 100644 index 0000000000000000000000000000000000000000..9deecf82fde4b3244829afc441034473b931b34f --- /dev/null +++ b/eslint/space-unary-ops-with-exception.js @@ -0,0 +1,37 @@ +import ruleComposer from 'eslint-rule-composer'; + +// Adjust this require to match the package you use for the rule. +// For eslint-stylistic v2+ the package is "@stylistic/eslint-plugin" +import stylistic from '@stylistic/eslint-plugin'; +const baseRule = stylistic.rules['space-unary-ops']; + +// unwrap nested parentheses +function unwrapParens (node) { + let n = node; + while ( n && n.type === 'ParenthesizedExpression' ) n = n.expression; + return n; +} + +function isTopLevelBangInIfTest (node) { + if ( !node || node.type !== 'UnaryExpression' || node.operator !== '!' ) return false; + + // Walk up through ancestors manually using .parent (safe in ESLint) + let current = node; + let parent = current.parent; + + // Skip ParenthesizedExpression layers + while ( parent && parent.type === 'ParenthesizedExpression' ) { + current = parent; + parent = parent.parent; + } + + return parent && parent.type === 'IfStatement' && unwrapParens(parent.test) === node; +} + +// Filter out ONLY the reports for top-level ! inside if(...) condition +export default ruleComposer.filterReports(baseRule, (problem, context) => { + const { node } = problem; + // If this particular report is about a top-level ! in an if(...) test, + // suppress it. Otherwise, keep the original report. + return !isTopLevelBangInIfTest(node, context); +}); diff --git a/exports.js b/exports.js new file mode 100644 index 0000000000000000000000000000000000000000..87897bb6de786fed0f3490580fd950d4f7929081 --- /dev/null +++ b/exports.js @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import backend from '@heyputer/backend'; +export default backend; diff --git a/extensions/.gitkeep b/extensions/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000000000000000000000000000000000000..40ceea710a854014a9e7f94fff88ce53c6a3377a --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,11 @@ +# Extension System Development Guide] + +## Where to find documentation + +### Here +Documentation for extensions is [here](src/backend/doc/extensions/README.md). + +### Not Here + +Outdated documentation for extensions is [here](../doc/contributors/extensions/README.md). +This documentation may include some topics that are missing from the current documentation. Eventually those topics should be updated and transferred to the current documentation so that this documentation may be removed. diff --git a/extensions/api.d.ts b/extensions/api.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2ea4717cf0b43477f23e295abf264e4adf508f9 --- /dev/null +++ b/extensions/api.d.ts @@ -0,0 +1,90 @@ +import type { WebServerService } from '@heyputer/backend/src/modules/web/WebServerService.js'; +import type { Actor } from '@heyputer/backend/src/services/auth/Actor.js'; +import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.d.ts'; +import type { MeteringService } from '@heyputer/backend/src/services/MeteringService/MeteringService.ts'; +import type { MeteringServiceWrapper } from '@heyputer/backend/src/services/MeteringService/MeteringServiceWrapper.mjs'; +import type { DBKVStore } from '@heyputer/backend/src/services/repositories/DBKVStore/DBKVStore.ts'; +import type { SUService } from '@heyputer/backend/src/services/SUService.js'; +import type { IUser } from '@heyputer/backend/src/services/User.js'; +import type { UserService } from '@heyputer/backend/src/services/UserService.d.ts'; +import type { RequestHandler } from 'express'; +import type FSNodeContext from '../src/backend/src/filesystem/FSNodeContext.js'; +import type helpers from '../src/backend/src/helpers.js'; +import type * as ExtensionControllerExports from './ExtensionController/src/ExtensionController.ts'; +declare global { + namespace Express { + interface Request { + services: { get: (string: T) => T extends keyof ServiceNameMap ? ServiceNameMap[T] : unknown } + actor: Actor, + rawBody: Buffer, + /** @deprecated use actor instead */ + user: IUser + } + } +} + +interface EndpointOptions { + allowedMethods?: string[] + subdomain?: string + noauth?: boolean + mw?: RequestHandler[] + otherOpts?: Record & { + json?: boolean + noReallyItsJson?: boolean + } +} + +type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch'; + +export type AddRouteFunction = (path: string, options: EndpointOptions, handler: RequestHandler) => void; + +export type RouterMethods = { + [K in HttpMethod]: { + (path: string, options: EndpointOptions, handler: RequestHandler): void; + (path: string, handler: RequestHandler, options?: EndpointOptions): void; + }; +}; + +interface CoreRuntimeModule { + util: { + helpers: typeof helpers, + } +} + +interface FilesystemModule { + FSNodeContext: FSNodeContext, + selectors: unknown, +} + +type StripPrefix = T extends `${TPrefix}.${infer R}` ? R : never; +// TODO DS: define this globally in core to use it there too +interface ServiceNameMap { + 'meteringService': Pick & MeteringService // TODO DS: squash into a single class without wrapper + 'puter-kvstore': DBKVStore + 'su': SUService + 'database': BaseDatabaseAccessService + 'user': UserService + 'web-server': WebServerService +} +interface Extension extends RouterMethods { + exports: Record, + span: ((label: string, fn: () => T) => () => T) & { + run(label: string, fn: () => T): T; + run(fn: () => T): T; + }, + on(event: string, listener: (...args: T) => void): void, // TODO DS: type events better + import(module: 'data'): { db: BaseDatabaseAccessService, kv: DBKVStore, cache: unknown }// TODO DS: type cache better + import(module: 'core'): CoreRuntimeModule, + import(module: 'fs'): FilesystemModule, + import(module: 'extensionController'): typeof ExtensionControllerExports + import(module: T): T extends `service:${infer R extends keyof ServiceNameMap}` + ? ServiceNameMap[R] + : unknown; +} + +declare global { + // Declare the extension variable + const extension: Extension; + const config: Record; + const global_config: Record; +} diff --git a/extensions/data.js b/extensions/data.js new file mode 100644 index 0000000000000000000000000000000000000000..8d162f6ae568bfc219b19cc74e82f22a90a03ff0 --- /dev/null +++ b/extensions/data.js @@ -0,0 +1,32 @@ +//@extension priority -10000 + +const { DB_WRITE } = extension.import('core').database; +const svc_database = extension.import('service:database'); +const svc_kvstore = extension.import('service:puter-kvstore'); + +// Methods on the object from `.as()` come from TraitsFeature.js, +// and they are already bound to their respective instance. +const simplified_kv = { ...svc_kvstore.as('puter-kvstore') }; + +const original_get = simplified_kv.get; +const original_set = simplified_kv.set; + +simplified_kv.get = (...a) => { + if ( typeof a[0] === 'string' ) { + return original_get({ key: a[0] }); + } + return original_get(...a); +}; + +simplified_kv.set = (...a) => { + if ( typeof a[0] === 'string' ) { + return original_set({ key: a[0], value: a[1] }); + } + return original_set(...a); +}; + +extension.exports = { + db: svc_database.get(DB_WRITE, 'extensions'), + kv: simplified_kv, + cache: kv, +}; diff --git a/extensions/example-kv.js b/extensions/example-kv.js new file mode 100644 index 0000000000000000000000000000000000000000..dd391d10ef3c2b81636f18a518b2261ee592abd0 --- /dev/null +++ b/extensions/example-kv.js @@ -0,0 +1,30 @@ +const { kv } = extension.import('data'); +const { sleep } = extension.import('utilities'); + +// "kv" is load ready to use before the 'init' event is fired. +extension.on('init', async () => { + kv.set('example-kv-key', 'example-kv-value'); + + console.log('kv key has', await kv.get('example-kv-key')); + + await kv.expire({ + key: 'example-kv-key', + ttl: 1000 * 60, // 1 minute + }); + + // This AIIFE demonstrates how "kv.expire" works. + // We cannot simply "await" this - otherwise we block init! + (async () => { + // wait for 30 seconds... + await sleep(30 * 1000); + + console.log('kv key still has value', await kv.get('example-kv-key')); + + // wait for 30 more seconds + await sleep(30 * 1000); + // and just a little bit longer + // await sleep(100); + + console.log('kv key should no longer have the value', await kv.get('example-kv-key')); + })(); +}); diff --git a/extensions/exports_something.js b/extensions/exports_something.js new file mode 100644 index 0000000000000000000000000000000000000000..f244b21ed33b9109d8be6c216758d7d06961b1ee --- /dev/null +++ b/extensions/exports_something.js @@ -0,0 +1,11 @@ +//@puter priority -1 +console.log('exporting something...'); +extension.exports = { + testval: 5, +}; + +extension.on('init', () => { + extension.emit('hello', { + from: 'exports_something', + }); +}); diff --git a/extensions/extension-util.js b/extensions/extension-util.js new file mode 100644 index 0000000000000000000000000000000000000000..519fb53c63d33ccbc22273bb9e5a6deb750e594c --- /dev/null +++ b/extensions/extension-util.js @@ -0,0 +1,32 @@ +//@extension name extension +const { Context } = extension.import('core'); + +// The 'create.commands' event is fired by CommandService +extension.on('create.commands', event => { + + // Add command to list available extensions + event.createCommand('list', { + description: 'list available extensions', + handler: async (_, console) => { + + // Get extnsion information from context + const extensionInfos = Context.get('extensionInfo'); + + // Iterate over extension infos + for ( const info of Object.values(extensionInfos) ) { + + // Construct a string + const moduleType = info.type === 'module' + ? '\x1B[32;1m(ESM)\x1B[0m' + : '\x1B[33;1m(CJS)\x1B[0m'; + let str = `- ${info.name} ${moduleType}`; + if ( info.priority !== 0 ) { + str += ` (priority ${info.priority})`; + } + + // Print a string + console.log(str); + } + }, + }); +}); diff --git a/extensions/hellodriver/config.json b/extensions/hellodriver/config.json new file mode 100644 index 0000000000000000000000000000000000000000..91ad40092ba0158ca865ead0fd3b4341b8ece7fb --- /dev/null +++ b/extensions/hellodriver/config.json @@ -0,0 +1,3 @@ +{ + "test": "yes I am a test" +} \ No newline at end of file diff --git a/extensions/hellodriver/hellodriver.js b/extensions/hellodriver/hellodriver.js new file mode 100644 index 0000000000000000000000000000000000000000..1ee1549585a8de62e4387f6c692e0acc7b6432e2 --- /dev/null +++ b/extensions/hellodriver/hellodriver.js @@ -0,0 +1,133 @@ +const { kv } = extension.import('data'); + +const span = extension.span; + +/** + * Here we create an interface called 'hello-world'. This interface + * specifies that any implementation of 'hello-world' should implement + * a method called `greet`. The greet method has a couple of optional + * parameters including `subject` and `locale`. The `locale` parameter + * is not implemented by the driver implementation in the proceeding + * definition, showing how driver implementations don't always need + * to support optional features. + * + * subject: the person to greet + * locale: a standard locale string (ex: en_US.UTF-8) + */ +extension.on('create.interfaces', event => { + event.createInterface('hello-world', { + description: 'Provides methods for generating greetings', + methods: { + greet: { + description: 'Returns a greeting', + parameters: { + subject: { + type: 'string', + optional: true, + }, + locale: { + type: 'string', + optional: true, + }, + }, + }, + }, + }); +}); + +/** + * Here we register an implementation of the `hello-world` driver + * interface. This implementation is called "no-frills" which is + * the most basic reasonable implementation of the interface. The + * default return value is "Hello, World!", but if subject is + * provided it will be "Hello, !". + * + * This implementation can be called from puter.js like this: + * + * await puter.call('hello-world', 'no-frills', 'greet', { subject: 'Dave' }); + * + * If you get an authorization error it's because the user you're + * logged in as does not have permission to invoke the `no-frills` + * implementation of `hello-world`. Users must be granted the following + * permission to access this driver: + * + * service:no-frills:ii:hello-world + * + * The value of `` can be one of many "special" values + * to demonstrate capabilities of drivers or extensions, including: + * - `%fail%`: simulate an error response from a driver + * - `%config%`: return the effective configuration object + */ +extension.on('create.drivers', event => { + event.createDriver('hello-world', 'no-frills', { + greet ({ subject }) { + return `Hello, ${subject ?? 'World'}!`; + }, + }); +}); + +extension.on('create.drivers', event => { + event.createDriver('hello-world', 'slow-hello', { + greet: span('slow-hello:greet', async ({ subject }) => { + await new Promise(rslv => setTimeout(rslv, 1000)); + await span.run(async () => { + await new Promise(rslv => setTimeout(rslv, 1000)); + }); + await new Promise(rslv => setTimeout(rslv, 1000)); + return `Hello, ${subject ?? 'World'}!`; + }), + }); +}); + +extension.on('create.drivers', event => { + event.createDriver('hello-world', 'extension-examples', { + greet ({ subject }) { + if ( subject === 'fail' ) { + throw new Error('failing on purpose'); + } + if ( subject === 'config' ) { + return JSON.stringify(config ?? null); + } + + const STR_KVSET = 'kv-set:'; + if ( subject.startsWith(STR_KVSET) ) { + return kv.set({ + key: 'extension-examples-test-key', + value: subject.slice(STR_KVSET.length), + }); + } + if ( subject === 'kv-get' ) { + return kv.get({ + key: 'extension-examples-test-key', + }); + } + + /* eslint-disable */ + const STR_KVSET2 = 'kv-set-2:'; + if ( subject.startsWith(STR_KVSET2) ) { + return kv.set( + 'extension-examples-test-key', + subject.slice(STR_KVSET2.length), + ); + } + if ( subject === 'kv-get-2' ) { + return kv.get( + 'extension-examples-test-key', + ); + } + /* eslint-enable */ + + return `Hello, ${subject ?? 'World'}!`; + }, + }); +}); + +/** + * Here we specify that both registered and temporary users are allowed + * to access the `no-frills` implementation of the `hello-world` driver. + */ +extension.on('create.permissions', event => { + event.grant_to_everyone('service:no-frills:ii:hello-world'); + event.grant_to_everyone('service:slow-hello:ii:hello-world'); + event.grant_to_everyone('service:extension-examples:ii:hello-world'); +}); diff --git a/extensions/hellodriver/package.json b/extensions/hellodriver/package.json new file mode 100644 index 0000000000000000000000000000000000000000..516d5d5ec9e4c91784fb752ee511911663ea3898 --- /dev/null +++ b/extensions/hellodriver/package.json @@ -0,0 +1,5 @@ +{ + "name": "hellodriver", + "main": "hellodriver.js", + "type": "module" +} \ No newline at end of file diff --git a/extensions/imports_something.js b/extensions/imports_something.js new file mode 100644 index 0000000000000000000000000000000000000000..041eff470ee5ec134918b08a497d8a0d9ed623af --- /dev/null +++ b/extensions/imports_something.js @@ -0,0 +1,7 @@ +console.log('importing something...'); +const { testval } = extension.import('exports_something'); +console.log(testval); + +extension.on('hello', event => { + console.log(`received "hello" from: ${event.from}`); +}); diff --git a/extensions/jsconfig.json b/extensions/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..791b319a2dfd658c08b12c00b231baa18309f070 --- /dev/null +++ b/extensions/jsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "node16", + "moduleResolution": "node16", + "rootDir": ".", + "paths": { + "../src/*": [ + "../src/*" + ] + }, + "allowJs": true, + "checkJs": true + }, + "include": [ + "./**/*.js", + "./**/*.d.ts" + ] +} \ No newline at end of file diff --git a/extensions/metering/config.json b/extensions/metering/config.json new file mode 100644 index 0000000000000000000000000000000000000000..c4a904ba07b983e450ec692c3de8f87241dc90d4 --- /dev/null +++ b/extensions/metering/config.json @@ -0,0 +1,10 @@ +{ + "unlimitedUsage": false, + "unlimitedAllowList": [ + "admin" + ], + "allowedGlobalUsageUsers": [ + "06ab2f87-aef5-441b-9c60-debbb8d24dda", + "d8fd169b-4e93-484a-bd84-115b5a2f0ed4" + ] +} \ No newline at end of file diff --git a/extensions/metering/eventListeners/subscriptionEvents.js b/extensions/metering/eventListeners/subscriptionEvents.js new file mode 100644 index 0000000000000000000000000000000000000000..1240c875ed5a5d521a74b081e9082dd85dd9ee2c --- /dev/null +++ b/extensions/metering/eventListeners/subscriptionEvents.js @@ -0,0 +1,30 @@ +extension.on('metering:overrideDefaultSubscription', async (/** @type {{actor: import('@heyputer/backend/src/services/auth/Actor').Actor, defaultSubscription: string}} */event) => { + // bit of a stub implementation for OSS, technically can be always free if you set this config true + if ( config.unlimitedUsage ) { + console.warn('WARNING!!! unlimitedUsage is enabled, this is not recommended for production use'); + event.defaultSubscriptionId = 'unlimited'; + } +}); + +extension.on('metering:registerAvailablePolicies', async ( + /** @type {{actor: import('@heyputer/backend/src/services/auth/Actor').Actor, availablePolicies: unknown[]}} */event) => { + // bit of a stub implementation for OSS, technically can be always free if you set this config true + if ( config.unlimitedUsage || config.unlimitedAllowList?.length ) { + event.availablePolicies.push({ + id: 'unlimited', + monthUsageAllowance: 5_000_000 * 1_000_000 * 100, // unless you're like, jeff's, mark's, and elon's illegitamate son, you probably won't hit $5m a month + monthlyStorageAllowance: 100_000 * 1024 * 1024, // 100MiB but ignored in local dev + }); + } +}); + +extension.on('metering:getUserSubscription', async (/** @type {{actor: import('@heyputer/backend/src/services/auth/Actor').Actor, userSubscriptionId: string}} */event) => { + const userName = event?.actor?.type?.user?.username; + if ( config.unlimitedAllowList?.includes(userName) ) { + event.userSubscriptionId; + } + else { + event.userSubscriptionId = event?.actor?.type?.user?.subscription?.active ? event.actor.type.user.subscription?.tier : undefined; + } + // default location for user sub, but can techinically be anywhere else or fetched on request +}); diff --git a/extensions/metering/main.js b/extensions/metering/main.js new file mode 100644 index 0000000000000000000000000000000000000000..6be541e9707d8f3f7e5d3535aaa5479f5bbebb46 --- /dev/null +++ b/extensions/metering/main.js @@ -0,0 +1,2 @@ +import './eventListeners/subscriptionEvents.js'; +import './routes/usage.js'; diff --git a/extensions/metering/package.json b/extensions/metering/package.json new file mode 100644 index 0000000000000000000000000000000000000000..b74a50e54269198f059bb936b039f54fdf870417 --- /dev/null +++ b/extensions/metering/package.json @@ -0,0 +1,5 @@ +{ + "name": "@heyputer/extension-metering-service", + "main": "main.js", + "type": "module" +} \ No newline at end of file diff --git a/extensions/metering/routes/usage.js b/extensions/metering/routes/usage.js new file mode 100644 index 0000000000000000000000000000000000000000..25b6bc5fefcace22db6185cd1ba7907d449492f7 --- /dev/null +++ b/extensions/metering/routes/usage.js @@ -0,0 +1,56 @@ +const meteringServiceWrapper = extension.import('service:meteringService'); + +// TODO DS: move this to its own router and just use under this path +extension.get('/metering/usage', { subdomain: 'api' }, async (req, res) => { + const meteringService = meteringServiceWrapper.meteringService; + + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + const actorUsagePromise = meteringService.getActorCurrentMonthUsageDetails(actor); + const actorAllowanceInfoPromise = meteringService.getAllowedUsage(actor); + + const [actorUsage, allowanceInfo] = await Promise.all([actorUsagePromise, actorAllowanceInfoPromise]); + res.status(200).json({ ...actorUsage, allowanceInfo }); + return; +}); + +extension.get('/metering/usage/:appId', { subdomain: 'api' }, async (req, res) => { + const meteringService = meteringServiceWrapper.meteringService; + + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + const appId = req.params.appId; + if ( ! appId ) { + res.status(400).json({ error: 'appId parameter is required' }); + return; + } + + const appUsage = await meteringService.getActorCurrentMonthAppUsageDetails(actor, appId); + res.status(200).json(appUsage); + return; +}); + +extension.get('/metering/globalUsage', { subdomain: 'api' }, async (req, res) => { + const meteringService = meteringServiceWrapper.meteringService; + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + + // check if actor is allowed to view global usage + const allowedUsers = extension.config.allowedGlobalUsageUsers || []; + if ( ! allowedUsers.includes(actor.type?.user.uuid) ) { + res.status(403).json({ error: 'You are not authorized to view global usage' }); + return; + } + + const globalUsage = await meteringService.getGlobalUsage(); + res.status(200).json(globalUsage); + return; +}); + +console.debug('Loaded /metering/usage route'); \ No newline at end of file diff --git a/extensions/puterfs/PuterFSProvider.js b/extensions/puterfs/PuterFSProvider.js new file mode 100644 index 0000000000000000000000000000000000000000..b457b80b388db0d3f16e6d948fdb5c150d367398 --- /dev/null +++ b/extensions/puterfs/PuterFSProvider.js @@ -0,0 +1,1009 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const STUCK_STATUS_TIMEOUT = 10 * 1000; +const STUCK_ALARM_TIMEOUT = 20 * 1000; + +import crypto from 'node:crypto'; +import path_ from 'node:path'; +import { v4 as uuidv4 } from 'uuid'; + +const { db } = extension.import('data'); + +const svc_metering = extension.import('service:meteringService'); +const svc_trace = extension.import('service:traceService'); +const svc_fs = extension.import('service:filesystem'); +const { stuck_detector_stream, hashing_stream } = extension.import('core').util.streamutil; + +// TODO: filesystem providers should not need to call EventService +const svc_event = extension.import('service:event'); + +// TODO: filesystem providers REALLY SHOULD NOT implement ACL logic! +const svc_acl = extension.import('service:acl'); + +// TODO: these services ought to be part of this extension +const svc_size = extension.import('service:sizeService'); +const svc_resource = extension.import('service:resourceService'); + +// Not sure where these really belong yet +const svc_fileCache = extension.import('service:file-cache'); + +// TODO: depending on mountpoint service will not be necessary +// once the storage provider is moved to this extension +const svc_mountpoint = extension.import('service:mountpoint'); + +const { + APIError, + Actor, + Context, + UserActorType, + TDetachable, + MultiDetachable, +} = extension.import('core'); + +const { + get_user, +} = extension.import('core').util.helpers; + +const { + ParallelTasks, +} = extension.import('core').util.otelutil; + +const { + TYPE_DIRECTORY, +} = extension.import('core').fs; + +const { + NodeChildSelector, + NodeUIDSelector, + NodeInternalIDSelector, +} = extension.import('core').fs.selectors; + +const { + FSNodeContext, + capabilities, +} = extension.import('fs'); + +const { + // MODE_READ, + MODE_WRITE, +} = extension.import('fs').lock; + +// ^ Yep I know, import('fs') and import('core').fs is confusing and +// redundant... this will be cleaned up as the new API is developed + +const { + // MODE_READ, + RESOURCE_STATUS_PENDING_CREATE, +} = extension.import('fs').resource; + +const { + UploadProgressTracker, +} = extension.import('fs').util; + +export default class PuterFSProvider { + constructor ({ fsEntryController, storageController }) { + this.fsEntryController = fsEntryController; + this.storageController = storageController; + this.name = 'puterfs'; + } + + // TODO: should this be a static member instead? + get_capabilities () { + return new Set([ + capabilities.THUMBNAIL, + capabilities.UPDATE_THUMBNAIL, + capabilities.UUID, + capabilities.OPERATION_TRACE, + capabilities.READDIR_UUID_MODE, + capabilities.PUTER_SHORTCUT, + + capabilities.COPY_TREE, + capabilities.GET_RECURSIVE_SIZE, + + capabilities.READ, + capabilities.WRITE, + capabilities.CASE_SENSITIVE, + capabilities.SYMLINK, + capabilities.TRASH, + ]); + } + + // #region PuterOnly + async update_thumbnail ({ context, node, thumbnail }) { + const { + actor: inputActor, + } = context.values; + const actor = inputActor ?? Context.get('actor'); + + context = context ?? Context.get(); + const services = context.get('services'); + + // TODO: this ACL check should not be here, but there's no LL method yet + // and it's possible we will never implement the thumbnail + // capability for any other filesystem type + + const svc_acl = services.get('acl'); + if ( ! await svc_acl.check(actor, node, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, node, 'write'); + } + + const uid = await node.get('uid'); + + const entryOp = await this.fsEntryController.update(uid, { + thumbnail, + }); + + (async () => { + await entryOp.awaitDone(); + svc_event.emit('fs.write.file', { + node, + context, + }); + })(); + + return node; + } + + async puter_shortcut ({ parent, name, user, target }) { + await target.fetchEntry({ thumbnail: true }); + + const ts = Math.round(Date.now() / 1000); + const uid = uuidv4(); + + svc_resource.register({ + uid, + status: RESOURCE_STATUS_PENDING_CREATE, + }); + + const raw_fsentry = { + is_shortcut: 1, + shortcut_to: target.mysql_id, + is_dir: target.entry.is_dir, + thumbnail: target.entry.thumbnail, + uuid: uid, + parent_uid: await parent.get('uid'), + path: path_.join(await parent.get('path'), name), + user_id: user.id, + name, + created: ts, + updated: ts, + modified: ts, + immutable: false, + }; + + const entryOp = await this.fsEntryController.insert(raw_fsentry); + + (async () => { + await entryOp.awaitDone(); + svc_resource.free(uid); + })(); + + const node = await svc_fs.node(new NodeUIDSelector(uid)); + + svc_event.emit('fs.create.shortcut', { + node, + context: Context.get(), + }); + + return node; + } + // #endregion + + // #region Standard FS + + /** + * Check if a given node exists. + * + * @param {Object} param + * @param {NodeSelector} param.selector - The selector used for checking. + * @returns {Promise} - True if the node exists, false otherwise. + */ + async quick_check ({ + selector, + }) { + // shortcut: has full path + if ( selector?.path ) { + const entry = await this.fsEntryController.findByPath(selector.path); + return Boolean(entry); + } + + // shortcut: has uid + if ( selector?.uid ) { + const entry = await this.fsEntryController.findByUID(selector.uid); + return Boolean(entry); + } + + // shortcut: parent uid + child name + if ( selector instanceof NodeChildSelector && selector.parent instanceof NodeUIDSelector ) { + return await this.fsEntryController.nameExistsUnderParent(selector.parent.uid, + selector.name); + } + + // shortcut: parent id + child name + if ( selector instanceof NodeChildSelector && selector.parent instanceof NodeInternalIDSelector ) { + return await this.fsEntryController.nameExistsUnderParentID(selector.parent.id, + selector.name); + } + + return false; + } + + async unlink ({ context, node, options = {} }) { + if ( await node.get('type') === TYPE_DIRECTORY ) { + throw new APIError(409, 'Cannot unlink a directory.'); + } + + await this.#rmnode({ context, node, options }); + } + + async rmdir ({ context, node, options = {} }) { + if ( await node.get('type') !== TYPE_DIRECTORY ) { + throw new APIError(409, 'Cannot rmdir a file.'); + } + + if ( await node.get('immutable') ) { + throw APIError.create('immutable'); + } + + const children = await this.fsEntryController.fast_get_direct_descendants(await node.get('uid')); + + if ( children.length > 0 && !options.ignore_not_empty ) { + throw APIError.create('not_empty'); + } + + await this.#rmnode({ context, node, options }); + } + + /** + * Create a new directory. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNode} param.parent + * @param {string} param.name + * @param {boolean} param.immutable + * @returns {Promise} + */ + async mkdir ({ context, parent, name, immutable }) { + const { actor, thumbnail } = context.values; + + const ts = Math.round(Date.now() / 1000); + const uid = uuidv4(); + + const existing = await svc_fs.node(new NodeChildSelector(parent.selector, name)); + + if ( await existing.exists() ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: name, + }); + } + + if ( ! await parent.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + svc_resource.register({ + uid, + status: RESOURCE_STATUS_PENDING_CREATE, + }); + + const raw_fsentry = { + is_dir: 1, + uuid: uid, + parent_uid: await parent.get('uid'), + path: path_.join(await parent.get('path'), name), + user_id: actor.type.user.id, + name, + created: ts, + accessed: ts, + modified: ts, + immutable: immutable ?? false, + ...(thumbnail ? { + thumbnail: thumbnail, + } : {}), + }; + + console.log('raw fsentry', raw_fsentry); + const entryOp = await this.fsEntryController.insert(raw_fsentry); + + await entryOp.awaitDone(); + svc_resource.free(uid); + + const node = await svc_fs.node(new NodeUIDSelector(uid)); + + svc_event.emit('fs.create.directory', { + node, + context: Context.get(), + }); + + return node; + } + + async read ({ context, node, version_id, range }) { + const svc_mountpoint = context.get('services').get('mountpoint'); + const storage = svc_mountpoint.get_storage(this.constructor.name); + const location = await node.get('s3:location') ?? {}; + const stream = (await storage.create_read_stream(await node.get('uid'), { + // TODO: fs:decouple-s3 + bucket: location.bucket, + bucket_region: location.bucket_region, + version_id, + key: location.key, + memory_file: node.entry, + ...(range ? { range } : {}), + })); + return stream; + } + + async stat ({ + selector, + options, + controls, + node, + }) { + // For Puter FS nodes, we assume we will obtain all properties from + // fsEntryController, except for 'thumbnail' unless it's + // explicitly requested. + + if ( options.tracer == null ) { + options.tracer = svc_trace.tracer; + } + + if ( options.op ) { + options.trace_options = { + parent: options.op.span, + }; + } + + let entry; + + await new Promise (rslv => { + const detachables = new MultiDetachable(); + + const callback = (_resolver) => { + detachables.as(TDetachable).detach(); + rslv(); + }; + + // either the resource is free + { + // no detachale because waitForResource returns a + // Promise that will be resolved when the resource + // is free no matter what, and then it will be + // garbage collected. + svc_resource.waitForResource(selector).then(callback.bind(null, 'resourceService')); + } + + // or pending information about the resource + // becomes available + { + // detachable is needed here because waitForEntry keeps + // a map of listeners in memory, and this event may + // never occur. If this never occurs, waitForResource + // is guaranteed to resolve eventually, and then this + // detachable will be detached by `callback` so the + // listener can be garbage collected. + const det = this.fsEntryController.waitForEntry(node, callback.bind(null, 'fsEntryService')); + if ( det ) detachables.add(det); + } + }); + + const maybe_uid = node.uid; + if ( svc_resource.getResourceInfo(maybe_uid) ) { + entry = await this.fsEntryController.get(maybe_uid, options); + controls.log.debug('got an entry from the future'); + } else { + entry = await this.fsEntryController.find(selector, options); + } + + if ( ! entry ) { + if ( this.log_fsentriesNotFound ) { + controls.log.warn(`entry not found: ${selector.describe(true)}`); + } + } + + if ( entry === null || typeof entry !== 'object' ) { + return null; + } + + if ( entry.id ) { + controls.provide_selector(new NodeInternalIDSelector('mysql', entry.id, { + source: 'FSNodeContext optimization', + })); + } + + return entry; + } + + async copy_tree ({ context, source, parent, target_name }) { + // Context + const actor = (context ?? Context).get('actor'); + const user = actor.type.user; + + const tracer = svc_trace.tracer; + const uuid = uuidv4(); + const timestamp = Math.round(Date.now() / 1000); + await parent.fetchEntry(); + await source.fetchEntry({ thumbnail: true }); + + // New filesystem entry + const raw_fsentry = { + uuid, + is_dir: source.entry.is_dir, + ...(source.entry.is_shortcut ? { + is_shortcut: source.entry.is_shortcut, + shortcut_to: source.entry.shortcut_to, + } : {}), + parent_uid: parent.uid, + name: target_name, + created: timestamp, + modified: timestamp, + + path: path_.join(await parent.get('path'), target_name), + + // if property exists but the value is undefined, + // it will still be included in the INSERT, causing + // an error + ...(source.entry.thumbnail ? + { thumbnail: source.entry.thumbnail } : {}), + + user_id: user.id, + }; + + svc_event.emit('fs.pending.file', { + fsentry: FSNodeContext.sanitize_pending_entry_info(raw_fsentry), + context: context, + }); + + if ( await source.get('has-s3') ) { + Object.assign(raw_fsentry, { + size: source.entry.size, + associated_app_id: source.entry.associated_app_id, + bucket: source.entry.bucket, + bucket_region: source.entry.bucket_region, + }); + + await tracer.startActiveSpan('fs:cp:storage-copy', async span => { + let progress_tracker = new UploadProgressTracker(); + + svc_event.emit('fs.storage.progress.copy', { + upload_tracker: progress_tracker, + context, + meta: { + item_uid: uuid, + item_path: raw_fsentry.path, + }, + }); + + // const storage = new PuterS3StorageStrategy({ services: svc }); + const storage = context.get('storage'); + const state_copy = storage.create_copy(); + await state_copy.run({ + src_node: source, + dst_storage: { + key: uuid, + bucket: raw_fsentry.bucket, + bucket_region: raw_fsentry.bucket_region, + }, + storage_api: { progress_tracker }, + }); + + span.end(); + }); + } + + { + await svc_size.add_node_size(undefined, source, user); + } + + svc_resource.register({ + uid: uuid, + status: RESOURCE_STATUS_PENDING_CREATE, + }); + + const entryOp = await this.fsEntryController.insert(raw_fsentry); + + let node; + + const tasks = new ParallelTasks({ tracer, max: 4 }); + await context.arun('fs:cp:parallel-portion', async () => { + // Add child copy tasks if this is a directory + if ( source.entry.is_dir ) { + const children = await this.fsEntryController.fast_get_direct_descendants(source.uid); + for ( const child_uuid of children ) { + tasks.add('fs:cp:copy-child', async () => { + const child_node = await svc_fs.node(new NodeUIDSelector(child_uuid)); + const child_name = await child_node.get('name'); + + await this.copy_tree({ + context, + source: await svc_fs.node(new NodeUIDSelector(child_uuid)), + parent: await svc_fs.node(new NodeUIDSelector(uuid)), + target_name: child_name, + }); + }); + } + } + + // Add task to await entry + tasks.add('fs:cp:entry-op', async () => { + await entryOp.awaitDone(); + svc_resource.free(uuid); + const copy_fsNode = await svc_fs.node(new NodeUIDSelector(uuid)); + copy_fsNode.entry = raw_fsentry; + copy_fsNode.found = true; + copy_fsNode.path = raw_fsentry.path; + + node = copy_fsNode; + + svc_event.emit('fs.create.file', { + node, + context, + }); + }, { force: true }); + + await tasks.awaitAll(); + }); + + node = node || await svc_fs.node(new NodeUIDSelector(uuid)); + + // TODO: What event do we emit? How do we know if we're overwriting? + return node; + } + + async move ({ context, node, new_parent, new_name, metadata }) { + const old_path = await node.get('path'); + const new_path = path_.join(await new_parent.get('path'), new_name); + + const op_update = await this.fsEntryController.update(node.uid, { + ...( + await node.get('parent_uid') !== await new_parent.get('uid') + ? { parent_uid: await new_parent.get('uid') } + : {} + ), + path: new_path, + name: new_name, + ...(metadata ? { metadata } : {}), + }); + + node.entry.name = new_name; + node.entry.path = new_path; + + // NOTE: this is a safeguard passed to update_child_paths to isolate + // changes to the owner's directory tree, ut this may need to be + // removed in the future. + const user_id = await node.get('user_id'); + + await op_update.awaitDone(); + + await svc_fs.update_child_paths(old_path, node.entry.path, user_id); + + const promises = []; + promises.push(svc_event.emit('fs.move.file', { + context, + moved: node, + old_path, + })); + promises.push(svc_event.emit('fs.rename', { + uid: await node.get('uid'), + new_name, + })); + + return node; + } + + async readdir ({ node }) { + const uuid = await node.get('uid'); + const child_uuids = await this.fsEntryController.fast_get_direct_descendants(uuid); + return child_uuids; + } + + async directory_has_name ({ parent, name }) { + const uid = await parent.get('uid'); + /* eslint-disable */ + let check_dupe = await db.read( + 'SELECT `id` FROM `fsentries` WHERE `parent_uid` = ? AND name = ? LIMIT 1', + [uid, name], + ); + /* eslint-enable */ + return !!check_dupe[0]; + } + + /** + * Write a new file to the filesystem. Throws an error if the destination + * already exists. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNode} param.parent: The parent directory of the file. + * @param {string} param.name: The name of the file. + * @param {File} param.file: The file to write. + * @returns {Promise} + */ + async write_new ({ context, parent, name, file }) { + console.log('calling write new'); + const { + tmp, fsentry_tmp, message, actor: inputActor, app_id, + } = context.values; + const actor = inputActor ?? Context.get('actor'); + + const uid = uuidv4(); + + // determine bucket region + let bucket_region = global_config.s3_region ?? global_config.region; + let bucket = global_config.s3_bucket; + + if ( ! await svc_acl.check(actor, parent, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, parent, 'write'); + } + + const storage_resp = await this.#storage_upload({ + uuid: uid, + bucket, + bucket_region, + file, + tmp: { + ...tmp, + path: path_.join(await parent.get('path'), name), + }, + }); + + fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise; + delete fsentry_tmp.thumbnail_promise; + + const timestamp = Math.round(Date.now() / 1000); + const raw_fsentry = { + uuid: uid, + is_dir: 0, + user_id: actor.type.user.id, + created: timestamp, + accessed: timestamp, + modified: timestamp, + parent_uid: await parent.get('uid'), + name, + size: file.size, + path: path_.join(await parent.get('path'), name), + ...fsentry_tmp, + bucket_region, + bucket, + associated_app_id: app_id ?? null, + }; + + svc_event.emit('fs.pending.file', { + fsentry: FSNodeContext.sanitize_pending_entry_info(raw_fsentry), + context, + }); + + svc_resource.register({ + uid, + status: RESOURCE_STATUS_PENDING_CREATE, + }); + + const filesize = file.size; + svc_size.change_usage(actor.type.user.id, filesize); + + // Meter ingress + const ownerId = await parent.get('user_id'); + const ownerActor = new Actor({ + type: new UserActorType({ + user: await get_user({ id: ownerId }), + }), + }); + + svc_metering.incrementUsage(ownerActor, 'filesystem:ingress:bytes', filesize); + + const entryOp = await this.fsEntryController.insert(raw_fsentry); + + (async () => { + await entryOp.awaitDone(); + svc_resource.free(uid); + + const new_item_node = await svc_fs.node(new NodeUIDSelector(uid)); + const new_item = await new_item_node.get('entry'); + const store_version_id = storage_resp.VersionId; + if ( store_version_id ) { + // insert version into db + db.write('INSERT INTO `fsentry_versions` (`user_id`, `fsentry_id`, `fsentry_uuid`, `version_id`, `message`, `ts_epoch`) VALUES (?, ?, ?, ?, ?, ?)', + [ + actor.type.user.id, + new_item.id, + new_item.uuid, + store_version_id, + message ?? null, + timestamp, + ]); + } + })(); + + const node = await svc_fs.node(new NodeUIDSelector(uid)); + + svc_event.emit('fs.create.file', { + node, + context, + }); + + return node; + } + + /** + * Overwrite an existing file. Throws an error if the destination does not + * exist. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The node to write to. + * @param {File} param.file: The file to write. + * @returns {Promise} + */ + async write_overwrite ({ context, node, file }) { + const { + tmp, fsentry_tmp, message, actor: inputActor, + } = context.values; + const actor = inputActor ?? Context.get('actor'); + + if ( ! await svc_acl.check(actor, node, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, node, 'write'); + } + + const uid = await node.get('uid'); + + const bucket_region = node.entry.bucket_region; + const bucket = node.entry.bucket; + + const state_upload = await this.#storage_upload({ + uuid: node.entry.uuid, + bucket, + bucket_region, + file, + tmp: { + ...tmp, + path: await node.get('path'), + }, + }); + + if ( fsentry_tmp?.thumbnail_promise ) { + fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise; + delete fsentry_tmp.thumbnail_promise; + } + + const ts = Math.round(Date.now() / 1000); + const raw_fsentry_delta = { + modified: ts, + accessed: ts, + size: file.size, + ...fsentry_tmp, + }; + + svc_resource.register({ + uid, + status: RESOURCE_STATUS_PENDING_CREATE, + }); + + const filesize = file.size; + svc_size.change_usage(actor.type.user.id, filesize); + + // Meter ingress + const ownerId = await node.get('user_id'); + const ownerActor = new Actor({ + type: new UserActorType({ + user: await get_user({ id: ownerId }), + }), + }); + svc_metering.incrementUsage(ownerActor, 'filesystem:ingress:bytes', filesize); + + const entryOp = await this.fsEntryController.update(uid, raw_fsentry_delta); + + // depends on fsentry, does not depend on S3 + const entryOpPromise = (async () => { + await entryOp.awaitDone(); + svc_resource.free(uid); + })(); + + const cachePromise = (async () => { + await svc_fileCache.invalidate(node); + })(); + + (async () => { + await Promise.all([entryOpPromise, cachePromise]); + svc_event.emit('fs.write.file', { + node, + context, + }); + })(); + + // TODO (xiaochen): determine if this can be removed, post_insert handler need + // to skip events from other servers (why? 1. current write logic is inside + // the local server 2. broadcast system conduct "fire-and-forget" behavior) + state_upload.post_insert({ + db, user: actor.type.user, node, uid, message, ts, + }); + + await cachePromise; + + return node; + } + + async get_recursive_size ({ node }) { + const uuid = await node.get('uid'); + const cte_query = ` + WITH RECURSIVE descendant_cte AS ( + SELECT uuid, parent_uid, size + FROM fsentries + WHERE parent_uid = ? + + UNION ALL + + SELECT f.uuid, f.parent_uid, f.size + FROM fsentries f + INNER JOIN descendant_cte d + ON f.parent_uid = d.uuid + ) + SELECT SUM(size) AS total_size FROM descendant_cte + `; + const rows = await db.read(cte_query, [uuid]); + return rows[0].total_size; + } + + // #endregion + + // #region internal + + /** + * @param {Object} param + * @param {File} param.file: The file to write. + * @returns + */ + async #storage_upload ({ + uuid, + bucket, + bucket_region, + file, + tmp, + }) { + const storage = svc_mountpoint.get_storage(this.constructor.name); + + bucket ??= global_config.s3_bucket; + bucket_region ??= global_config.s3_region ?? global_config.region; + + let upload_tracker = new UploadProgressTracker(); + + svc_event.emit('fs.storage.upload-progress', { + upload_tracker, + context: Context.get(), + meta: { + item_uid: uuid, + item_path: tmp.path, + }, + }); + + if ( ! file.buffer ) { + let stream = file.stream; + let alarm_timeout = null; + stream = stuck_detector_stream(stream, { + timeout: STUCK_STATUS_TIMEOUT, + on_stuck: () => { + console.warn('Upload stream stuck might be stuck', { + bucket_region, + bucket, + uuid, + }); + alarm_timeout = setTimeout(() => { + extension.errors.report('fs.write.s3-upload', { + message: 'Upload stream stuck for too long', + alarm: true, + extra: { + bucket_region, + bucket, + uuid, + }, + }); + }, STUCK_ALARM_TIMEOUT); + }, + on_unstuck: () => { + clearTimeout(alarm_timeout); + }, + }); + file = { ...file, stream }; + } + + let hashPromise; + if ( file.buffer ) { + const hash = crypto.createHash('sha256'); + hash.update(file.buffer); + hashPromise = Promise.resolve(hash.digest('hex')); + } else { + const hs = hashing_stream(file.stream); + file.stream = hs.stream; + hashPromise = hs.hashPromise; + } + + hashPromise.then(hash => { + svc_event.emit('outer.fs.write-hash', { + hash, uuid, + }); + }); + + const state_upload = storage.create_upload(); + + try { + await this.storageController.upload({ + uid: uuid, + file, + storage_meta: { bucket, bucket_region }, + storage_api: { progress_tracker: upload_tracker }, + }); + } catch (e) { + extension.errors.report('fs.write.storage-upload', { + source: e || new Error('unknown'), + trace: true, + alarm: true, + extra: { + bucket_region, + bucket, + uuid, + }, + }); + throw APIError.create('upload_failed'); + } + + return state_upload; + } + + async #rmnode ({ node, options }) { + // Services + if ( !options.override_immutable && await node.get('immutable') ) { + throw new APIError(403, 'File is immutable.'); + } + + const userId = await node.get('user_id'); + const fileSize = await node.get('size'); + svc_size.change_usage(userId, + -1 * fileSize); + + const ownerActor = new Actor({ + type: new UserActorType({ + user: await get_user({ id: userId }), + }), + }); + + svc_metering.incrementUsage(ownerActor, 'filesystem:delete:bytes', fileSize); + + const tracer = svc_trace.tracer; + const tasks = new ParallelTasks({ tracer, max: 4 }); + + tasks.add('remove-fsentry', async () => { + await this.fsEntryController.delete(await node.get('uid')); + }); + + if ( await node.get('has-s3') ) { + tasks.add('remove-from-s3', async () => { + // const storage = new PuterS3StorageStrategy({ services: svc }); + const storage = Context.get('storage'); + const state_delete = storage.create_delete(); + await state_delete.run({ + node: node, + }); + }); + } + + await tasks.awaitAll(); + } + // #endregion +} diff --git a/extensions/puterfs/fsentries/BaseOperation.js b/extensions/puterfs/fsentries/BaseOperation.js new file mode 100644 index 0000000000000000000000000000000000000000..8ca9be822e4b32c17e1267fcd6e1da67048bc7e7 --- /dev/null +++ b/extensions/puterfs/fsentries/BaseOperation.js @@ -0,0 +1,27 @@ +export default class BaseOperation { + static STATUS_PENDING = {}; + static STATUS_RUNNING = {}; + static STATUS_DONE = {}; + constructor () { + this.status_ = this.constructor.STATUS_PENDING; + this.donePromise = new Promise((resolve, reject) => { + this.doneResolve = resolve; + this.doneReject = reject; + }); + } + get status () { + return this.status_; + } + set status (status) { + this.status_ = status; + if ( status === this.constructor.STATUS_DONE ) { + this.doneResolve(); + } + } + awaitDone () { + return this.donePromise; + } + onComplete (fn) { + this.donePromise.then(fn); + } +} diff --git a/extensions/puterfs/fsentries/Delete.js b/extensions/puterfs/fsentries/Delete.js new file mode 100644 index 0000000000000000000000000000000000000000..e9eb8deb0bd46251fcde461c3b15495a68c0d3d6 --- /dev/null +++ b/extensions/puterfs/fsentries/Delete.js @@ -0,0 +1,18 @@ +import BaseOperation from './BaseOperation.js'; + +export default class extends BaseOperation { + constructor (uuid) { + super(); + this.uuid = uuid; + } + + getStatement () { + const statement = 'DELETE FROM fsentries WHERE uuid = ? LIMIT 1'; + const values = [this.uuid]; + return { statement, values }; + } + + apply (answer) { + answer.entry = null; + } +} diff --git a/extensions/puterfs/fsentries/FSEntryController.js b/extensions/puterfs/fsentries/FSEntryController.js new file mode 100644 index 0000000000000000000000000000000000000000..d6036d93fa7a1e70bef52a841424fbbdc0fe7b65 --- /dev/null +++ b/extensions/puterfs/fsentries/FSEntryController.js @@ -0,0 +1,537 @@ +import BaseOperation from './BaseOperation.js'; +import Delete from './Delete.js'; +import Insert from './Insert.js'; +import Update from './Update.js'; + +const { db } = extension.import('data'); +const svc_params = extension.import('service:params'); +const svc_info = extension.import('service:information'); + +const { PuterPath } = extension.import('fs'); + +const { Context } = extension.import('core'); + +const { id2path } = extension.import('core').util.helpers; + +const { + RootNodeSelector, + NodeChildSelector, + NodeUIDSelector, + NodePathSelector, + NodeInternalIDSelector, +} = extension.import('core').fs.selectors; + +export default class { + static CONCERN = 'filesystem'; + + static STATUS_READY = {}; + static STATUS_RUNNING_JOB = {}; + + constructor () { + this.status = this.constructor.STATUS_READY; + + this.currentState = { + queue: [], + updating_uuids: {}, + }; + this.deferredState = { + queue: [], + updating_uuids: {}, + }; + + this.entryListeners_ = {}; + + this.mkPromiseForQueueSize_(); + + // this list of properties is for read operations + // (originally in FSEntryFetcher) + this.defaultProperties = [ + 'id', + 'associated_app_id', + 'uuid', + 'public_token', + 'bucket', + 'bucket_region', + 'file_request_token', + 'user_id', + 'parent_uid', + 'is_dir', + 'is_public', + 'is_shortcut', + 'is_symlink', + 'symlink_path', + 'shortcut_to', + 'sort_by', + 'sort_order', + 'immutable', + 'name', + 'metadata', + 'modified', + 'created', + 'accessed', + 'size', + 'layout', + 'path', + ]; + } + + init () { + svc_params.createParameters('fsentry-service', [ + { + id: 'max_queue', + description: 'Maximum queue size', + default: 50, + }, + ], this); + + // Register information providers + + // uuid -> path via mysql + svc_info.given('fs.fsentry:uuid').provide('fs.fsentry:path') + .addStrategy('mysql', async uuid => { + // TODO: move id2path here + try { + return await id2path(uuid); + } catch (e) { + console.error('DASH VOID ERROR !!', e); + return `/-void/${ uuid}`; + } + }); + } + + mkPromiseForQueueSize_ () { + this.queueSizePromise = new Promise((resolve, reject) => { + this.queueSizeResolve = resolve; + }); + } + + // #region write operations + async insert (entry) { + const op = new Insert(entry); + await this.enqueue_(op); + return op; + } + + async update (uuid, entry) { + const op = new Update(uuid, entry); + await this.enqueue_(op); + return op; + } + + async delete (uuid) { + const op = new Delete(uuid); + await this.enqueue_(op); + return op; + } + // #endregion + + // #region read operations + async fast_get_descendants (uuid) { + return (await db.read(` + WITH RECURSIVE descendant_cte AS ( + SELECT uuid, parent_uid + FROM fsentries + WHERE parent_uid = ? + + UNION ALL + + SELECT f.uuid, f.parent_uid + FROM fsentries f + INNER JOIN descendant_cte d ON f.parent_uid = d.uuid + ) + SELECT uuid FROM descendant_cte + `, [uuid])).map(x => x.uuid); + } + + async fast_get_direct_descendants (uuid) { + return (uuid === PuterPath.NULL_UUID + ? await db.read('SELECT uuid FROM fsentries WHERE parent_uid IS NULL') + : await db.read('SELECT uuid FROM fsentries WHERE parent_uid = ?', + [uuid])).map(x => x.uuid); + } + + waitForEntry (node, callback) { + // *** uncomment to debug slow waits *** + // console.log('ATTEMPT TO WAIT FOR', selector.describe()) + let selector = node.get_selector_of_type(NodeUIDSelector); + if ( selector === null ) { + // console.log(new Error('========')); + return; + } + + const entry_already_enqueued = + this.currentState.updating_uuids.hasOwnProperty(selector.value) || + this.deferredState.updating_uuids.hasOwnProperty(selector.value) ; + + if ( entry_already_enqueued ) { + callback(); + return; + } + + const k = `uid:${selector.value}`; + if ( ! this.entryListeners_.hasOwnProperty(k) ) { + this.entryListeners_[k] = []; + } + + const det = { + detach: () => { + const i = this.entryListeners_[k].indexOf(callback); + if ( i === -1 ) return; + this.entryListeners_[k].splice(i, 1); + if ( this.entryListeners_[k].length === 0 ) { + delete this.entryListeners_[k]; + } + }, + }; + + this.entryListeners_[k].push(callback); + + return det; + } + + async get (uuid, fetch_entry_options) { + const answer = {}; + for ( const op of this.currentState.queue ) { + if ( op.uuid != uuid ) continue; + op.apply(answer); + } + for ( const op of this.deferredState.queue ) { + if ( op.uuid != uuid ) continue; + op.apply(answer); + op.apply(answer); + } + if ( answer.is_diff ) { + const base_entry = await this.find(new NodeUIDSelector(uuid), + fetch_entry_options); + answer.entry = { ...base_entry, ...answer.entry }; + } + return answer.entry; + } + + async get_descendants (uuid) { + return uuid === PuterPath.NULL_UUID + ? await db.read('SELECT uuid FROM fsentries WHERE parent_uid IS NULL', + [uuid]) + : await db.read('SELECT uuid FROM fsentries WHERE parent_uid = ?', + [uuid]) + ; + } + + async get_recursive_size (uuid) { + const cte_query = ` + WITH RECURSIVE descendant_cte AS ( + SELECT uuid, parent_uid, size + FROM fsentries + WHERE parent_uid = ? + + UNION ALL + + SELECT f.uuid, f.parent_uid, f.size + FROM fsentries f + INNER JOIN descendant_cte d + ON f.parent_uid = d.uuid + ) + SELECT SUM(size) AS total_size FROM descendant_cte + `; + const rows = await db.read(cte_query, [uuid]); + return rows[0].total_size; + } + + /** + * Finds a filesystem entry using the provided selector. + * @param {Object} selector - The selector object specifying how to find the entry + * @param {Object} fetch_entry_options - Options for fetching the entry + * @returns {Promise} The filesystem entry or null if not found + */ + async find (selector, fetch_entry_options) { + if ( selector instanceof RootNodeSelector ) { + return selector.entry; + } + if ( selector instanceof NodePathSelector ) { + return await this.findByPath(selector.value, fetch_entry_options); + } + if ( selector instanceof NodeUIDSelector ) { + return await this.findByUID(selector.value, fetch_entry_options); + } + if ( selector instanceof NodeInternalIDSelector ) { + return await this.findByID(selector.id, fetch_entry_options); + } + if ( selector instanceof NodeChildSelector ) { + let id; + + if ( selector.parent instanceof RootNodeSelector ) { + id = await this.findNameInRoot(selector.name); + } else { + const parentEntry = await this.find(selector.parent); + if ( ! parentEntry ) return null; + id = await this.findNameInParent(parentEntry.uuid, selector.name); + } + + if ( id === undefined ) return null; + if ( typeof id !== 'number' ) { + throw new Error('unexpected type for id value', + typeof id, + id); + } + return this.find(new NodeInternalIDSelector('mysql', id)); + } + } + + /** + * Finds a filesystem entry by its UUID. + * @param {string} uuid - The UUID of the entry to find + * @param {Object} fetch_entry_options - Options including thumbnail flag + * @returns {Promise} The filesystem entry or undefined if not found + */ + async findByUID (uuid, fetch_entry_options = {}) { + const { thumbnail } = fetch_entry_options; + + let fsentry = await db.tryHardRead(`SELECT ${ + this.defaultProperties.join(', ') + }${thumbnail ? ', thumbnail' : '' + } FROM fsentries WHERE uuid = ? LIMIT 1`, + [uuid]); + + return fsentry[0]; + } + + /** + * Finds a filesystem entry by its internal database ID. + * @param {number} id - The internal ID of the entry to find + * @param {Object} fetch_entry_options - Options including thumbnail flag + * @returns {Promise} The filesystem entry or undefined if not found + */ + async findByID (id, fetch_entry_options = {}) { + const { thumbnail } = fetch_entry_options; + + let fsentry = await db.tryHardRead(`SELECT ${ + this.defaultProperties.join(', ') + }${thumbnail ? ', thumbnail' : '' + } FROM fsentries WHERE id = ? LIMIT 1`, + [id]); + + return fsentry[0]; + } + + /** + * Finds a filesystem entry by its full path. + * @param {string} path - The full path of the entry to find + * @param {Object} fetch_entry_options - Options including thumbnail flag and tracer + * @returns {Promise} The filesystem entry or false if not found + */ + async findByPath (path, fetch_entry_options = {}) { + const { thumbnail } = fetch_entry_options; + + if ( path === '/' ) { + return this.find(new RootNodeSelector()); + } + + const parts = path.split('/').filter(path => path !== ''); + if ( parts.length === 0 ) { + // TODO: invalid path; this should be an error + return false; + } + + // TODO: use a closure table for more efficient path resolving + let parent_uid = null; + let result; + + const resultColsSql = this.defaultProperties.join(', ') + + (thumbnail ? ', thumbnail' : ''); + + result = await db.read(`SELECT ${ resultColsSql + } FROM fsentries WHERE path=? LIMIT 1`, + [path]); + + // using knex instead + + if ( result[0] ) return result[0]; + + const loop = async () => { + for ( let i = 0 ; i < parts.length ; i++ ) { + const part = parts[i]; + const isLast = i == parts.length - 1; + const colsSql = isLast ? resultColsSql : 'uuid'; + if ( parent_uid === null ) { + result = await db.read(`SELECT ${ colsSql + } FROM fsentries WHERE parent_uid IS NULL AND name=? LIMIT 1`, + [part]); + } else { + result = await db.read(`SELECT ${ colsSql + } FROM fsentries WHERE parent_uid=? AND name=? LIMIT 1`, + [parent_uid, part]); + } + + if ( ! result[0] ) return false; + parent_uid = result[0].uuid; + } + }; + + if ( fetch_entry_options.tracer ) { + const tracer = fetch_entry_options.tracer; + const options = fetch_entry_options.trace_options; + await tracer.startActiveSpan('fs:sql:findByPath', + ...(options ? [options] : []), + async span => { + await loop(); + span.end(); + }); + } else { + await loop(); + } + + return result[0]; + } + + /** + * Finds the ID of a child entry with the given name in the root directory. + * @param {string} name - The name of the child entry to find + * @returns {Promise} The ID of the child entry or undefined if not found + */ + async findNameInRoot (name) { + let child_id = await db.read('SELECT `id` FROM `fsentries` WHERE `parent_uid` IS NULL AND name = ? LIMIT 1', + [name]); + return child_id[0]?.id; + } + + /** + * Finds the ID of a child entry with the given name under a specific parent. + * @param {string} parent_uid - The UUID of the parent directory + * @param {string} name - The name of the child entry to find + * @returns {Promise} The ID of the child entry or undefined if not found + */ + async findNameInParent (parent_uid, name) { + let child_id = await db.read('SELECT `id` FROM `fsentries` WHERE `parent_uid` = ? AND name = ? LIMIT 1', + [parent_uid, name]); + return child_id[0]?.id; + } + + /** + * Checks if an entry with the given name exists under a specific parent. + * @param {string} parent_uid - The UUID of the parent directory + * @param {string} name - The name to check for + * @returns {Promise} True if the name exists under the parent, false otherwise + */ + async nameExistsUnderParent (parent_uid, name) { + let check_dupe = await db.read('SELECT `id` FROM `fsentries` WHERE `parent_uid` = ? AND name = ? LIMIT 1', + [parent_uid, name]); + return !!check_dupe[0]; + } + + /** + * Checks if an entry with the given name exists under a parent specified by ID. + * @param {number} parent_id - The internal ID of the parent directory + * @param {string} name - The name to check for + * @returns {Promise} True if the name exists under the parent, false otherwise + */ + async nameExistsUnderParentID (parent_id, name) { + const parent = await this.findByID(parent_id); + if ( ! parent ) { + return false; + } + return this.nameExistsUnderParent(parent.uuid, name); + } + // #endregion + + // #region queue logic + async enqueue_ (op) { + while ( + this.currentState.queue.length > this.max_queue || + this.deferredState.queue.length > this.max_queue + ) { + await this.queueSizePromise; + } + + if ( ! (op instanceof BaseOperation) ) { + throw new Error('Invalid operation'); + } + + const state = this.status === this.constructor.STATUS_READY ? + this.currentState : this.deferredState; + + if ( ! state.updating_uuids.hasOwnProperty(op.uuid) ) { + state.updating_uuids[op.uuid] = []; + } + state.updating_uuids[op.uuid].push(state.queue.length); + + state.queue.push(op); + + // DRY: same pattern as FSOperationContext:provideValue + // DRY: same pattern as FSOperationContext:rejectValue + if ( this.entryListeners_.hasOwnProperty(op.uuid) ) { + const listeners = this.entryListeners_[op.uuid]; + + delete this.entryListeners_[op.uuid]; + + for ( const lis of listeners ) lis(); + } + + this.checkShouldExec_(); + } + + checkShouldExec_ () { + if ( this.status !== this.constructor.STATUS_READY ) return; + if ( this.currentState.queue.length === 0 ) return; + this.exec_(); + } + + async exec_ () { + if ( this.status !== this.constructor.STATUS_READY ) { + throw new Error('Duplicate exec_ call'); + } + + const queue = this.currentState.queue; + + this.status = this.constructor.STATUS_RUNNING_JOB; + + // const conn = await db_primary.promise().getConnection(); + // await conn.beginTransaction(); + + for ( const op of queue ) { + op.status = op.constructor.STATUS_RUNNING; + // await conn.execute(stmt, values); + } + + // await conn.commit(); + // conn.release(); + + // const stmtAndVals = queue.map(op => op.getStatementAndValues()); + // const stmts = stmtAndVals.map(x => x.stmt).join('; '); + // const vals = stmtAndVals.reduce((acc, x) => acc.concat(x.values), []); + + // *** uncomment to debug batch queries *** + // this.log.debug({ stmts, vals }); + // console.log('<<========================'); + // console.log({ stmts, vals }); + // console.log('>>========================'); + + // this.log.debug('array?', Array.isArray(vals)) + + await db.batch_write(queue.map(op => op.getStatement())); + + for ( const op of queue ) { + op.status = op.constructor.STATUS_DONE; + } + + this.flipState_(); + this.status = this.constructor.STATUS_READY; + + for ( const op of queue ) { + op.status = op.constructor.STATUS_DONE; + } + + this.checkShouldExec_(); + } + + flipState_ () { + this.currentState = this.deferredState; + this.deferredState = { + queue: [], + updating_uuids: {}, + }; + const queueSizeResolve = this.queueSizeResolve; + this.mkPromiseForQueueSize_(); + queueSizeResolve(); + } + // #endregion +} \ No newline at end of file diff --git a/extensions/puterfs/fsentries/Insert.js b/extensions/puterfs/fsentries/Insert.js new file mode 100644 index 0000000000000000000000000000000000000000..6c135b2f4748feeb365898dafe5f2bbb961543e6 --- /dev/null +++ b/extensions/puterfs/fsentries/Insert.js @@ -0,0 +1,72 @@ +import { safeHasOwnProperty } from '../lib/objectfn.js'; +import BaseOperation from './BaseOperation.js'; + +export default class extends BaseOperation { + static requiredForCreate = [ + 'uuid', + 'parent_uid', + ]; + + static allowedForCreate = [ + ...this.requiredForCreate, + 'name', + 'user_id', + 'is_dir', + 'created', + 'modified', + 'immutable', + 'shortcut_to', + 'is_shortcut', + 'metadata', + 'bucket', + 'bucket_region', + 'thumbnail', + 'accessed', + 'size', + 'symlink_path', + 'is_symlink', + 'associated_app_id', + 'path', + ]; + + constructor (entry) { + super(); + const requiredForCreate = this.constructor.requiredForCreate; + const allowedForCreate = this.constructor.allowedForCreate; + + { + const sanitized_entry = {}; + for ( const k of allowedForCreate ) { + if ( safeHasOwnProperty(entry, k) ) { + sanitized_entry[k] = entry[k]; + } + } + entry = sanitized_entry; + } + + for ( const k of requiredForCreate ) { + if ( ! safeHasOwnProperty(entry, k) ) { + throw new Error(`Missing required property: ${k}`); + } + } + + this.entry = entry; + } + + getStatement () { + const fields = Object.keys(this.entry); + const statement = 'INSERT INTO fsentries ' + + `(${fields.join(', ')}) ` + + `VALUES (${fields.map(() => '?').join(', ')})`; + const values = fields.map(k => this.entry[k]); + return { statement, values }; + } + + apply (answer) { + answer.entry = { ...this.entry }; + } + + get uuid () { + return this.entry.uuid; + } +}; diff --git a/extensions/puterfs/fsentries/Update.js b/extensions/puterfs/fsentries/Update.js new file mode 100644 index 0000000000000000000000000000000000000000..d98770e1ed87af8c66e3de466109d851d2a190fd --- /dev/null +++ b/extensions/puterfs/fsentries/Update.js @@ -0,0 +1,52 @@ +import { safeHasOwnProperty } from '../lib/objectfn.js'; +import BaseOperation from './BaseOperation.js'; + +export default class extends BaseOperation { + static allowedForUpdate = [ + 'name', + 'parent_uid', + 'user_id', + 'modified', + 'shortcut_to', + 'metadata', + 'thumbnail', + 'size', + 'path', + ]; + + constructor (uuid, entry) { + super(); + const allowedForUpdate = this.constructor.allowedForUpdate; + + { + const sanitized_entry = {}; + for ( const k of allowedForUpdate ) { + if ( safeHasOwnProperty(entry, k) ) { + sanitized_entry[k] = entry[k]; + } + } + entry = sanitized_entry; + } + + this.uuid = uuid; + this.entry = entry; + } + + getStatement () { + const fields = Object.keys(this.entry); + const statement = 'UPDATE fsentries SET ' + + `${fields.map(k => `${k} = ?`).join(', ')} ` + + 'WHERE uuid = ? LIMIT 1'; + const values = fields.map(k => this.entry[k]); + values.push(this.uuid); + return { statement, values }; + } + + apply (answer) { + if ( ! answer.entry ) { + answer.is_diff = true; + answer.entry = {}; + } + Object.assign(answer.entry, this.entry); + } +}; diff --git a/extensions/puterfs/lib/objectfn.js b/extensions/puterfs/lib/objectfn.js new file mode 100644 index 0000000000000000000000000000000000000000..e9836f8ab31ee4da1fad7b49deef0d6d6d73ade5 --- /dev/null +++ b/extensions/puterfs/lib/objectfn.js @@ -0,0 +1,16 @@ +/** + * Instead of `myObject.hasOwnProperty(k)`, always write: + * `safeHasOwnProperty(myObject, k)`. + * + * This is a less verbose way to call `Object.prototype.hasOwnProperty.call`. + * This prevents unexpected behavior when `hasOwnProperty` is overridden, + * which is especially possible for objects parsed from user-sent JSON. + * + * explanation: https://eslint.org/docs/latest/rules/no-prototype-builtins + * @param {*} o + * @param {...any} a + * @returns + */ +export const safeHasOwnProperty = (o, ...a) => { + return Object.prototype.hasOwnProperty.call(o, ...a); +}; diff --git a/extensions/puterfs/main.js b/extensions/puterfs/main.js new file mode 100644 index 0000000000000000000000000000000000000000..29d1b15729ad051b58dd3c1cccf11489d42a5d91 --- /dev/null +++ b/extensions/puterfs/main.js @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import FSEntryController from './fsentries/FSEntryController.js'; +import PuterFSProvider from './PuterFSProvider.js'; +import LocalDiskStorageController from './storage/LocalDiskStorageController.js'; +import ProxyStorageController from './storage/ProxyStorageController.js'; + +const svc_event = extension.import('service:event'); + +const fsEntryController = new FSEntryController(); +const storageController = new ProxyStorageController(); + +extension.on('init', async () => { + fsEntryController.init(); + + // Keep track of possible storage strategies for puterfs here + let defaultStorage = 'flat-files'; + const storageStrategies = { + 'flat-files': new LocalDiskStorageController(), + }; + + // Emit the "create storage strategies" event + const event = { + createStorageStrategy (name, implementation) { + storageStrategies[name] = implementation; + if ( implementation === undefined ) { + throw new Error('createStorageStrategy was called wrong'); + } + if ( implementation.forceDefault ) { + defaultStorage = name; + } + }, + }; + // Awaiting the event ensures all the storage strategies are registered + await svc_event.emit('puterfs.storage.create', event); + + let configuredStorage = defaultStorage; + if ( config.storage ) configuredStorage = config.storage; + + // Not we can select the configured strategy + const storageToUse = storageStrategies[configuredStorage]; + storageController.setDelegate(storageToUse); + + // The StorageController may need to await some asynchronous operations + // before it's ready to be used. + await storageController.init(); + +}); + +extension.on('create.filesystem-types', event => { + event.createFilesystemType('puterfs', { + mount ({ path }) { + return new PuterFSProvider({ + fsEntryController, + storageController, + }); + }, + }); +}); diff --git a/extensions/puterfs/package.json b/extensions/puterfs/package.json new file mode 100644 index 0000000000000000000000000000000000000000..aaca7b353fedaa0c55699ab72884866540f92f3b --- /dev/null +++ b/extensions/puterfs/package.json @@ -0,0 +1,8 @@ +{ + "main": "main.js", + "type": "module", + "dependencies": { + "teepromise": "^0.1.1", + "uuid": "^13.0.0" + } +} diff --git a/extensions/puterfs/storage/LocalDiskStorageController.js b/extensions/puterfs/storage/LocalDiskStorageController.js new file mode 100644 index 0000000000000000000000000000000000000000..f7626e740181e79224bea80d735ebad6ad4082c7 --- /dev/null +++ b/extensions/puterfs/storage/LocalDiskStorageController.js @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path_ from 'node:path'; +import { TeePromise } from 'teepromise'; + +const { + progress_stream, + size_limit_stream, +} = extension.import('core').util.streamutil; + +export default class LocalDiskStorageController { + constructor () { + this.path = path_.join(process.cwd(), '/storage'); + } + + async init () { + await fs.promises.mkdir(this.path, { recursive: true }); + } + + async upload ({ uid, file, storage_api }) { + const { progress_tracker } = storage_api; + + if ( file.buffer ) { + const path = this.#getPath(uid); + await fs.promises.writeFile(path, file.buffer); + + progress_tracker.set_total(file.buffer.length); + progress_tracker.set(file.buffer.length); + return; + } + + let stream = file.stream; + stream = progress_stream(stream, { + total: file.size, + progress_callback: evt => { + progress_tracker.set_total(file.size); + progress_tracker.set(evt.uploaded); + }, + }); + stream = size_limit_stream(stream, { + limit: file.size, + }); + + const writePromise = new TeePromise(); + const path = this.#getPath(uid); + const write_stream = fs.createWriteStream(path); + + write_stream.on('error', () => writePromise.reject()); + write_stream.on('finish', () => writePromise.resolve()); + + stream.pipe(write_stream); + + // @ts-ignore (it's wrong about this) + await writePromise; + } + copy () { + } + delete () { + } + read () { + } + + #getPath (key) { + return path_.join(this.path, key); + } +} \ No newline at end of file diff --git a/extensions/puterfs/storage/ProxyStorageController.js b/extensions/puterfs/storage/ProxyStorageController.js new file mode 100644 index 0000000000000000000000000000000000000000..66e5b696f4d6781d25308aa6a8b92178b47a89c7 --- /dev/null +++ b/extensions/puterfs/storage/ProxyStorageController.js @@ -0,0 +1,24 @@ +export default class { + constructor (delegate) { + this.delegate = delegate ?? null; + } + setDelegate (delegate) { + this.delegate = delegate; + } + + init (...a) { + return this.delegate.init(...a); + } + upload (...a) { + return this.delegate.upload(...a); + } + copy (...a) { + return this.delegate.copy(...a); + } + delete (...a) { + return this.delegate.delete(...a); + } + read (...a) { + return this.delegate.read(...a); + } +} diff --git a/extensions/tsconfig.json b/extensions/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..9a76643eeedf6f03549abe1aa8449109afa1cd10 --- /dev/null +++ b/extensions/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "node16", + "moduleResolution": "node16", + "allowJs": true, + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "sourceMap": true, + }, + "include": [ + "./**/*.ts", + "./**/*.d.ts", + "./**/*.d.mts", + "./**/*.d.cts" + ], + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/test/**", + "**/tests/**", + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/extensions/utilities.js b/extensions/utilities.js new file mode 100644 index 0000000000000000000000000000000000000000..a089e1d976a781489ab5333972751c2545eaa668 --- /dev/null +++ b/extensions/utilities.js @@ -0,0 +1,9 @@ +//@extension priority -10000 + +extension.exports = {}; + +extension.exports.sleep = async (seconds) => { + await new Promise(resolve => { + setTimeout(resolve, seconds); + }); +}; diff --git a/extensions/whoami/main.js b/extensions/whoami/main.js new file mode 100644 index 0000000000000000000000000000000000000000..e72d7e1ee24c858d966beadfb8a8f10812835d59 --- /dev/null +++ b/extensions/whoami/main.js @@ -0,0 +1 @@ +import './routes.js'; diff --git a/extensions/whoami/package.json b/extensions/whoami/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a1a3f37f3e5a91b4c81413e558b3af2d16ffa448 --- /dev/null +++ b/extensions/whoami/package.json @@ -0,0 +1,8 @@ +{ + "name": "@heyputer/extension-whoami", + "main": "main.js", + "type": "module", + "dependencies": { + "javascript-time-ago": "^2.5.12" + } +} diff --git a/extensions/whoami/routes.js b/extensions/whoami/routes.js new file mode 100644 index 0000000000000000000000000000000000000000..7c8847b72ff06270d677211cfa652f80e9a2cd71 --- /dev/null +++ b/extensions/whoami/routes.js @@ -0,0 +1,234 @@ +// static imports +import _path from 'fs'; +import TimeAgo from 'javascript-time-ago'; +import localeEn from 'javascript-time-ago/locale/en'; + +// runtime imports +const { UserActorType, AppUnderUserActorType } = extension.import('core'); +const { + id2uuid, + get_descendants, + suggest_app_for_fsentry, + is_shared_with_anyone, + get_app, + get_taskbar_items, +} = extension.import('core').util.helpers; + +const timeago = (() => { + TimeAgo.addDefaultLocale(localeEn); + return new TimeAgo('en-US'); +})(); + +const whoami_common = ({ is_user, user }) => { + const details = {}; + + // User's immutable default (often called "system") directories' + // alternative (to path) identifiers are sent to the user's client + // (but not to apps; they don't need this information) + if ( is_user ) { + const directories = details.directories = {}; + const name_to_path = { + 'desktop_uuid': `/${user.username}/Desktop`, + 'appdata_uuid': `/${user.username}/AppData`, + 'documents_uuid': `/${user.username}/Documents`, + 'pictures_uuid': `/${user.username}/Pictures`, + 'videos_uuid': `/${user.username}/Videos`, + 'trash_uuid': `/${user.username}/Trash`, + }; + for ( const k in name_to_path ) { + directories[name_to_path[k]] = user[k]; + } + } + + if ( user.last_activity_ts ) { + + // Create a Date object and get the epoch timestamp + let epoch; + try { + epoch = new Date(user.last_activity_ts).getTime(); + // round to 1 decimal place + epoch = Math.round(epoch / 1000); + } catch ( e ) { + console.error('Error parsing last_activity_ts', e); + } + + // add last_activity_ts + details.last_activity_ts = epoch; + } + + return details; +}; + +extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => { + const actor = req.actor; + + if ( ! actor ) { + throw Error('actor not found in context'); + } + + const is_user = actor.type instanceof UserActorType; + + if ( req.query.icon_size ) { + const ALLOWED_SIZES = ['16', '32', '64', '128', '256', '512']; + + if ( ! ALLOWED_SIZES.includes(req.query.icon_size) ) { + res.status(400).send({ error: 'Invalid icon_size' }); + } + } + + const details = { + username: req.user.username, + uuid: req.user.uuid, + email: req.user.email, + unconfirmed_email: req.user.email, + email_confirmed: req.user.email_confirmed + || req.user.username === 'admin', + requires_email_confirmation: req.user.requires_email_confirmation, + desktop_bg_url: req.user.desktop_bg_url, + desktop_bg_color: req.user.desktop_bg_color, + desktop_bg_fit: req.user.desktop_bg_fit, + is_temp: (req.user.password === null && req.user.email === null), + taskbar_items: await get_taskbar_items(req.user, { + ...(req.query.icon_size + ? { icon_size: req.query.icon_size } + : { no_icons: true }), + }), + referral_code: req.user.referral_code, + otp: !!req.user.otp_enabled, + human_readable_age: timeago.format(new Date(req.user.timestamp)), + hasDevAccountAccess: !!req.actor.type.user.metadata?.hasDevAccountAccess, + ...(req.new_token ? { token: req.token } : {}), + }; + + // TODO: redundant? GetUserService already puts these values on 'user' + // Get whoami values from other services + const /** @type {any} */ svc_whoami = req.services.get('whoami'); + + const /** @type {any} */ svc_permission = req.services.get('permission'); + + const provider_details = await svc_whoami.get_details({ + user: req.user, + actor: actor, + }); + Object.assign(details, provider_details); + + if ( ! is_user ) { + // When apps call /whoami they should not see these attributes + // delete details.username; + // delete details.uuid; + + if ( ! (await svc_permission.check(actor, `user:${details.uuid}:email:read`, { no_cache: true })) ) { + delete details.email; + delete details.unconfirmed_email; + } + + delete details.desktop_bg_url; + delete details.desktop_bg_color; + delete details.desktop_bg_fit; + delete details.taskbar_items; + delete details.token; + delete details.human_readable_age; + } + + if ( actor.type instanceof AppUnderUserActorType ) { + details.app_name = actor.type.app.name; + + // IDEA: maybe we do this in the future + // details.app = { + // name: actor.type.app.name, + // }; + } + + Object.assign(details, whoami_common({ is_user, user: req.user })); + + res.send(details); +}); + +extension.post('/whoami', { subdomain: 'api' }, async (req, res) => { + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + + const is_user = actor.type instanceof UserActorType; + if ( ! is_user ) { + throw Error('actor is not a user'); + } + + let desktop_items = []; + + // check if user asked for desktop items + if ( req.query.return_desktop_items === 1 || req.query.return_desktop_items === '1' || req.query.return_desktop_items === 'true' ) { + // by cached desktop id + if ( req.user.desktop_id ) { + // TODO: Check if used anywhere, maybe remove + // eslint-disable-next-line no-undef + desktop_items = await db.read(`SELECT * FROM fsentries + WHERE user_id = ? AND parent_uid = ?`, + [req.user.id, await id2uuid(req.user.desktop_id)]); + } + // by desktop path + else { + desktop_items = await get_descendants(`${req.user.username }/Desktop`, req.user, 1, true); + } + + // clean up desktop items and add some extra information + if ( desktop_items.length > 0 ) { + if ( desktop_items.length > 0 ) { + for ( let i = 0; i < desktop_items.length; i++ ) { + if ( desktop_items[i].id !== null ) { + // suggested_apps for files + if ( ! desktop_items[i].is_dir ) { + desktop_items[i].suggested_apps = await suggest_app_for_fsentry(desktop_items[i], { user: req.user }); + } + // is_shared + desktop_items[i].is_shared = await is_shared_with_anyone(desktop_items[i].id); + + // associated_app + if ( desktop_items[i].associated_app_id ) { + const app = await get_app({ id: desktop_items[i].associated_app_id }); + + // remove some privileged information + delete app.id; + delete app.approved_for_listing; + delete app.approved_for_opening_items; + delete app.godmode; + delete app.owner_user_id; + // add to array + desktop_items[i].associated_app = app; + + } else { + desktop_items[i].associated_app = {}; + } + + // remove associated_app_id since it's sensitive info + // delete desktop_items[i].associated_app_id; + } + // id is sesitive info + delete desktop_items[i].id; + delete desktop_items[i].user_id; + delete desktop_items[i].bucket; + desktop_items[i].path = _path.join('/', req.user.username, desktop_items[i].name); + } + } + } + } + + // send user object + res.send(Object.assign({ + username: req.user.username, + uuid: req.user.uuid, + email: req.user.email, + email_confirmed: req.user.email_confirmed + || req.user.username === 'admin', + requires_email_confirmation: req.user.requires_email_confirmation, + desktop_bg_url: req.user.desktop_bg_url, + desktop_bg_color: req.user.desktop_bg_color, + desktop_bg_fit: req.user.desktop_bg_fit, + is_temp: (req.user.password === null && req.user.email === null), + taskbar_items: await get_taskbar_items(req.user), + desktop_items: desktop_items, + referral_code: req.user.referral_code, + hasDevAccountAccess: !!req.actor.user.metadata?.hasDevAccountAccess, + }, whoami_common({ is_user, user: req.user }))); +}); diff --git a/mod_packages/testex/package.json b/mod_packages/testex/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a00be0ab5d8393c7b2dbfa1f534579d980100360 --- /dev/null +++ b/mod_packages/testex/package.json @@ -0,0 +1 @@ +{} diff --git a/mods/README.md b/mods/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f55466ce36634c65bf7af04627e323c637d4cddc --- /dev/null +++ b/mods/README.md @@ -0,0 +1,13 @@ +# Puter Mods + +A list of Puter mods which may be expanded in the future. + +**Contributions of new mods are welcome.** + +## kdmod + +- **location:** [./kdmod](./kdmod) +- **description:** + > "kernel dev mod"; specifically for the devex needs of + > GitHub user KernelDeimos and provided in case anyone else + > finds it of any use. diff --git a/mods/mods_available/example-singlefile.js b/mods/mods_available/example-singlefile.js new file mode 100644 index 0000000000000000000000000000000000000000..87df7ffa2be454528492389f372c8f907d0c560e --- /dev/null +++ b/mods/mods_available/example-singlefile.js @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +extension.get('/example-onefile-get', (req, res) => { + res.send('Hello World!'); +}); + +extension.on('install', ({ services }) => { + // console.log('install was called'); +}); diff --git a/mods/mods_available/example/main.js b/mods/mods_available/example/main.js new file mode 100644 index 0000000000000000000000000000000000000000..6dc64a3bb45a906e2e1c035540a396a3c8f9072e --- /dev/null +++ b/mods/mods_available/example/main.js @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +extension.get('/example-mod-get', (req, res) => { + res.send('Hello World!'); +}); + +extension.on('install', ({ services }) => { + // console.log('install was called'); +}); diff --git a/mods/mods_available/example/package.json b/mods/mods_available/example/package.json new file mode 100644 index 0000000000000000000000000000000000000000..046a65b5b1edd2f73809b01ff903164b60178317 --- /dev/null +++ b/mods/mods_available/example/package.json @@ -0,0 +1,12 @@ +{ + "name": "example-puter-extension", + "version": "1.0.0", + "description": "", + "main": "main.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0-only" +} diff --git a/mods/mods_available/kdmod/CustomPuterService.js b/mods/mods_available/kdmod/CustomPuterService.js new file mode 100644 index 0000000000000000000000000000000000000000..5e7baddc1f33ccfe807f5168ab22ea78d8175661 --- /dev/null +++ b/mods/mods_available/kdmod/CustomPuterService.js @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const path = require('path'); + +class CustomPuterService extends use.Service { + async _init () { + const svc_commands = this.services.get('commands'); + this._register_commands(svc_commands); + + const svc_puterHomepage = this.services.get('puter-homepage'); + svc_puterHomepage.register_script('/custom-gui/main.js'); + } + ['__on_install.routes'] (_, { app }) { + const require = this.require; + const express = require('express'); + const path_ = require('path'); + + app.use('/custom-gui', + express.static(path.join(__dirname, 'gui'))); + } + async ['__on_boot.consolidation'] () { + const then = Date.now(); + this.tod_widget = () => { + const s = 5 - Math.floor((Date.now() - then) / 1000); + const lines = [ + '\x1B[36;1mKDMOD ENABLED\x1B[0m' + + ` (👁️ ${s}s)`, + ]; + // It would be super cool to be able to use this here + // surrounding_box('33;1', lines); + return lines; + }; + + const svc_devConsole = this.services.get('dev-console', { optional: true }); + if ( ! svc_devConsole ) return; + svc_devConsole.add_widget(this.tod_widget); + + setTimeout(() => { + svc_devConsole.remove_widget(this.tod_widget); + }, 5000); + } + + _register_commands (commands) { + commands.registerCommands('o', [ + { + id: 'k', + description: '', + handler: async (_, log) => { + const svc_devConsole = this.services.get('dev-console', { optional: true }); + if ( ! svc_devConsole ) return; + svc_devConsole.remove_widget(this.tod_widget); + const lines = this.tod_widget(); + for ( const line of lines ) log.log(line); + this.tod_widget = null; + }, + }, + ]); + } +} + +module.exports = { CustomPuterService }; \ No newline at end of file diff --git a/mods/mods_available/kdmod/README.md b/mods/mods_available/kdmod/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e1d959985948d971d2aa20575efc74f2c4cf6072 --- /dev/null +++ b/mods/mods_available/kdmod/README.md @@ -0,0 +1,7 @@ +# Kernel Dev Mod + +This mod makes testing and debugging easier. + +## Current Features: +- A service-script adds `reqex` to the `window` object in the client, + which contains a bunch of example requests to internal API endpoints. diff --git a/mods/mods_available/kdmod/ShareTestService.js b/mods/mods_available/kdmod/ShareTestService.js new file mode 100644 index 0000000000000000000000000000000000000000..5d73b09c801ccfd07a3eaffce81b3ccff3abe2a5 --- /dev/null +++ b/mods/mods_available/kdmod/ShareTestService.js @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +// TODO: accessing these imports directly from a mod is not really +// the way mods are intended to work; this is temporary until +// we have these things registered in "useapi". +const { + get_user, + invalidate_cached_user, + deleteUser, +} = require('../../../src/backend/src/helpers.js'); +const { HLWrite } = require('../../../src/backend/src/filesystem/hl_operations/hl_write.js'); +const { LLRead } = require('../../../src/backend/src/filesystem/ll_operations/ll_read.js'); +const { Actor, UserActorType } + = require('../../../src/backend/src/services/auth/Actor.js'); +const { DB_WRITE } = require('../../../src/backend/src/services/database/consts.js'); +const { + RootNodeSelector, + NodeChildSelector, + NodePathSelector, +} = require('../../../src/backend/src/filesystem/node/selectors.js'); +const { Context } = require('../../../src/backend/src/util/context.js'); + +class ShareTestService extends use.Service { + static MODULES = { + uuidv4: require('uuid').v4, + }; + + async _init () { + const svc_commands = this.services.get('commands'); + this._register_commands(svc_commands); + + this.scenarios = require('./data/sharetest_scenarios'); + + const svc_db = this.services.get('database'); + this.db = svc_db.get(svc_db.DB_WRITE, 'share-test'); + } + + _register_commands (commands) { + commands.registerCommands('share-test', [ + { + id: 'start', + description: '', + handler: async (_, log) => { + const results = await this.runit(); + + for ( const result of results ) { + log.log(`=== ${result.title} ===`); + if ( ! result.report ) { + log.log('\x1B[32;1mSUCCESS\x1B[0m'); + continue; + } + log.log('\x1B[31;1mSTOPPED\x1B[0m at ' + + `${result.report.step}: ${ + result.report.report.message}`); + } + }, + }, + ]); + } + + async runit () { + await this.teardown_(); + await this.setup_(); + + const results = []; + + for ( const scenario of this.scenarios ) { + if ( ! scenario.title ) { + scenario.title = scenario.sequence.map(step => step.title).join('; '); + } + results.push({ + title: scenario.title, + report: await this.run_scenario_(scenario), + }); + } + + await this.teardown_(); + return results; + } + + async setup_ () { + await this.create_test_user_('testuser_eric'); + await this.create_test_user_('testuser_stan'); + await this.create_test_user_('testuser_kyle'); + await this.create_test_user_('testuser_kenny'); + } + async run_scenario_ (scenario) { + let error; + // Run sequence + for ( const step of scenario.sequence ) { + const method = this[`__scenario:${step.call}`]; + const user = await get_user({ username: step.as }); + const actor = await Actor.create(UserActorType, { user }); + const generated = { user, actor }; + const report = await Context.get().sub({ user, actor }) + .arun(async () => { + return await method.call(this, generated, step.with); + }); + if ( report ) { + error = { step: step.title, report }; + break; + } + } + return error; + } + async teardown_ () { + await this.delete_test_user_('testuser_eric'); + await this.delete_test_user_('testuser_stan'); + await this.delete_test_user_('testuser_kyle'); + await this.delete_test_user_('testuser_kenny'); + } + + async create_test_user_ (username) { + await this.db.write(` + INSERT INTO user (uuid, username, email, free_storage, password) + VALUES (?, ?, ?, ?, ?) + `, + [ + this.modules.uuidv4(), + username, + `${username}@example.com`, + 1024 * 1024 * 500, // 500 MiB + this.modules.uuidv4(), + ]); + const user = await get_user({ username }); + const svc_user = this.services.get('user'); + await svc_user.generate_default_fsentries({ user }); + invalidate_cached_user(user); + return user; + } + + async delete_test_user_ (username) { + const user = await get_user({ username }); + if ( ! user ) return; + await deleteUser(user.id); + } + + // API for scenarios + async ['__scenario:create-example-file'] ( + { actor, user }, + { name, contents }, + ) { + const svc_fs = this.services.get('filesystem'); + const parent = await svc_fs.node(new NodePathSelector(`/${user.username}/Desktop`)); + console.log('test -> create-example-file', + user, + name, + contents); + const buffer = Buffer.from(contents); + const file = { + size: buffer.length, + name: name, + type: 'application/octet-stream', + buffer, + }; + const hl_write = new HLWrite(); + await hl_write.run({ + actor, + user, + destination_or_parent: parent, + specified_name: name, + file, + }); + } + async ['__scenario:assert-no-access'] ( + { actor, user }, + { path }, + ) { + const svc_fs = this.services.get('filesystem'); + const node = await svc_fs.node(new NodePathSelector(path)); + const ll_read = new LLRead(); + let expected_e; try { + const stream = await ll_read.run({ + fsNode: node, + actor, + }); + } catch (e) { + expected_e = e; + } + if ( ! expected_e ) { + return { message: 'expected error, got none' }; + } + } + async ['__scenario:grant'] ( + { actor, user }, + { to, permission }, + ) { + const svc_permission = this.services.get('permission'); + await svc_permission.grant_user_user_permission(actor, to, permission, {}, {}); + } + async ['__scenario:assert-access'] ( + { actor, user }, + { path, level }, + ) { + const svc_fs = this.services.get('filesystem'); + const svc_acl = this.services.get('acl'); + const node = await svc_fs.node(new NodePathSelector(path)); + const has_read = await svc_acl.check(actor, node, 'read'); + const has_write = await svc_acl.check(actor, node, 'write'); + + if ( level !== 'write' && level !== 'read' ) { + return { + message: 'unexpected value for "level" parameter', + }; + } + + if ( level === 'read' && has_write ) { + return { + message: 'expected read-only but actor can write', + }; + } + if ( level === 'read' && !has_read ) { + return { + message: 'expected read access but no read access', + }; + } + if ( level === 'write' && (!has_write || !has_read) ) { + return { + message: 'expected write access but no write access', + }; + } + if ( level === 'manage' && (!has_write || !has_read) ) { + return { + message: 'expected write access but no write access', + }; + } + } +} + +module.exports = { + ShareTestService, +}; diff --git a/mods/mods_available/kdmod/data/sharetest_scenarios.js b/mods/mods_available/kdmod/data/sharetest_scenarios.js new file mode 100644 index 0000000000000000000000000000000000000000..ba7cf27582b645b74a92028fbaeb57abeb9c70b3 --- /dev/null +++ b/mods/mods_available/kdmod/data/sharetest_scenarios.js @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = [ + { + sequence: [ + { + title: 'Kyle creates a file', + call: 'create-example-file', + as: 'testuser_kyle', + with: { + name: 'example.txt', + contents: 'secret file', + }, + }, + { + title: 'Eric tries to access it', + call: 'assert-no-access', + as: 'testuser_eric', + with: { + path: '/testuser_kyle/Desktop/example.txt', + }, + }, + ], + }, + { + sequence: [ + { + title: 'Stan creates a file', + call: 'create-example-file', + as: 'testuser_stan', + with: { + name: 'example.txt', + contents: 'secret file', + }, + }, + { + title: 'Stan grants permission to Eric', + call: 'grant', + as: 'testuser_stan', + with: { + to: 'testuser_eric', + permission: 'fs:/testuser_stan/Desktop/example.txt:read', + }, + }, + { + title: 'Eric tries to access it', + call: 'assert-access', + as: 'testuser_eric', + with: { + path: '/testuser_stan/Desktop/example.txt', + level: 'read', + }, + }, + ], + }, + { + sequence: [ + { + title: 'Stan grants Kyle\'s file to Eric', + call: 'grant', + as: 'testuser_stan', + with: { + to: 'testuser_eric', + permission: 'fs:/testuser_kyle/Desktop/example.txt:read', + }, + }, + { + title: 'Eric tries to access it', + call: 'assert-no-access', + as: 'testuser_eric', + with: { + path: '/testuser_kyle/Desktop/example.txt', + }, + }, + ], + }, +]; diff --git a/mods/mods_available/kdmod/gui/main.js b/mods/mods_available/kdmod/gui/main.js new file mode 100644 index 0000000000000000000000000000000000000000..3194c75acca034cfbaf2c51d1afc89e936b83c91 --- /dev/null +++ b/mods/mods_available/kdmod/gui/main.js @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const request_examples = [ + { + name: 'entity storage app read', + fetch: async (args) => { + return await fetch(`${window.api_origin}/drivers/call`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${puter.authToken}`, + }, + body: JSON.stringify({ + interface: 'puter-apps', + method: 'read', + args, + }), + method: 'POST', + }); + }, + out: async (resp) => { + const data = await resp.json(); + if ( ! data.success ) return data; + return data.result; + }, + exec: async function exec (...a) { + const resp = await this.fetch(...a); + return await this.out(resp); + }, + }, + { + name: 'entity storage app select all', + fetch: async () => { + return await fetch(`${window.api_origin}/drivers/call`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${puter.authToken}`, + }, + body: JSON.stringify({ + interface: 'puter-apps', + method: 'select', + args: { predicate: [] }, + }), + method: 'POST', + }); + }, + out: async (resp) => { + const data = await resp.json(); + if ( ! data.success ) return data; + return data.result; + }, + exec: async function exec (...a) { + const resp = await this.fetch(...a); + return await this.out(resp); + }, + }, + { + name: 'grant permission from a user to a user', + fetch: async (user, perm) => { + return await fetch(`${window.api_origin}/auth/grant-user-user`, { + 'headers': { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${puter.authToken}`, + }, + 'body': JSON.stringify({ + target_username: user, + permission: perm, + }), + 'method': 'POST', + }); + }, + out: async (resp) => { + const data = await resp.json(); + return data; + }, + exec: async function exec (...a) { + const resp = await this.fetch(...a); + return await this.out(resp); + }, + }, + { + name: 'write file', + fetch: async (path, str) => { + const endpoint = `${window.api_origin}/write`; + const token = puter.authToken; + + const blob = new Blob([str], { type: 'text/plain' }); + const formData = new FormData(); + formData.append('create_missing_ancestors', true); + formData.append('path', path); + formData.append('size', 8); + formData.append('overwrite', true); + formData.append('file', blob, 'something.txt'); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + return await response.json(); + }, + }, +]; + +globalThis.reqex = request_examples; + +globalThis.service_script(api => { + api.on_ready(() => { + }); +}); diff --git a/mods/mods_available/kdmod/module.js b/mods/mods_available/kdmod/module.js new file mode 100644 index 0000000000000000000000000000000000000000..e5720f6f50f5f15575d3c9d60224675359f7361d --- /dev/null +++ b/mods/mods_available/kdmod/module.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +extension.on('install', ({ services }) => { + const { CustomPuterService } = require('./CustomPuterService.js'); + services.registerService('__custom-puter', CustomPuterService); + + const { ShareTestService } = require('./ShareTestService.js'); + services.registerService('__share-test', ShareTestService); +}); diff --git a/mods/mods_available/kdmod/package.json b/mods/mods_available/kdmod/package.json new file mode 100644 index 0000000000000000000000000000000000000000..e20a882591d6a608cf387eb0954a1a730e03e5ee --- /dev/null +++ b/mods/mods_available/kdmod/package.json @@ -0,0 +1,12 @@ +{ + "name": "custom-puter-mod", + "version": "1.0.0", + "description": "", + "main": "module.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0-only" +} diff --git a/mods/mods_available/testex.js b/mods/mods_available/testex.js new file mode 100644 index 0000000000000000000000000000000000000000..8b5b3bc2423a63f93056550bf7b35e0c359a7f68 --- /dev/null +++ b/mods/mods_available/testex.js @@ -0,0 +1,149 @@ +// Test extension for event listeners + +extension.on('ai.prompt.complete', event => { + console.log('GOT AI.PROMPT.COMPLETE EVENT', event); +}); + +extension.on('ai.prompt.validate', event => { + console.log('GOT AI.PROMPT.VALIDATE EVENT', event); +}); + +extension.on('app.new-icon', event => { + console.log('GOT APP.NEW-ICON EVENT', event); +}); + +extension.on('app.rename', event => { + console.log('GOT APP.RENAME EVENT', event); +}); + +extension.on('apps.invalidate', event => { + console.log('GOT APPS.INVALIDATE EVENT', event); +}); + +extension.on('email.validate', event => { + console.log('GOT EMAIL.VALIDATE EVENT', event); +}); + +extension.on('fs.create.directory', event => { + console.log('GOT FS.CREATE.DIRECTORY EVENT', event); +}); + +extension.on('fs.create.file', event => { + console.log('GOT FS.CREATE.FILE EVENT', event); +}); + +extension.on('fs.create.shortcut', event => { + console.log('GOT FS.CREATE.SHORTCUT EVENT', event); +}); + +extension.on('fs.create.symlink', event => { + console.log('GOT FS.CREATE.SYMLINK EVENT', event); +}); + +extension.on('fs.move.file', event => { + console.log('GOT FS.MOVE.FILE EVENT', event); +}); + +extension.on('fs.pending.file', event => { + console.log('GOT FS.PENDING.FILE EVENT', event); +}); + +extension.on('fs.storage.progress.copy', event => { + console.log('GOT FS.STORAGE.PROGRESS.COPY EVENT', event); +}); + +extension.on('fs.storage.upload-progress', event => { + console.log('GOT FS.STORAGE.UPLOAD-PROGRESS EVENT', event); +}); + +extension.on('fs.write.file', event => { + console.log('GOT FS.WRITE.FILE EVENT', event); +}); + +extension.on('ip.validate', event => { + console.log('GOT IP.VALIDATE EVENT', event); +}); + +extension.on('outer.fs.write-hash', event => { + console.log('GOT OUTER.FS.WRITE-HASH EVENT', event); +}); + +extension.on('outer.gui.item.added', event => { + console.log('GOT OUTER.GUI.ITEM.ADDED EVENT', event); +}); + +extension.on('outer.gui.item.moved', event => { + console.log('GOT OUTER.GUI.ITEM.MOVED EVENT', event); +}); + +extension.on('outer.gui.item.pending', event => { + console.log('GOT OUTER.GUI.ITEM.PENDING EVENT', event); +}); + +extension.on('outer.gui.item.updated', event => { + console.log('GOT OUTER.GUI.ITEM.UPDATED EVENT', event); +}); + +extension.on('outer.gui.notif.ack', event => { + console.log('GOT OUTER.GUI.NOTIF.ACK EVENT', event); +}); + +extension.on('outer.gui.notif.message', event => { + console.log('GOT OUTER.GUI.NOTIF.MESSAGE EVENT', event); +}); + +extension.on('outer.gui.notif.persisted', event => { + console.log('GOT OUTER.GUI.NOTIF.PERSISTED EVENT', event); +}); + +extension.on('outer.gui.notif.unreads', event => { + console.log('GOT OUTER.GUI.NOTIF.UNREADS EVENT', event); +}); + +extension.on('outer.gui.submission.done', event => { + console.log('GOT OUTER.GUI.SUBMISSION.DONE EVENT', event); +}); + +extension.on('puter-exec.submission.done', event => { + console.log('GOT PUTER-EXEC.SUBMISSION.DONE EVENT', event); +}); + +extension.on('request.measured', event => { + console.log('GOT REQUEST.MEASURED EVENT', event); +}); + +extension.on('sns', event => { + console.log('GOT SNS EVENT', event); +}); + +extension.on('template-service.hello', event => { + console.log('GOT TEMPLATE-SERVICE.HELLO EVENT', event); +}); + +extension.on('usages.query', event => { + console.log('GOT USAGES.QUERY EVENT', event); +}); + +extension.on('user.email-changed', event => { + console.log('GOT USER.EMAIL-CHANGED EVENT', event); +}); + +extension.on('user.email-confirmed', event => { + console.log('GOT USER.EMAIL-CONFIRMED EVENT', event); +}); + +extension.on('user.save_account', event => { + console.log('GOT USER.SAVE_ACCOUNT EVENT', event); +}); + +extension.on('web.socket.connected', event => { + console.log('GOT WEB.SOCKET.CONNECTED EVENT', event); +}); + +extension.on('web.socket.user-connected', event => { + console.log('GOT WEB.SOCKET.USER-CONNECTED EVENT', event); +}); + +extension.on('wisp.get-policy', event => { + console.log('GOT WISP.GET-POLICY EVENT', event); +}); diff --git a/mods/mods_enabled/.gitignore b/mods/mods_enabled/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..005717ead0bb8f920c00d76feb8207deb7946a57 --- /dev/null +++ b/mods/mods_enabled/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..c8e12678b22a72c934e1de9c45967ea1e4dcd2a3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,18058 @@ +{ + "name": "puter.com", + "version": "2.5.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "puter.com", + "version": "2.5.1", + "license": "AGPL-3.0-only", + "workspaces": [ + "src/*", + "tools/*", + "experiments/js-parse-and-output" + ], + "dependencies": { + "@anthropic-ai/sdk": "^0.68.0", + "@aws-sdk/client-secrets-manager": "^3.879.0", + "@aws-sdk/client-sns": "^3.907.0", + "@google/genai": "^1.19.0", + "@heyputer/putility": "^1.0.2", + "@paralleldrive/cuid2": "^2.2.2", + "@stylistic/eslint-plugin-js": "^4.4.1", + "dedent": "^1.5.3", + "express-xml-bodyparser": "^0.4.1", + "ioredis": "^5.6.0", + "javascript-time-ago": "^2.5.11", + "json-colorizer": "^3.0.1", + "open": "^10.1.0", + "parse-domain": "^8.2.2", + "simple-git": "^3.25.0", + "string-template": "^1.0.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.35.0", + "@playwright/test": "^1.56.1", + "@stylistic/eslint-plugin": "^5.3.1", + "@types/mime-types": "^3.0.1", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.46.1", + "@typescript-eslint/parser": "^8.46.1", + "@vitest/coverage-v8": "^4.0.14", + "@vitest/ui": "^4.0.14", + "chalk": "^4.1.0", + "clean-css": "^5.3.2", + "dotenv": "^16.4.5", + "eslint": "^9.35.0", + "eslint-rule-composer": "^0.3.0", + "express": "^4.18.2", + "globals": "^15.15.0", + "html-entities": "^2.3.3", + "html-webpack-plugin": "^5.6.0", + "husky": "^9.1.7", + "license-check-and-add": "^4.0.5", + "mocha": "^10.6.0", + "nodemon": "^3.1.0", + "ts-proto": "^2.8.0", + "typescript": "^5.4.5", + "uglify-js": "^3.17.4", + "vite-plugin-static-copy": "^3.1.3", + "vitest": "^4.0.14", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.1", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=20.19.5" + }, + "optionalDependencies": { + "sharp": "^0.34.4", + "sharp-bmp": "^0.1.5", + "sharp-ico": "^0.1.5" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.24", + "dev": true, + "license": "MIT" + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.68.0", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/credential-provider-node": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-compression": "^4.3.12", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-polly": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/credential-provider-node": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/credential-provider-node": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sns": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/credential-provider-node": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-textract": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/credential-provider-node": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/credential-provider-env": "3.940.0", + "@aws-sdk/credential-provider-http": "3.940.0", + "@aws-sdk/credential-provider-login": "3.940.0", + "@aws-sdk/credential-provider-process": "3.940.0", + "@aws-sdk/credential-provider-sso": "3.940.0", + "@aws-sdk/credential-provider-web-identity": "3.940.0", + "@aws-sdk/nested-clients": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/nested-clients": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.940.0", + "@aws-sdk/credential-provider-http": "3.940.0", + "@aws-sdk/credential-provider-ini": "3.940.0", + "@aws-sdk/credential-provider-process": "3.940.0", + "@aws-sdk/credential-provider-sso": "3.940.0", + "@aws-sdk/credential-provider-web-identity": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.940.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/token-providers": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/nested-clients": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.940.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.940.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.940.0", + "@aws-sdk/nested-clients": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.936.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.940.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.940.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.1", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.1", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@canvas/image-data": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.20", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.17.3", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^4.4.1", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/fast-xml-parser": { + "version": "4.5.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gtoken": { + "version": "7.1.0", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/storage/node_modules/strnum": { + "version": "1.1.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@google/genai": { + "version": "1.30.0", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.20.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@google/generative-ai": { + "version": "0.21.0", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.1", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@hapi/b64": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-5.0.0.tgz", + "integrity": "sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/bourne": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz", + "integrity": "sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/cryptiles": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-5.1.0.tgz", + "integrity": "sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "9.x.x" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/iron": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-6.0.0.tgz", + "integrity": "sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "5.x.x", + "@hapi/boom": "9.x.x", + "@hapi/bourne": "2.x.x", + "@hapi/cryptiles": "5.x.x", + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/podium": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-4.1.3.tgz", + "integrity": "sha512-ljsKGQzLkFqnQxE7qeanvgGj4dejnciErYd30dbrYzUOF/FyS/DOF97qcrT3bhoVwCYmxa6PEMhxfCPlnUcD2g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x", + "@hapi/teamwork": "5.x.x", + "@hapi/validate": "1.x.x" + } + }, + "node_modules/@hapi/teamwork": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-5.1.1.tgz", + "integrity": "sha512-1oPx9AE5TIv+V6Ih54RP9lTZBso3rP8j4Xhb6iSVwPXtAM+sDopl5TFMv5Paw73UnpZJ9gjcrTE1BXrWt9eQrg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hapi/validate": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-1.1.3.tgz", + "integrity": "sha512-/XMR0N0wjw0Twzq2pQOzPBZlDzkekGcoCtzO314BpIEsbXdYGthQUbxgkGDf4nhk1+IPDAsXqWjMohRQYO06UA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0" + } + }, + "node_modules/@heyputer/backend": { + "resolved": "src/backend", + "link": true + }, + "node_modules/@heyputer/gui": { + "resolved": "src/gui", + "link": true + }, + "node_modules/@heyputer/kv.js": { + "version": "0.1.92", + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.0" + } + }, + "node_modules/@heyputer/kv.js/node_modules/brace-expansion": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@heyputer/kv.js/node_modules/minimatch": { + "version": "9.0.5", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@heyputer/multest": { + "version": "0.0.2", + "license": "UNLICENSED", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "form-data": "^4.0.0" + } + }, + "node_modules/@heyputer/puter-wisp": { + "resolved": "src/puter-wisp", + "link": true + }, + "node_modules/@heyputer/puter.js": { + "resolved": "src/puter-js", + "link": true + }, + "node_modules/@heyputer/putility": { + "resolved": "src/putility", + "link": true + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jimp/bmp": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "bmp-js": "^0.1.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/core": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "exif-parser": "^0.1.12", + "file-type": "^16.5.4", + "isomorphic-fetch": "^3.0.0", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.6.0" + } + }, + "node_modules/@jimp/core/node_modules/file-type": { + "version": "16.5.4", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/@jimp/core/node_modules/peek-readable": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@jimp/core/node_modules/strtok3": { + "version": "6.3.0", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@jimp/core/node_modules/token-types": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@jimp/custom": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/core": "^0.22.12" + } + }, + "node_modules/@jimp/gif": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "gifwrap": "^0.10.1", + "omggif": "^1.0.9" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/jpeg": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "jpeg-js": "^0.4.4" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "tinycolor2": "^1.6.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-rotate": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-gaussian": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-invert": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-normalize": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "load-bmfont": "^1.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-scale": { + "version": "0.22.12", + "license": "MIT", + "peer": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-shadow": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blur": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-color": ">=0.8.0", + "@jimp/plugin-resize": ">=0.8.0" + } + }, + "node_modules/@jimp/plugins": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/plugin-blit": "^0.22.12", + "@jimp/plugin-blur": "^0.22.12", + "@jimp/plugin-circle": "^0.22.12", + "@jimp/plugin-color": "^0.22.12", + "@jimp/plugin-contain": "^0.22.12", + "@jimp/plugin-cover": "^0.22.12", + "@jimp/plugin-crop": "^0.22.12", + "@jimp/plugin-displace": "^0.22.12", + "@jimp/plugin-dither": "^0.22.12", + "@jimp/plugin-fisheye": "^0.22.12", + "@jimp/plugin-flip": "^0.22.12", + "@jimp/plugin-gaussian": "^0.22.12", + "@jimp/plugin-invert": "^0.22.12", + "@jimp/plugin-mask": "^0.22.12", + "@jimp/plugin-normalize": "^0.22.12", + "@jimp/plugin-print": "^0.22.12", + "@jimp/plugin-resize": "^0.22.12", + "@jimp/plugin-rotate": "^0.22.12", + "@jimp/plugin-scale": "^0.22.12", + "@jimp/plugin-shadow": "^0.22.12", + "@jimp/plugin-threshold": "^0.22.12", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/png": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "pngjs": "^6.0.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/tiff": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "utif2": "^4.0.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/types": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/bmp": "^0.22.12", + "@jimp/gif": "^0.22.12", + "@jimp/jpeg": "^0.22.12", + "@jimp/png": "^0.22.12", + "@jimp/tiff": "^0.22.12", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/utils": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.13.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "1.10.0", + "dependencies": { + "zod": "^3.20.0", + "zod-to-json-schema": "^3.24.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/otlp-proto-exporter-base": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-proto-exporter-base/-/otlp-proto-exporter-base-0.49.1.tgz", + "integrity": "sha512-x1qB4EUC7KikUl2iNuxCkV8yRzrSXSyj4itfpIO674H7dhI7Zv37SFaOJTDN+8Z/F50gF2ISFH9CWQ4KCtGm2A==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/otlp-exporter-base": "0.49.1", + "protobufjs": "^7.2.3" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/otlp-proto-exporter-base/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "node_modules/@opentelemetry/otlp-proto-exporter-base/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.49.1.tgz", + "integrity": "sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/otlp-proto-exporter-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.22.0.tgz", + "integrity": "sha512-CAOgFOKLybd02uj/GhCdEeeBjOS0yeoDeo/CA7ASBSmenpZHAKGB3iDm/rv3BQLcabb/OprDEsSQ1y0P8A7Siw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/propagation-utils": { + "version": "0.30.16", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/propagator-aws-xray": { + "version": "1.26.2", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.36.2", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-aws": { + "version": "1.12.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.0.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-aws/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-aws/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp": { + "version": "0.29.13", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.0.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.40.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@opentelemetry/sql-common/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sql-common/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@pagerduty/pdjs": { + "version": "2.2.4", + "license": "Apache-2.0", + "dependencies": { + "browser-or-node": "^2.0.0", + "cross-fetch": "^3.0.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "dev": true, + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.3", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.18.6", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.6", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.6", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-compression": { + "version": "4.3.13", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.6", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "fflate": "0.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.3.13", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.6", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.13", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.9", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.6", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.9.9", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.6", + "@smithy/middleware-endpoint": "^4.3.13", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.9.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.12", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.9", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.15", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.9", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.6", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.5", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.0", + "@typescript-eslint/types": "^8.47.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin-js": { + "version": "4.4.1", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.122", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bunyan": { + "version": "1.8.9", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "license": "MIT", + "optional": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/chai/node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/connect": { + "version": "3.4.36", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==", + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/hapi__catbox": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/@types/hapi__catbox/-/hapi__catbox-10.2.6.tgz", + "integrity": "sha512-qdMHk4fBlwRfnBBDJaoaxb+fU9Ewi2xqkXD3mNjSPl2v/G/8IJbDpVRBuIcF7oXrcE8YebU5M8cCeKh1NXEn0w==", + "license": "MIT" + }, + "node_modules/@types/hapi__hapi": { + "version": "20.0.13", + "resolved": "https://registry.npmjs.org/@types/hapi__hapi/-/hapi__hapi-20.0.13.tgz", + "integrity": "sha512-LP4IPfhIO5ZPVOrJo7H8c8Slc0WYTFAUNQX1U0LBPKyXioXhH5H2TawIgxKujIyOhbwoBbpvOsBf6o5+ToJIrQ==", + "license": "MIT", + "dependencies": { + "@hapi/boom": "^9.0.0", + "@hapi/iron": "^6.0.0", + "@hapi/podium": "^4.1.3", + "@types/hapi__catbox": "*", + "@types/hapi__mimos": "*", + "@types/hapi__shot": "*", + "@types/node": "*", + "joi": "^17.3.0" + } + }, + "node_modules/@types/hapi__mimos": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/hapi__mimos/-/hapi__mimos-4.1.4.tgz", + "integrity": "sha512-i9hvJpFYTT/qzB5xKWvDYaSXrIiNqi4ephi+5Lo6+DoQdwqPXQgmVVOZR+s3MBiHoFqsCZCX9TmVWG3HczmTEQ==", + "license": "MIT", + "dependencies": { + "@types/mime-db": "*" + } + }, + "node_modules/@types/hapi__shot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/hapi__shot/-/hapi__shot-4.1.6.tgz", + "integrity": "sha512-h33NBjx2WyOs/9JgcFeFhkxnioYWQAZxOHdmqDuoJ1Qjxpcs+JGvSjEEoDeWfcrF+1n47kKgqph5IpfmPOnzbg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/@types/ioredis4": { + "name": "@types/ioredis", + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.14.0.tgz", + "integrity": "sha512-DTDUyznHGNHAl+wd1n0z1jxNajduyTh8R53xoewuerdBzGo6Ogj6F2299BFtrexJw4NtgjsI5SMPCmV9gZwGXA==", + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa__router": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@types/koa__router/-/koa__router-12.0.3.tgz", + "integrity": "sha512-5YUJVv6NwM1z7m6FuYpKfNLTZ932Z6EF6xy2BbtpJSyn13DKNQEkXVffFVSnJHxvwwWh2SAeumpjAYUELqgjyw==", + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz", + "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==", + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "license": "MIT", + "optional": true + }, + "node_modules/@types/memcached": { + "version": "2.2.10", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/@types/mime-db": { + "version": "1.43.6", + "resolved": "https://registry.npmjs.org/@types/mime-db/-/mime-db-1.43.6.tgz", + "integrity": "sha512-r2cqxAt/Eo5yWBOQie1lyM1JZFCiORa5xtLlhSZI0w8RJggBPKw8c4g/fgQCzWydaDR5bL4imnmix2d1n52iBw==", + "license": "MIT" + }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/@types/mysql": { + "version": "2.15.22", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.25", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/pg": { + "version": "8.6.1", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "license": "MIT" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "license": "MIT", + "optional": true + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.14", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.14", + "vitest": "4.0.14" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/chai": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.14", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.14", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.14" + } + }, + "node_modules/@vitest/ui/node_modules/fflate": { + "version": "0.8.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/utils": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "deprecated": "package has been renamed to acorn-import-attributes", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/append-transform": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "license": "ISC" + }, + "node_modules/archy": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/args": { + "version": "5.0.3", + "license": "MIT", + "dependencies": { + "camelcase": "5.0.0", + "chalk": "2.4.2", + "leven": "2.1.0", + "mri": "1.1.4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/args/node_modules/ansi-styles": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/args/node_modules/chalk": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/args/node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/args/node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/args/node_modules/escape-string-regexp": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/args/node_modules/has-flag": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/args/node_modules/supports-color": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcrypt-pbkdf/node_modules/tweetnacl": { + "version": "0.14.5", + "license": "Unlicense" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/bowser": { + "version": "2.13.1", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal": { + "version": "0.0.1", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/caching-transform": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/case-anything": { + "version": "2.1.13", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/centra": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "license": "MIT", + "peer": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chai-as-promised": { + "version": "7.1.2", + "license": "WTFPL", + "dependencies": { + "check-error": "^1.0.2" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "license": "MIT" + }, + "node_modules/clean-css": { + "version": "5.3.3", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone-regexp": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "is-regexp": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "5.0.3", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/comment-parser": { + "resolved": "tools/comment-parser", + "link": true + }, + "node_modules/comment-writer": { + "resolved": "tools/comment-writer", + "link": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/composite-error": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concurrently": { + "version": "8.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "license": "ISC" + }, + "node_modules/console-table-printer": { + "version": "2.15.0", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.1.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/convertapi": { + "version": "1.15.0", + "license": "MIT", + "dependencies": { + "axios": "^1.6.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/corser": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "license": "CC0-1.0" + }, + "node_modules/cssstyle": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-bmp": { + "version": "0.2.1", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/decode-ico": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "decode-bmp": "^0.2.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.4.0", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "5.2.0", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dns2": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/dom-walk": { + "version": "0.1.2" + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dprint-node": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "node_modules/dprint-node/node_modules/detect-libc": { + "version": "1.0.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.4", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-rule-composer": { + "version": "0.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/esm": { + "version": "3.2.25", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-xml-bodyparser": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "xml2js": "^0.6.2" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fflate": { + "version": "0.8.1", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/file-type": { + "version": "18.7.0", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.2", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/file-walker": { + "resolved": "tools/file-walker", + "link": true + }, + "node_modules/fill-keys": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/firebase-admin": { + "version": "13.6.0", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/firebase-admin/node_modules/@types/node": { + "version": "22.19.1", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/firebase-admin/node_modules/google-auth-library": { + "version": "9.15.1", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/firebase-admin/node_modules/gtoken": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/firebase-admin/node_modules/uuid": { + "version": "11.1.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fromentries": { + "version": "1.3.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "0.1.1", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "license": "MIT", + "optional": true + }, + "node_modules/gauge": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/genwiki": { + "resolved": "tools/genwiki", + "link": true + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getopts": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/gifwrap": { + "version": "0.10.1", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/gitignore-to-glob": { + "version": "0.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.4 <5 || >=6.9" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/global": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/agent-base": { + "version": "7.1.4", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-auth-library/node_modules/brace-expansion": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/google-auth-library/node_modules/foreground-child": { + "version": "3.3.1", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "8.1.2", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/glob": { + "version": "10.5.0", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library/node_modules/google-logging-utils": { + "version": "1.1.3", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-auth-library/node_modules/minimatch": { + "version": "9.0.5", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library/node_modules/minipass": { + "version": "7.1.2", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-auth-library/node_modules/rimraf": { + "version": "5.0.10", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library/node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gtoken": { + "version": "7.1.0", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/groq-sdk": { + "version": "0.5.0", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + } + }, + "node_modules/groq-sdk/node_modules/@types/node": { + "version": "18.19.130", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/groq-sdk/node_modules/undici-types": { + "version": "5.26.5", + "license": "MIT" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gtoken/node_modules/agent-base": { + "version": "7.1.4", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gtoken/node_modules/brace-expansion": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/gtoken/node_modules/foreground-child": { + "version": "3.3.1", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "7.1.3", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gtoken/node_modules/glob": { + "version": "10.5.0", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gtoken/node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gtoken/node_modules/minimatch": { + "version": "9.0.5", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gtoken/node_modules/minipass": { + "version": "7.1.2", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "3.3.2", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gtoken/node_modules/rimraf": { + "version": "5.0.10", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gtoken/node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/handlebars": { + "version": "4.7.8", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "license": "ISC" + }, + "node_modules/hasha": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/hi-base32": { + "version": "0.5.1", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "2.2.0", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/mime": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ico-endec": { + "version": "0.1.6", + "license": "MPL-2.0" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/image-q": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ioredis": { + "version": "5.8.2", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ip-regex": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-function": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-ip": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "ip-regex": "^5.0.0", + "super-regex": "^0.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-object": { + "version": "1.0.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbot": { + "version": "3.8.0", + "license": "Unlicense", + "engines": { + "node": ">=12" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "4.0.3", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/uuid": { + "version": "8.3.2", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/javascript-time-ago": { + "version": "2.5.12", + "license": "MIT", + "dependencies": { + "relative-time-format": "^1.1.7" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jimp": { + "version": "0.22.12", + "license": "MIT", + "dependencies": { + "@jimp/custom": "^0.22.12", + "@jimp/plugins": "^0.22.12", + "@jimp/types": "^0.22.12", + "regenerator-runtime": "^0.13.3" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "license": "BSD-3-Clause" + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-sha256": { + "version": "0.9.0", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.23", + "@asamuzakjp/dom-selector": "^6.7.4", + "cssstyle": "^5.3.3", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/json-colorizer": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.20" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jwa": { + "version": "1.4.2", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jssha": { + "version": "3.3.1", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/just-extend": { + "version": "6.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keygen": { + "resolved": "tools/keygen", + "link": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/knex": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/colorette": { + "version": "2.0.19", + "license": "MIT" + }, + "node_modules/knex/node_modules/commander": { + "version": "10.0.1", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/ms": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/knex/node_modules/resolve-from": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/leven": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/license-check-and-add": { + "version": "4.0.5", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "fs-extra": "^8.1.0", + "gitignore-to-glob": "^0.3.0", + "globby": "^10.0.1", + "ignore": "^5.1.2", + "yargs": "^13.3.0" + }, + "bin": { + "license-check-and-add": "dist/src/cli.js" + } + }, + "node_modules/license-check-and-add/node_modules/ansi-regex": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/ansi-styles": { + "version": "3.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/license-check-and-add/node_modules/cliui": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/license-check-and-add/node_modules/color-convert": { + "version": "1.9.3", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/license-check-and-add/node_modules/color-name": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/license-check-and-add/node_modules/emoji-regex": { + "version": "7.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/license-check-and-add/node_modules/find-up": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/fs-extra": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/license-check-and-add/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/license-check-and-add/node_modules/globby": { + "version": "10.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/license-check-and-add/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/license-check-and-add/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/license-check-and-add/node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/license-check-and-add/node_modules/locate-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/license-check-and-add/node_modules/p-locate": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/path-exists": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/license-check-and-add/node_modules/slash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/license-check-and-add/node_modules/string-width": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/strip-ansi": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/license-check-and-add/node_modules/wrap-ansi": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/y18n": { + "version": "4.0.3", + "dev": true, + "license": "ISC" + }, + "node_modules/license-check-and-add/node_modules/yargs": { + "version": "13.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/license-check-and-add/node_modules/yargs-parser": { + "version": "13.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/license-headers": { + "resolved": "tools/license-headers", + "link": true + }, + "node_modules/limiter": { + "version": "1.1.5" + }, + "node_modules/load-bmfont": { + "version": "1.4.2", + "license": "MIT", + "dependencies": { + "buffer-equal": "0.0.1", + "mime": "^1.3.4", + "parse-bmfont-ascii": "^1.0.3", + "parse-bmfont-binary": "^1.0.5", + "parse-bmfont-xml": "^1.1.4", + "phin": "^3.7.1", + "xhr": "^2.0.1", + "xtend": "^4.0.0" + } + }, + "node_modules/load-bmfont/node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "license": "Apache-2.0" + }, + "node_modules/lorem-ipsum": { + "version": "2.0.8", + "license": "ISC", + "dependencies": { + "commander": "^9.3.0" + }, + "bin": { + "lorem-ipsum": "dist/bin/lorem-ipsum.bin.js" + }, + "engines": { + "node": ">= 8.x", + "npm": ">= 5.x" + } + }, + "node_modules/lorem-ipsum/node_modules/commander": { + "version": "9.5.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/migrations-test": { + "resolved": "tools/migrations-test", + "link": true + }, + "node_modules/mime": { + "version": "3.0.0", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-document": { + "version": "2.19.2", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "license": "MIT" + }, + "node_modules/mocha": { + "version": "10.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.6", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "license": "MIT" + }, + "node_modules/module-docgen": { + "resolved": "tools/module-docgen", + "link": true + }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mri": { + "version": "1.1.4", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/multi-progress": { + "version": "4.0.0", + "license": "MIT", + "peerDependencies": { + "progress": "^2.0.0" + } + }, + "node_modules/murmurhash": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "7.14.0", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.3.4", + "file-type": "^16.5.4", + "media-typer": "^1.1.0", + "strtok3": "^6.3.0", + "token-types": "^4.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/music-metadata/node_modules/file-type": { + "version": "16.5.4", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/music-metadata/node_modules/peek-readable": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/music-metadata/node_modules/strtok3": { + "version": "6.3.0", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/music-metadata/node_modules/token-types": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/nan": { + "version": "2.23.1", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "license": "MIT" + }, + "node_modules/nise": { + "version": "5.1.9", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.3.2", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-preload": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nyc": { + "version": "15.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/nyc/node_modules/cliui": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/wrap-ansi": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/y18n": { + "version": "4.0.3", + "dev": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/yargs": { + "version": "15.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/yargs-parser": { + "version": "18.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/omggif": { + "version": "1.0.10", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/open": { + "version": "10.2.0", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "6.9.1", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/opener": { + "version": "1.5.2", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/opentype.js": { + "version": "0.7.3", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.2" + }, + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/otpauth": { + "version": "9.2.4", + "license": "MIT", + "dependencies": { + "jssha": "~3.3.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-hash": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "license": "(MIT AND Zlib)" + }, + "node_modules/param-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "node_modules/parse-bmfont-xml/node_modules/xml2js": { + "version": "0.5.0", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/parse-domain": { + "version": "8.2.2", + "license": "MIT", + "dependencies": { + "is-ip": "^5.0.1" + }, + "bin": { + "parse-domain-update": "bin/update.js" + } + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/peek-readable": { + "version": "5.4.2", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/phin": { + "version": "3.7.1", + "license": "MIT", + "dependencies": { + "centra": "^2.7.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "4.0.2", + "license": "ISC", + "dependencies": { + "pngjs": "^3.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "3.4.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "6.0.0", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-on-spawn": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/prompt-sync": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "strip-ansi": "^5.0.0" + } + }, + "node_modules/prompt-sync/node_modules/ansi-regex": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompt-sync/node_modules/strip-ansi": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/proxyquire": { + "version": "2.1.3", + "license": "MIT", + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/buffer": { + "version": "6.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "license": "MIT" + }, + "node_modules/relateurl": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/relative-time-format": { + "version": "1.1.11", + "license": "MIT" + }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/response-time": { + "version": "2.3.4", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "license": "BlueOak-1.0.0" + }, + "node_modules/saxes": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sharp": { + "version": "0.34.4", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, + "node_modules/sharp-bmp": { + "version": "0.1.5", + "license": "MIT", + "dependencies": { + "bmp-js": "*", + "sharp": "*" + } + }, + "node_modules/sharp-ico": { + "version": "0.1.5", + "license": "MIT", + "dependencies": { + "decode-ico": "*", + "ico-endec": "*", + "sharp": "*" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "license": "BSD-2-Clause" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-git": { + "version": "3.30.0", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/sinon": { + "version": "15.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^10.3.0", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.4", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "dev": true + }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-events": { + "version": "1.0.5", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "license": "MIT", + "optional": true + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-hash": { + "version": "1.1.3", + "license": "CC0-1.0" + }, + "node_modules/string-length": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "7.1.2", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string-template": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.1.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.1.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "license": "MIT", + "optional": true + }, + "node_modules/super-regex": { + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "clone-regexp": "^3.0.0", + "function-timeout": "^0.1.0", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-captcha": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "opentype.js": "^0.7.3" + }, + "engines": { + "node": ">=4.x" + } + }, + "node_modules/svgo": { + "version": "3.3.2", + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/svgo/node_modules/css-select": { + "version": "5.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/css-tree": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domhandler": { + "version": "5.0.3", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "3.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/svgo/node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/svgo/node_modules/mdn-data": { + "version": "2.0.30", + "license": "CC0-1.0" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tarn": { + "version": "3.0.2", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/tiktoken": { + "version": "1.0.22", + "license": "MIT" + }, + "node_modules/tildify": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/timm": { + "version": "1.7.1", + "license": "MIT" + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "dev": true, + "license": "MIT" + }, + "node_modules/to-data-view": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/together-ai": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/together-ai/-/together-ai-0.33.0.tgz", + "integrity": "sha512-2JdxYwbw+Xw2bW2PHBGqbMTtYsQHoWO9UXvdwIfQkde/swoKp2x/hpxEjtTERzrMP4O5SdDPGxsjfcPXewDJ9A==", + "license": "Apache-2.0", + "bin": { + "together-ai": "bin/cli" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-count-accuracy": { + "resolved": "tools/token-count-accuracy", + "link": true + }, + "node_modules/token-types": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-poet": { + "version": "6.12.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.8.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.0.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.8.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "license": "MIT" + }, + "node_modules/union": { + "version": "0.5.0", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/useapi": { + "resolved": "src/useapi", + "link": true + }, + "node_modules/utif2": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.23", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.2.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "fs-extra": "^11.3.2", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vitest": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.14", + "@vitest/mocker": "4.0.14", + "@vitest/pretty-format": "4.0.14", + "@vitest/runner": "4.0.14", + "@vitest/snapshot": "4.0.14", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.14", + "@vitest/browser-preview": "4.0.14", + "@vitest/browser-webdriverio": "4.0.14", + "@vitest/ui": "4.0.14", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack": { + "version": "5.103.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.26.3", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-cli/node_modules/interpret": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/winston": { + "version": "3.18.3", + "license": "MIT", + "peer": true, + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "4.7.1", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^2.0.1", + "triple-beam": "^1.3.0", + "winston-transport": "^4.4.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-daily-rotate-file/node_modules/object-hash": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/workerpool": { + "version": "6.5.1", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xhr": { + "version": "2.6.0", + "license": "MIT", + "dependencies": { + "global": "~4.4.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.6.2", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs-unparser/node_modules/decamelize": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.0", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "src/backend": { + "name": "@heyputer/backend", + "version": "2.5.1", + "license": "AGPL-3.0-only", + "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.940.0", + "@aws-sdk/client-polly": "^3.622.0", + "@aws-sdk/client-textract": "^3.621.0", + "@google/generative-ai": "^0.21.0", + "@heyputer/kv.js": "^0.1.9", + "@heyputer/multest": "^0.0.2", + "@heyputer/putility": "^1.0.0", + "@mistralai/mistralai": "^1.3.4", + "@opentelemetry/api": "^1.4.1", + "@opentelemetry/auto-instrumentations-node": "^0.43.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.40.0", + "@opentelemetry/sdk-metrics": "^1.14.0", + "@opentelemetry/sdk-node": "^0.49.1", + "@pagerduty/pdjs": "^2.2.4", + "@smithy/node-http-handler": "^2.2.2", + "args": "^5.0.3", + "axios": "^1.8.2", + "bcrypt": "^5.1.0", + "better-sqlite3": "^11.9.0", + "busboy": "^1.6.0", + "chai-as-promised": "^7.1.1", + "clean-css": "^5.3.2", + "composite-error": "^1.0.2", + "compression": "^1.7.4", + "convertapi": "^1.15.0", + "cookie-parser": "^1.4.6", + "dedent": "^1.5.3", + "dns2": "^2.1.0", + "express": "^4.18.2", + "file-type": "^18.5.0", + "firebase-admin": "^13.3.0", + "form-data": "^4.0.0", + "groq-sdk": "^0.5.0", + "handlebars": "^4.7.8", + "helmet": "^7.0.0", + "hi-base32": "^0.5.1", + "html-entities": "^2.3.3", + "is-glob": "^4.0.3", + "isbot": "^3.7.1", + "jimp": "^0.22.8", + "js-sha256": "^0.9.0", + "json5": "^2.2.3", + "jsonwebtoken": "^9.0.0", + "knex": "^3.1.0", + "lorem-ipsum": "^2.0.8", + "lru-cache": "^11.0.2", + "micromatch": "^4.0.5", + "mime-types": "^2.1.35", + "moment": "^2.29.4", + "morgan": "^1.10.0", + "multer": "^2.0.2", + "multi-progress": "^4.0.0", + "murmurhash": "^2.0.1", + "music-metadata": "^7.14.0", + "nodemailer": "^6.9.3", + "on-finished": "^2.4.1", + "openai": "^6.7.0", + "otpauth": "9.2.4", + "prompt-sync": "^4.2.0", + "proxyquire": "^2.1.3", + "recursive-readdir": "^2.2.3", + "response-time": "^2.3.2", + "seedrandom": "^3.0.5", + "sharp": "^0.34.3", + "sharp-bmp": "^0.1.5", + "sharp-ico": "^0.1.5", + "socket.io": "^4.6.2", + "socket.io-client": "^4.6.2", + "ssh2": "^1.13.0", + "string-hash": "^1.1.3", + "string-length": "^6.0.0", + "svg-captcha": "^1.4.0", + "svgo": "^3.0.2", + "tiktoken": "^1.0.16", + "together-ai": "^0.33.0", + "tweetnacl": "^1.0.3", + "ua-parser-js": "^1.0.38", + "uglify-js": "^3.17.4", + "uuid": "^9.0.0", + "validator": "^13.9.0", + "winston": "^3.9.0", + "winston-daily-rotate-file": "^4.7.1", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/node": "^20.5.3", + "chai": "^4.3.7", + "jsdom": "^27.2.0", + "mocha": "^10.2.0", + "nodemon": "^3.1.0", + "nyc": "^15.1.0", + "sinon": "^15.2.0", + "typescript": "^5.9.3", + "vitest": "^4.0.14" + } + }, + "src/backend/node_modules/@opentelemetry/api": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", + "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/api-logs": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.49.1.tgz", + "integrity": "sha512-kaNl/T7WzyMUQHQlVq7q0oV4Kev6+0xFwqzofryC66jgGMacd0QH5TwfpbUwSTby+SdAdprAe5UKMvBw4tKS5Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/backend/node_modules/@opentelemetry/auto-instrumentations-node": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.43.0.tgz", + "integrity": "sha512-2WvHUSi/QVeVG8ObPD0Ls6WevfIbQjspxIQRuHaQFWXhmEwy/MsEcoQUjbNKXwO5516aS04GTydKEoRKsMwhdA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/instrumentation-amqplib": "^0.35.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.39.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.39.1", + "@opentelemetry/instrumentation-bunyan": "^0.36.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.36.0", + "@opentelemetry/instrumentation-connect": "^0.34.0", + "@opentelemetry/instrumentation-cucumber": "^0.4.0", + "@opentelemetry/instrumentation-dataloader": "^0.7.0", + "@opentelemetry/instrumentation-dns": "^0.34.0", + "@opentelemetry/instrumentation-express": "^0.36.1", + "@opentelemetry/instrumentation-fastify": "^0.34.0", + "@opentelemetry/instrumentation-fs": "^0.10.0", + "@opentelemetry/instrumentation-generic-pool": "^0.34.0", + "@opentelemetry/instrumentation-graphql": "^0.38.1", + "@opentelemetry/instrumentation-grpc": "^0.49.1", + "@opentelemetry/instrumentation-hapi": "^0.35.0", + "@opentelemetry/instrumentation-http": "^0.49.1", + "@opentelemetry/instrumentation-ioredis": "^0.38.0", + "@opentelemetry/instrumentation-knex": "^0.34.0", + "@opentelemetry/instrumentation-koa": "^0.38.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.35.0", + "@opentelemetry/instrumentation-memcached": "^0.34.0", + "@opentelemetry/instrumentation-mongodb": "^0.41.0", + "@opentelemetry/instrumentation-mongoose": "^0.36.0", + "@opentelemetry/instrumentation-mysql": "^0.36.0", + "@opentelemetry/instrumentation-mysql2": "^0.36.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.35.0", + "@opentelemetry/instrumentation-net": "^0.34.0", + "@opentelemetry/instrumentation-pg": "^0.39.1", + "@opentelemetry/instrumentation-pino": "^0.36.0", + "@opentelemetry/instrumentation-redis": "^0.37.0", + "@opentelemetry/instrumentation-redis-4": "^0.37.0", + "@opentelemetry/instrumentation-restify": "^0.36.0", + "@opentelemetry/instrumentation-router": "^0.35.0", + "@opentelemetry/instrumentation-socket.io": "^0.37.0", + "@opentelemetry/instrumentation-tedious": "^0.8.0", + "@opentelemetry/instrumentation-winston": "^0.35.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.28.7", + "@opentelemetry/resource-detector-aws": "^1.4.0", + "@opentelemetry/resource-detector-container": "^0.3.7", + "@opentelemetry/resource-detector-gcp": "^0.29.7", + "@opentelemetry/resources": "^1.12.0", + "@opentelemetry/sdk-node": "^0.49.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.4.1" + } + }, + "src/backend/node_modules/@opentelemetry/context-async-hooks": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.22.0.tgz", + "integrity": "sha512-Nfdxyg8YtWqVWkyrCukkundAjPhUXi93JtVQmqDT1mZRVKqA7e2r7eJCrI+F651XUBMp0hsOJSGiFk3QSpaIJw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/core": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.14.0.tgz", + "integrity": "sha512-MnMZ+sxsnlzloeuXL2nm5QcNczt/iO82UOeQQDHhV83F2fP3sgntW2evvtoxJki0MBLxEsh5ADD7PR/Hn5uzjw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "src/backend/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.14.0.tgz", + "integrity": "sha512-rJfCY8rCWz3cb4KI6pEofnytvMPuj3YLQwoscCCYZ5DkdiPjo15IQ0US7+mjcWy9H3fcZIzf2pbJZ7ck/h4tug==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.40.0.tgz", + "integrity": "sha512-/UW/6s1WBHkFgdwizouUCEGZPt7NE0Y5xpuFuHqQF/KyjcHzTWibXzB/XWOSS81X55FUxrI3Icoeptk7vtxJFQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.14.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.40.0", + "@opentelemetry/otlp-transformer": "0.40.0", + "@opentelemetry/resources": "1.14.0", + "@opentelemetry/sdk-trace-base": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.49.1.tgz", + "integrity": "sha512-KOLtZfZvIrpGZLVvblKsiVQT7gQUZNKcUUH24Zz6Xbi7LJb9Vt6xtUZFYdR5IIjvt47PIqBKDWUQlU0o1wAsRw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/otlp-exporter-base": "0.49.1", + "@opentelemetry/otlp-transformer": "0.49.1", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.49.1.tgz", + "integrity": "sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.49.1.tgz", + "integrity": "sha512-Z+koA4wp9L9e3jkFacyXTGphSWTbOKjwwXMpb0CxNb0kjTHGUxhYRN8GnkLFsFo5NbZPjP07hwAqeEG/uCratQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.49.1", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-logs": "0.49.1", + "@opentelemetry/sdk-metrics": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.22.0.tgz", + "integrity": "sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-logs": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.49.1.tgz", + "integrity": "sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.9.0", + "@opentelemetry/api-logs": ">=0.39.1" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.22.0.tgz", + "integrity": "sha512-k6iIx6H3TZ+BVMr2z8M16ri2OxWaljg5h8ihGJxi/KQWcjign6FEaEzuigXt5bK9wVEhqAcWLCfarSftaNWkkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "lodash.merge": "^4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.22.0.tgz", + "integrity": "sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.49.1.tgz", + "integrity": "sha512-n8ON/c9pdMyYAfSFWKkgsPwjYoxnki+6Olzo+klKfW7KqLWoyEkryNkbcMIYnGGNXwdkMIrjoaP0VxXB26Oxcg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/otlp-exporter-base": "0.49.1", + "@opentelemetry/otlp-proto-exporter-base": "0.49.1", + "@opentelemetry/otlp-transformer": "0.49.1", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.49.1.tgz", + "integrity": "sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.49.1.tgz", + "integrity": "sha512-Z+koA4wp9L9e3jkFacyXTGphSWTbOKjwwXMpb0CxNb0kjTHGUxhYRN8GnkLFsFo5NbZPjP07hwAqeEG/uCratQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.49.1", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-logs": "0.49.1", + "@opentelemetry/sdk-metrics": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.22.0.tgz", + "integrity": "sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-logs": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.49.1.tgz", + "integrity": "sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.9.0", + "@opentelemetry/api-logs": ">=0.39.1" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.22.0.tgz", + "integrity": "sha512-k6iIx6H3TZ+BVMr2z8M16ri2OxWaljg5h8ihGJxi/KQWcjign6FEaEzuigXt5bK9wVEhqAcWLCfarSftaNWkkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "lodash.merge": "^4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.22.0.tgz", + "integrity": "sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-zipkin": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.22.0.tgz", + "integrity": "sha512-XcFs6rGvcTz0qW5uY7JZDYD0yNEXdekXAb6sFtnZgY/cHY6BQ09HMzOjv9SX+iaXplRDcHr1Gta7VQKM1XXM6g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/resources": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.22.0.tgz", + "integrity": "sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.22.0.tgz", + "integrity": "sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.49.1.tgz", + "integrity": "sha512-0DLtWtaIppuNNRRllSD4bjU8ZIiLp1cDXvJEbp752/Zf+y3gaLNaoGRGIlX4UHhcsrmtL+P2qxi3Hodi8VuKiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.49.1", + "@types/shimmer": "^1.0.2", + "import-in-the-middle": "1.7.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.35.0.tgz", + "integrity": "sha512-rb3hIWA7f0HXpXpfElnGC6CukRxy58/OJ6XYlTzpZJtNJPao7BuobZjkQEscaRYhUzgi7X7R1aKkIUOTV5JFrg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-aws-lambda": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.39.0.tgz", + "integrity": "sha512-D+oG/hIBDdwCNq7Y6BEuddjcwDVD0C8NhBE7A85mRZ9RLG0bKoWrhIdVvbpqEoa0U5AWe9Y98RX4itNg7WTy4w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/propagator-aws-xray": "^1.3.1", + "@opentelemetry/resources": "^1.8.0", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/aws-lambda": "8.10.122" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-aws-sdk": { + "version": "0.39.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.39.1.tgz", + "integrity": "sha512-QnvIMVpzRYqQHSXydGUksbhBjPbMyHSUBwi6ocN7gEXoI711+tIY3R1cfRutl0u3M67A/fAvPI3IgACfJaFORg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/propagation-utils": "^0.30.7", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-bunyan": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.36.0.tgz", + "integrity": "sha512-sHD5BSiqSrgWow7VmugEFzV8vGdsz5m+w1v9tK6YwRzuAD7vbo57chluq+UBzIqStoCH+0yOzRzSALH7hrfffg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.49.1", + "@opentelemetry/instrumentation": "^0.49.1", + "@types/bunyan": "1.8.9" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-cassandra-driver": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.36.0.tgz", + "integrity": "sha512-gMfxzryOIP/mvSLXBJp/QxSr2NvS+cC1dkIXn+aSOzYoU1U3apeF3nAyuikmY9dRCQDV7wHPslqbi+pCmd4pAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.34.0.tgz", + "integrity": "sha512-PJO99nfyUp3JSoBMhwZsOQDm/XKfkb/QQ8YTsNX4ZJ28phoRcNLqe36mqIMp80DKmKAX4xkxCAyrSYtW8QqZxA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/connect": "3.4.36" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-cucumber": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.4.0.tgz", + "integrity": "sha512-n53QvozzgMS9imEclow2nBYJ/jtZlZqiKIqDUi2/g0nDi08F555JhDS03d/Z+4NJxbu7bDLAg12giCV9KZN/Jw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.7.0.tgz", + "integrity": "sha512-sIaevxATJV5YaZzBTTcTaDEnI+/1vxYs+lVk1honnvrEAaP0FA9C/cFrQEN0kP2BDHkHRE/t6y5lGUqusi/h3A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-dns": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.34.0.tgz", + "integrity": "sha512-3tmXdvrzHQ7S3v82Cm36PTYLtgg2+hVm00K1xB3uzP08GEo9w/F8DW4me9z6rDroVGiLIg621RZ6dzjBcmmFCg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-express": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.36.1.tgz", + "integrity": "sha512-ltIE4kIMa+83QjW/p7oe7XCESF29w3FQ9/T1VgShdX7fzm56K2a0xfEX1vF8lnHRGERYxIWX9D086C6gJOjVGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-fastify": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.34.0.tgz", + "integrity": "sha512-2Qu66XBkfJ8tr6H+RHBTyw/EX73N9U7pvNa49aonDnT9/mK58k7AKOscpRnKXOvHqc2YIdEPRcBIWxhksPFZVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.10.0.tgz", + "integrity": "sha512-XtMoNINVsIQTQHjtxe7A0Lng96wxA5DSD5CYVVvpquG6HJRdZ4xNe9DTU03YtoEFqlN9qTfvGb/6ILzhKhiG8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.34.0.tgz", + "integrity": "sha512-jdI7tfVVwZJuTu4j2kAvJtx4wlEQKIXSZnZG4RdqRHc56KqQQDuVTBLvUgmDXvnSVclH9ayf4oaAV08R9fICtw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.38.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.38.1.tgz", + "integrity": "sha512-mSt4ztn3EVlLtZJ+tDEqq5GUEYdY8cbTT9SeVJFmXSfdSQkPZn0ovo/dRe6dUcplM60gg4w+llw8SZuQN0iZfQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-grpc": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.49.1.tgz", + "integrity": "sha512-f8mQjFi5/PiP4SK3VDU1/3sUUgs6exMtBgcnNycgCKgN40htiPT+MuDRwdRnRMNI/4vNQ7p1/5r4Q5oN0GuRBw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "0.49.1", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.35.0.tgz", + "integrity": "sha512-j7q99aTLHfjNKW94qJnEaDatgz+q2psTKs7lxZO4QHRnoDltDk39a44/+AkI1qBJNw5xyLjrApqkglfbWJ2abg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/hapi__hapi": "20.0.13" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-http": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.49.1.tgz", + "integrity": "sha512-Yib5zrW2s0V8wTeUK/B3ZtpyP4ldgXj9L3Ws/axXrW1dW0/mEFKifK50MxMQK9g5NNJQS9dWH7rvcEGZdWdQDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/instrumentation": "0.49.1", + "@opentelemetry/semantic-conventions": "1.22.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.38.0.tgz", + "integrity": "sha512-c9nQFhRjFAtpInTks7z5v9CiOCiR8U9GbIhIv0TLEJ/r0wqdKNLfLZzCrr9XQ9WasxeOmziLlPFhpRBAd9Q4oA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/redis-common": "^0.36.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/ioredis4": "npm:@types/ioredis@^4.28.10" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.34.0.tgz", + "integrity": "sha512-6kZOEvNJOylTQunU5zSSi4iTuCkwIL9nwFnZg7719p61u3d6Qj3X4xi9su46VE3M0dH7vEoxUW+nb/0ilm+aZg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.38.0.tgz", + "integrity": "sha512-lQujF4I3wdcrOF14miCV2pC72H+OJKb2LrrmTvTDAhELQDN/95v0doWgT9aHybUGkaAeB3QG4d09sved548TlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/koa": "2.14.0", + "@types/koa__router": "12.0.3" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.35.0.tgz", + "integrity": "sha512-wCXe+iCF7JweMgY3blLM2Y1G0GSwLEeSA61z/y1UwzvBLEEXt7vL6qOl2mkNcUL9ZbLDS+EABatBH+vFO6DV5Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-memcached": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.34.0.tgz", + "integrity": "sha512-RleFfaag3Evg4pTzHwDBwo1KiFgnCtiT4V6MQRRHadytNGdpcL+Ynz32ydDdiOXeadt7xpRI7HSvBy0quGTXSw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/memcached": "^2.2.6" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.41.0.tgz", + "integrity": "sha512-DlSH0oyEuTW5gprCUppb0Qe3pK3cpUUFW5eTmayWNyICI1LFunwtcrULTNv6UiThD/V5ykAf/GGGEa7KFAmkog==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/sdk-metrics": "^1.9.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.36.0.tgz", + "integrity": "sha512-UelQ8dLQRLTdck3tPJdZ17b+Hk9usLf1cY2ou5THAaZpulUdpg62Q9Hx2RHRU71Rp2/YMDk25og7GJhuWScfEA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.36.0.tgz", + "integrity": "sha512-2mt/032SLkiuddzMrq3YwM0bHksXRep69EzGRnBfF+bCbwYvKLpqmSFqJZ9T3yY/mBWj+tvdvc1+klXGrh2QnQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/mysql": "2.15.22" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.36.0.tgz", + "integrity": "sha512-F63lKcl/R+if2j5Vz66c2/SLXQEtLlFkWTmYb8NQSgmcCaEKjML4RRRjZISIT4IBwdpanJ2qmNuXVM6MYqhBXw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@opentelemetry/sql-common": "^0.40.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-nestjs-core": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.35.0.tgz", + "integrity": "sha512-INKA7CIOteTSRVxP7SQaFby11AYU3uezI93xDaDRGY4TloXNVoyw5n6UmcVJU4yDn6xY2r7zZ2SVHvblUc21/g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-net": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.34.0.tgz", + "integrity": "sha512-gjybNOQQqbXmD1qVHNO2qBJI4V6p3QQ7xKg3pnC/x7wRdxn+siLQj7QIVxW85C3mymngoJJdRs6BwI3qPUfsPQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.39.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.39.1.tgz", + "integrity": "sha512-pX5ujDOyGpPcrZlzaD3LJzmyaSMMMKAP+ffTHJp9vasvZJr+LifCk53TMPVUafcXKV/xX/IIkvADO+67M1Z25g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@opentelemetry/sql-common": "^0.40.0", + "@types/pg": "8.6.1", + "@types/pg-pool": "2.0.4" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-pino": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.36.0.tgz", + "integrity": "sha512-oEz+BJEYRBMAUu7MVJFJhhlsBuwLaUGjbJciKZRIeGX+fUtgcbQGV+a2Ris9jR3yFzWZrYg0aNBSCbGqvPCtMQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.37.0.tgz", + "integrity": "sha512-9G0T74kheu37k+UvyBnAcieB5iowxska3z2rhUcSTL8Cl0y/CvMn7sZ7txkUbXt0rdX6qeEUdMLmbsY2fPUM7Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/redis-common": "^0.36.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-redis-4": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.37.0.tgz", + "integrity": "sha512-WNO+HALvPPvjbh7UEEIuay0Z0d2mIfSCkBZbPRwZttDGX6LYGc2WnRgJh3TnYqjp7/y9IryWIbajAFIebj1OBA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/redis-common": "^0.36.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-restify": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.36.0.tgz", + "integrity": "sha512-QbOh8HpnnRn4xxFXX77Gdww6M78yx7dRiIKR6+H3j5LH5u6sYckTXw3TGPSsXsaM4DQHy0fOw15sAcJoWkC+aQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-router": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.35.0.tgz", + "integrity": "sha512-MdxGJuNTIy/2qDI8yow6cRBQ87m6O//VuHIlawe8v0x1NsTOSwS72xm+BzTuY9D0iMqiJUiTlE3dBs8DA91MTw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-socket.io": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.37.0.tgz", + "integrity": "sha512-aIztxmx/yis/goEndnoITrZvDDr1GdCtlsWo9ex7MhUIjqq5nJbTuyigf3GmU86XFFhSThxfQuJ9DpJyPxfBfA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.8.0.tgz", + "integrity": "sha512-BBRW8+Qm2PLNkVMynr3Q7L4xCAOCOs0J9BJIJ8ZGoatW42b2H4qhMhq35jfPDvEL5u5azxHDapmUVYrDJDjAfA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/tedious": "^4.0.10" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/instrumentation-winston": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.35.0.tgz", + "integrity": "sha512-ymcuA3S2flnLmH1GS0105H91iDLap8cizOCaLMCp7Xz7r4L+wFf1zfix9M+iSkxcPFshHRt8LFA/ELXw51nk0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.49.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "src/backend/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.40.0.tgz", + "integrity": "sha512-AUmMUPM1/oYGbOWYRBBQz4Ic/adMYA/mIMnAy+QAEmCzjBIC/fyRReVhJmF2cpkvYh7QOkX3017zl2dgWLHpvQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.40.0.tgz", + "integrity": "sha512-rgfyCofGMpou1OsCF1fNr/2iBzgeZj3rjplEBi0yfX6s3nNcJ6ZfhDvyblKG6dd/UydPSHYAtFAstZwwuucFJA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.14.0", + "@opentelemetry/otlp-exporter-base": "0.40.0", + "protobufjs": "^7.2.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.40.0.tgz", + "integrity": "sha512-YrJgVVAsJHibENSbYmC1x+5jAmkAGZ9yrgmHxc6IyqM3D1mryhqBvMRDD31JoavPYelkS7dmrXWM8g7swX0B+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.40.0", + "@opentelemetry/core": "1.14.0", + "@opentelemetry/resources": "1.14.0", + "@opentelemetry/sdk-logs": "0.40.0", + "@opentelemetry/sdk-metrics": "1.14.0", + "@opentelemetry/sdk-trace-base": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, + "src/backend/node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.40.0.tgz", + "integrity": "sha512-8WRuvGnfnbeR9ifGjLN8kklk2fkd0gBT6aN7NHO9zeYF/6qacAViD3bwAKqGXKnJgl39l1EU41I9diqUjamEEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/backend/node_modules/@opentelemetry/propagator-b3": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.22.0.tgz", + "integrity": "sha512-qBItJm9ygg/jCB5rmivyGz1qmKZPsL/sX715JqPMFgq++Idm0x+N9sLQvWFHFt2+ZINnCSojw7FVBgFW6izcXA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.22.0.tgz", + "integrity": "sha512-pMLgst3QIwrUfepraH5WG7xfpJ8J3CrPKrtINK0t7kBkuu96rn+HDYQ8kt3+0FXvrZI8YJE77MCQwnJWXIrgpA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/resource-detector-alibaba-cloud": { + "version": "0.28.10", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.28.10.tgz", + "integrity": "sha512-TZv/1Y2QCL6sJ+X9SsPPBXe4786bc/Qsw0hQXFsNTbJzDTGGUmOAlSZ2qPiuqAd4ZheUYfD+QA20IvAjUz9Hhg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/resources": "^1.0.0", + "@opentelemetry/semantic-conventions": "^1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/resource-detector-container": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.3.11.tgz", + "integrity": "sha512-22ndMDakxX+nuhAYwqsciexV8/w26JozRUV0FN9kJiqSWtA1b5dCVtlp3J6JivG5t8kDN9UF5efatNnVbqRT9Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/resources": "^1.0.0", + "@opentelemetry/semantic-conventions": "^1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/resources": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.14.0.tgz", + "integrity": "sha512-qRfWIgBxxl3z47E036Aey0Lj2ZjlFb27Q7Xnj1y1z/P293RXJZGLtcfn/w8JF7v1Q2hs3SDGxz7Wb9Dko1YUQA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.14.0", + "@opentelemetry/semantic-conventions": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "src/backend/node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.14.0.tgz", + "integrity": "sha512-rJfCY8rCWz3cb4KI6pEofnytvMPuj3YLQwoscCCYZ5DkdiPjo15IQ0US7+mjcWy9H3fcZIzf2pbJZ7ck/h4tug==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-logs": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.40.0.tgz", + "integrity": "sha512-/JG7DOLo/Y3VR9azPXlXNRGQff3gp7nQbWl5cFD2SmlYqUrzMq1OjbksZLVztDu1+ynbFunseUG11SxhoxvSRg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.14.0", + "@opentelemetry/resources": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.5.0", + "@opentelemetry/api-logs": ">=0.39.1" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.14.0.tgz", + "integrity": "sha512-F0JXmLqT4LmsaiaE28fl0qMtc5w0YuMWTHt1hnANTNX8hxW4IKSv9+wrYG7BZd61HEbPm032Re7fXyzzNA6nIw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.14.0", + "@opentelemetry/resources": "1.14.0", + "lodash.merge": "4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.49.1.tgz", + "integrity": "sha512-feBIT85ndiSHXsQ2gfGpXC/sNeX4GCHLksC4A9s/bfpUbbgbCSl0RvzZlmEpCHarNrkZMwFRi4H0xFfgvJEjrg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.49.1", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.49.1", + "@opentelemetry/exporter-trace-otlp-http": "0.49.1", + "@opentelemetry/exporter-trace-otlp-proto": "0.49.1", + "@opentelemetry/exporter-zipkin": "1.22.0", + "@opentelemetry/instrumentation": "0.49.1", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-logs": "0.49.1", + "@opentelemetry/sdk-metrics": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0", + "@opentelemetry/sdk-trace-node": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.49.1.tgz", + "integrity": "sha512-Zbd7f3zF7fI2587MVhBizaW21cO/SordyrZGtMtvhoxU6n4Qb02Gx71X4+PzXH620e0+JX+Pcr9bYb1HTeVyJA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.49.1", + "@opentelemetry/otlp-transformer": "0.49.1", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.49.1.tgz", + "integrity": "sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.49.1.tgz", + "integrity": "sha512-DNDNUWmOqtKTFJAyOyHHKotVox0NQ/09ETX8fUOeEtyNVHoGekAVtBbvIA3AtK+JflP7LC0PTjlLfruPM3Wy6w==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/otlp-exporter-base": "0.49.1", + "protobufjs": "^7.2.3" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.49.1.tgz", + "integrity": "sha512-Z+koA4wp9L9e3jkFacyXTGphSWTbOKjwwXMpb0CxNb0kjTHGUxhYRN8GnkLFsFo5NbZPjP07hwAqeEG/uCratQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.49.1", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-logs": "0.49.1", + "@opentelemetry/sdk-metrics": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/resources": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.22.0.tgz", + "integrity": "sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-logs": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.49.1.tgz", + "integrity": "sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.9.0", + "@opentelemetry/api-logs": ">=0.39.1" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.22.0.tgz", + "integrity": "sha512-k6iIx6H3TZ+BVMr2z8M16ri2OxWaljg5h8ihGJxi/KQWcjign6FEaEzuigXt5bK9wVEhqAcWLCfarSftaNWkkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "lodash.merge": "^4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.22.0.tgz", + "integrity": "sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.14.0.tgz", + "integrity": "sha512-NzRGt3PS+HPKfQYMb6Iy8YYc5OKA73qDwci/6ujOIvyW9vcqBJSWbjZ8FeLEAmuatUB5WrRhEKu9b0sIiIYTrQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.14.0", + "@opentelemetry/resources": "1.14.0", + "@opentelemetry/semantic-conventions": "1.14.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.14.0.tgz", + "integrity": "sha512-rJfCY8rCWz3cb4KI6pEofnytvMPuj3YLQwoscCCYZ5DkdiPjo15IQ0US7+mjcWy9H3fcZIzf2pbJZ7ck/h4tug==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.22.0.tgz", + "integrity": "sha512-gTGquNz7ue8uMeiWPwp3CU321OstQ84r7PCDtOaCicjbJxzvO8RZMlEC4geOipTeiF88kss5n6w+//A0MhP1lQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.22.0", + "@opentelemetry/core": "1.22.0", + "@opentelemetry/propagator-b3": "1.22.0", + "@opentelemetry/propagator-jaeger": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/resources": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.22.0.tgz", + "integrity": "sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.22.0.tgz", + "integrity": "sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.9.0" + } + }, + "src/backend/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.22.0.tgz", + "integrity": "sha512-CAOgFOKLybd02uj/GhCdEeeBjOS0yeoDeo/CA7ASBSmenpZHAKGB3iDm/rv3BQLcabb/OprDEsSQ1y0P8A7Siw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "src/backend/node_modules/@smithy/abort-controller": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "src/backend/node_modules/@smithy/node-http-handler": { + "version": "2.5.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "src/backend/node_modules/@smithy/protocol-http": { + "version": "3.3.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "src/backend/node_modules/@smithy/querystring-builder": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.12.0", + "@smithy/util-uri-escape": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "src/backend/node_modules/@smithy/types": { + "version": "2.12.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "src/backend/node_modules/@smithy/util-uri-escape": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "src/backend/node_modules/import-in-the-middle": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.7.1.tgz", + "integrity": "sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.8.2", + "acorn-import-assertions": "^1.9.0", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "src/emulator": { + "version": "1.0.0", + "extraneous": true, + "license": "AGPL-3.0-only", + "dependencies": { + "brotli-dec-wasm": "^2.3.0", + "copy-webpack-plugin": "^12.0.2" + }, + "devDependencies": { + "html-webpack-plugin": "^5.6.0" + } + }, + "src/gui": { + "name": "@heyputer/gui", + "version": "2.4.0", + "license": "AGPL-3.0-only", + "workspaces": [ + "src/*" + ], + "dependencies": { + "json-colorizer": "^3.0.1", + "string-template": "^1.0.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.1.1", + "chai": "^4.3.7", + "chalk": "^4.1.0", + "clean-css": "^5.3.2", + "dotenv": "^16.4.5", + "eslint": "^9.1.1", + "express": "^4.18.2", + "globals": "^15.0.0", + "html-entities": "^2.3.3", + "jsdom": "^21.1.0", + "nodemon": "^3.1.0", + "sinon": "^15.0.1", + "uglify-js": "^3.17.4", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.1" + } + }, + "src/gui/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "src/gui/node_modules/cssstyle": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/gui/node_modules/data-urls": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/gui/node_modules/diff": { + "version": "7.0.0", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "src/gui/node_modules/jsdom": { + "version": "21.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.2", + "acorn-globals": "^7.0.0", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "src/gui/node_modules/nise": { + "version": "6.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "src/gui/node_modules/parse5": { + "version": "7.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "src/gui/node_modules/path-to-regexp": { + "version": "8.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "src/gui/node_modules/rrweb-cssom": { + "version": "0.6.0", + "dev": true, + "license": "MIT" + }, + "src/gui/node_modules/sinon": { + "version": "19.0.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "src/gui/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "src/gui/node_modules/tough-cookie": { + "version": "4.1.4", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "src/gui/node_modules/tr46": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/gui/node_modules/universalify": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "src/gui/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/gui/node_modules/webidl-conversions": { + "version": "7.0.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "src/gui/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "src/gui/node_modules/whatwg-url": { + "version": "12.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "src/gui/node_modules/xml-name-validator": { + "version": "4.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "src/parsers": { + "name": "@heyputer/parsers", + "version": "1.0.0", + "extraneous": true, + "license": "AGPL-3.0-only" + }, + "src/phoenix": { + "name": "@heyputer/phoenix", + "version": "0.0.0", + "extraneous": true, + "license": "AGPL-3.0-only", + "workspaces": [ + "packages/pty", + "packages/strataparse", + "packages/contextlink" + ], + "dependencies": { + "@pkgjs/parseargs": "^0.11.0", + "capture-console": "^1.0.2", + "chronokinesis": "^6.0.0", + "cli-columns": "^4.0.0", + "columnify": "^1.6.0", + "fs-mode-to-string": "^0.0.2", + "json-query": "^2.2.2", + "path-browserify": "^1.0.1", + "sinon": "^17.0.1" + }, + "devDependencies": { + "copy-webpack-plugin": "^12.0.2", + "mocha": "^10.8.2", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.1" + }, + "optionalDependencies": { + "node-pty": "^1.0.0" + } + }, + "src/pty": { + "name": "dev-pty", + "version": "0.0.0", + "extraneous": true, + "license": "AGPL-3.0-only" + }, + "src/puter-js": { + "name": "@heyputer/puter.js", + "version": "2.1.15", + "license": "Apache-2.0", + "dependencies": { + "@heyputer/kv.js": "^0.2.1" + }, + "devDependencies": { + "concurrently": "^8.2.2", + "http-server": "^14.1.1", + "webpack-cli": "^5.1.4" + } + }, + "src/puter-js/node_modules/@heyputer/kv.js": { + "version": "0.2.1", + "license": "MIT" + }, + "src/puter-wisp": { + "name": "@heyputer/puter-wisp", + "version": "1.0.0", + "license": "AGPL-3.0-only" + }, + "src/putility": { + "name": "@heyputer/putility", + "version": "1.1.1", + "license": "MIT" + }, + "src/terminal": { + "name": "@heyputer/terminal", + "version": "0.0.0", + "extraneous": true, + "license": "AGPL-3.0-only", + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-image": "^0.8.0", + "@xterm/xterm": "^5.5.0" + }, + "devDependencies": { + "copy-webpack-plugin": "^12.0.2", + "http-server": "^14.1.1", + "mocha": "^10.8.2", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.1" + } + }, + "src/useapi": { + "version": "1.0.0", + "license": "AGPL-3.0-only" + }, + "tools/comment-parser": { + "version": "1.0.0", + "license": "AGPL-3.0-only", + "devDependencies": { + "chai": "^5.1.1" + } + }, + "tools/comment-parser/node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "tools/comment-parser/node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "tools/comment-parser/node_modules/check-error": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "tools/comment-parser/node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "tools/comment-parser/node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "tools/comment-parser/node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "tools/comment-writer": { + "version": "1.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "axios": "^1.7.8", + "console-table-printer": "^2.12.1", + "dedent": "^1.5.3", + "diff-match-patch": "^1.0.5", + "enquirer": "^2.4.1", + "js-levenshtein": "^1.1.6", + "word-wrap": "^1.2.5", + "yaml": "^2.4.5" + } + }, + "tools/file-walker": { + "version": "1.0.0", + "license": "AGPL-3.0-only" + }, + "tools/genwiki": { + "version": "0.0.0", + "license": "AGPL-3.0-only" + }, + "tools/keygen": { + "version": "1.0.0", + "license": "AGPL-3.0-only" + }, + "tools/license-headers": { + "version": "1.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "console-table-printer": "^2.12.1", + "dedent": "^1.5.3", + "diff-match-patch": "^1.0.5", + "enquirer": "^2.4.1", + "js-levenshtein": "^1.1.6", + "yaml": "^2.4.5" + } + }, + "tools/migrations-test": { + "version": "1.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "commander": "^12.1.0" + } + }, + "tools/migrations-test/node_modules/commander": { + "version": "12.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "tools/module-docgen": { + "version": "1.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/traverse": "^7.25.9", + "dedent": "^1.5.3", + "doctrine": "^3.0.0" + } + }, + "tools/token-count-accuracy": { + "version": "1.0.0", + "license": "AGPL-3.0-only" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f09c8c7ba3fdee7fd2f3b4739e0420083dffbd39 --- /dev/null +++ b/package.json @@ -0,0 +1,99 @@ +{ + "name": "puter.com", + "version": "2.5.1", + "author": "Puter Technologies Inc.", + "license": "AGPL-3.0-only", + "description": "Desktop environment in the browser!", + "homepage": "https://puter.com", + "type": "module", + "main": "exports.js", + "directories": { + "lib": "lib" + }, + "devDependencies": { + "@eslint/js": "^9.35.0", + "@playwright/test": "^1.56.1", + "@stylistic/eslint-plugin": "^5.3.1", + "@types/mime-types": "^3.0.1", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.46.1", + "@typescript-eslint/parser": "^8.46.1", + "@vitest/coverage-v8": "^4.0.14", + "@vitest/ui": "^4.0.14", + "chalk": "^4.1.0", + "clean-css": "^5.3.2", + "dotenv": "^16.4.5", + "eslint": "^9.35.0", + "eslint-rule-composer": "^0.3.0", + "express": "^4.18.2", + "globals": "^15.15.0", + "html-entities": "^2.3.3", + "html-webpack-plugin": "^5.6.0", + "husky": "^9.1.7", + "license-check-and-add": "^4.0.5", + "mocha": "^10.6.0", + "nodemon": "^3.1.0", + "ts-proto": "^2.8.0", + "typescript": "^5.4.5", + "uglify-js": "^3.17.4", + "vite-plugin-static-copy": "^3.1.3", + "vitest": "^4.0.14", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.1", + "yaml": "^2.8.1" + }, + "scripts": { + "test": "npx vitest run --config=src/backend/vitest.config.ts && node src/backend/tools/test.mjs", + "test:puterjs-api": "vitest run tests/puterJsApiTests", + "test:backend": "npm run build:ts; vitest run --config=src/backend/vitest.config.ts", + "test:backend-coverage": "npm run build:ts; vitest run --config=src/backend/vitest.config.ts", + "start=gui": "nodemon --exec \"node dev-server.js\" ", + "start": "node ./tools/run-selfhosted.js", + "prestart": "npm run build:ts", + "dev": "npm run build:ts && DEVCONSOLE=1 node ./tools/run-selfhosted.js", + "build": "npx eslint --quiet -c eslint/mandatory.eslint.config.js src/backend/src extensions && npm run build:ts && cd src/gui && node ./build.js", + "check-translations": "node tools/check-translations.js", + "prepare": "husky", + "build:ts": "tsc -p tsconfig.build.json", + "gen": "./scripts/gen.sh" + }, + "workspaces": [ + "src/*", + "tools/*", + "experiments/js-parse-and-output" + ], + "nodemonConfig": { + "ext": "js, json, mjs, jsx, svg, css", + "ignore": [ + "./dist/", + "./node_modules/" + ] + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.68.0", + "@aws-sdk/client-secrets-manager": "^3.879.0", + "@aws-sdk/client-sns": "^3.907.0", + "@google/genai": "^1.19.0", + "@heyputer/putility": "^1.0.2", + "@paralleldrive/cuid2": "^2.2.2", + "@stylistic/eslint-plugin-js": "^4.4.1", + "dedent": "^1.5.3", + "express-xml-bodyparser": "^0.4.1", + "ioredis": "^5.6.0", + "javascript-time-ago": "^2.5.11", + "json-colorizer": "^3.0.1", + "open": "^10.1.0", + "parse-domain": "^8.2.2", + "simple-git": "^3.25.0", + "string-template": "^1.0.0", + "uuid": "^9.0.1" + }, + "optionalDependencies": { + "sharp": "^0.34.4", + "sharp-bmp": "^0.1.5", + "sharp-ico": "^0.1.5" + }, + "engines": { + "node": ">=20.19.5" + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000000000000000000000000000000000000..04bedee4024a963f1e0805f51b2f963991d501d3 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "nightly" +components = [ "rustc", "rust-std" ] +targets = [ "wasm32-unknown-unknown", "i686-unknown-linux-gnu" ] +profile = "minimal" diff --git a/scripts/gen.sh b/scripts/gen.sh new file mode 100644 index 0000000000000000000000000000000000000000..4c5982086a1fd15cd774014dd655dec39bbdb6fc --- /dev/null +++ b/scripts/gen.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + + +protoc \ + -I=src/backend/src/filesystem/definitions/proto \ + --plugin=protoc-gen-ts_proto=$(npm root)/.bin/protoc-gen-ts_proto \ + --ts_proto_out=src/backend/src/filesystem/definitions/ts \ + --ts_proto_opt=esModuleInterop=true,outputServices=none,outputJsonMethods=true,useExactTypes=false,snakeToCamel=false \ + src/backend/src/filesystem/definitions/proto/fsentry.proto \ No newline at end of file diff --git a/src/backend/.gitignore b/src/backend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ea181823d998f06226e4624b4bc5c63f2ffd6d05 --- /dev/null +++ b/src/backend/.gitignore @@ -0,0 +1,151 @@ +# MAC OS hidden directory settings file +.DS_Store + +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# End of https://www.toptal.com/developers/gitignore/api/node +public/.DS_Store +*.zip +*.pem +public/.DS_Store +public/.DS_Store +public/.DS_Store +./build +build + +# config file +volatile/ +ssl +ssl/ +keys +*.code-workspace + +# credentials +creds* + +# thumbnai-service +thumbnail-service + +# init sql generated from ./run.sh +init.sql \ No newline at end of file diff --git a/src/backend/CONTRIBUTING.md b/src/backend/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..abdc15946f548d7df148f20ef608d45a01f74e71 --- /dev/null +++ b/src/backend/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to Puter's Backend + +## File Structure + + + +## Architecture + +- [boot sequence](./doc/contributors/boot-sequence.md) +- [modules and services](./doc/contributors/modules.md) + +## Features + +- [protected apps](./doc/features/protected-apps.md) +- [service scripts](./doc/features/service-scripts.md) + +## Lists of Things + +- [list of permissions](./doc/lists-of-things/list-of-permissions.md) + +## Code-First Approach + +If you prefer to understand a system by looking at the +first files which are invoked and starting from there, +here's a handy list! + +- [Kernel](./src/Kernel.js), despite its intimidating name, is a + relatively simple (< 200 LOC) class which loads the modules + (modules register services), and then starts all the services. +- [RuntimeEnvironment](./src/boot/RuntimeEnvironment.js) + sets the configuration and runtime directories. It's invoked by Kernel. +- The default setup for running a self-hosted Puter loads these modules: + - [CoreModule](./src/CoreModule.js) + - [DatabaseModule](./src/DatabaseModule.js) + - [LocalDiskStorageModule](./src/LocalDiskStorageModule.js) +- HTTP endpoints are registered with + [WebServerService](./src/services/WebServerService.js) + by these services: + - [ServeGUIService](./src/services/ServeGUIService.js) + - [PuterAPIService](./src/services/PuterAPIService.js) + - [FilesystemAPIService](./src/services/FilesystemAPIService.js) + +## Development Philosophies + +### The copy-paste rule + +If you're copying and pasting code, you need to ask this question: +- am I copying as a reference (i.e. how this function is used), +- or am I copying an implementation of actual behavior? + +If your answer is the first, you should find more than one piece of +code that's doing the same thing you want to do and see if any of them +are doing it differently. One of the ways of doing this thing is going +to be more recent and/or (yes, potentially "or") more correct. +More correct approaches are ones which reduce +[coupling](https://en.wikipedia.org/wiki/Coupling_(computer_programming)), +move from legacy implementations to more recent ones, and are actually +more convenient for you to use. Whenever ever any of these three things +are in contention it's very important to communicate this to the +appropriate maintainers and contributors. + +If your answer is the second, you should find a way to +[DRY that code](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself). + +### Architecture Mistakes? You will make them and it will suck. + +In my experience, the harder I think about the correct way to implement +something, the bigger a mistake I'm going to make; ***unless*** a big part +of the reason I'm thinking so hard is because I want to find a solution +that reduces complexity and has the right maintenance trade-off. +There's no easy solution for this so just keep it in mind; there are some +things we might write 2 times, 3 times, even more times over before we +really get it right and *that's okay*; sometimes part of doing useful work is +doing the useless work that reveals what the useful work is. + +## Underlying Constructs + +- [putility's README.md](../putility/README.md) + - Whenever you see `AdvancedBase`, that's from here + - Many things in backend extend this. Anything that doesn't only doesn't + because it was written before `AdvancedBase` existed. + - Allows adding "traits" to classes + - Have you ever wanted to wrap every method of a class with + common behavior? This can do that! diff --git a/src/backend/README.md b/src/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ac1ce7b104f0fa65b3bedf8f39496c6684dfca0b --- /dev/null +++ b/src/backend/README.md @@ -0,0 +1,47 @@ +# Puter Backend + +_Part of a High-Level Distributed Operating System_ + +Whether or not you call Puter an operating system +(we call it a "high-level distributed operating system"), +**operating systems for devices** +are a useful reference point to describe the architecture of Puter. +If Puter's "hardware" is services, and Puter's "userspace" is the +client side of the API, then Puter's "kernel" is the backend. + +Puter's backend is composed of: +- The **Kernel** class, which is responsible for initialization +- A number of **Modules** which are registered in **Kernel** for a customized + Puter instance. +- Many **Services** which are contained inside modules. + +## Documentation + +- [Backend File Structure](./doc/contributors/structure.md) +- [Boot Sequence](./doc/contributors/boot-sequence.md) +- [Kernel](./doc/Kernel.md) +- [Modules](./doc/contributors/modules.md) + +## Can I use Puter's Backend Alone? + +Puter's backend is not dependent on Puter's frontned. In fact, you could +prevent Puter's GUI from ever showing up by disabling PuterHomepageModule. +Similarly, you can run Puter's backend with no modules loaded for a completely +blank slate, or only include CoreModule and WebModule to quickly build your +own backend that's compatible with any of Puter's services. + +## What can it do? + +Puter's Kernel only initializes modules, nothing more. The modules bring a lot +of capabilities to the table, however. Within this directory you'll find modules that: +- coerce all the well-known AI services to a common interface +- manage authentication with Wisp servers (this brings TCP to the browser!) +- manage apps on Puter +- allow a user to host websites from Puter +- provide persistent key-value storage to Puter's desktop and apps +- provide a fast filesystem implementation +- communicate with other instances of Puter's backend, + secured with elliptic curve cryptography +- provide more services like converting files and compiling low-level code. + +![diagram of Puter backend connections](./doc/assets/puter-backend-map.drawio.png) diff --git a/src/backend/doc/A-and-A/auth.md b/src/backend/doc/A-and-A/auth.md new file mode 100644 index 0000000000000000000000000000000000000000..8f4ce07804771a3d0a6bb0002ed52dfe0aeea7e5 --- /dev/null +++ b/src/backend/doc/A-and-A/auth.md @@ -0,0 +1,63 @@ +# Authentication Documentation + +## Concepts + +### Actor + +An "Actor" is an entity that can be authenticated. The following types of +actors are currently supported by Puter: +- **UserActorType** - represents a user and is identified by a user's UUID +- **AppUnderUserActorType** - represents an app running in an iframe from a + `puter.site` domain or another origin and is identified by a user's UUID + and an app's UUID together. +- **AccessTokenActorType** - not widely currently, but Puter supports + a concept called "access tokens". Any user can create an access token and + then grant any permissions they want to that access token. The access + token will have those permissions granted provided that the user who + created the access token does as well (via permission cascade) +- **SiteActorType** - represents a `puter.site` website accessing Puter's API. +- **SystemActorType** - internal representation of the actor during a privileged + backend operation. This actor cannot be authenticated in a request. + This actor does not represent the `system` user. + +### Token + +- **Legacy** - legacy tokens result in an error response +- **Session** - this token is a JWT with a claim for the UUID of an entry in + server memory or the database that we call a "session". This entry associates + the token to a user and some metadata for security auditing purposes. + Revoking the session entry disables the token. + This type of token resolves to an actor with **UserActorType**. +- **AppUnderUser** - this token is a JWT with a claim for an app UUID and a + claim for a session UUID. + Revoking the session entry disables the token. + This type of token resolves to an actor with **AppUnderUserActorType**. +- **AccessToken** - this token is a JWT with three claims: + - A session UUID + - An optional App UUID + - A UUID representing the access token for permission associations + The session or session+app creates a **UserActorType** or + **AppUnderUserActorType** actor respectively. This actor is called + the "authorizor". This actor is aggregated by an **AccessTokenActorType** + actor which becomes the effective actor for a request. +- **ActorSite** - this token is a JWT with a claim for a site UID. + The site UID is associated with an origin, generally a `puter.site` + subdomain. + +## Components + +### Auth Middleware + +There have so far been three iterations of the authentication middleware: +- `src/backend/src/middleware/auth.js` +- `src/backend/src/middleware/auth2.js` +- `src/backend/src/middleware/configurable_auth.js` + +The newest implementation is `configurable_auth` and eventually the other +two will be removed. There is no legacy behavior involved: +- `auth` was rewritten to use `auth2` +- `auth2` was rewritten to use `configurable_auth` + +The `configurable_auth` middleware accepts a parameter that can be specified +if an endpoint is optionally authenticated. In this case, the request's +`actor` will be `undefined` if there was no information for authentication. diff --git a/src/backend/doc/A-and-A/permission.md b/src/backend/doc/A-and-A/permission.md new file mode 100644 index 0000000000000000000000000000000000000000..aa4c20384d6031279e09a0c6c563ce65a123fa1f --- /dev/null +++ b/src/backend/doc/A-and-A/permission.md @@ -0,0 +1,179 @@ +# Permission Documentation + +## Concepts + +### Permission + +A permission is a string composed of colon-delimited components which identifies +a resource or functionality to which access can be controlled. + +For example, `fs:e8ac2973-287b-4121-a75d-7e0619eb8e87:read` is a permission which +represents reading the file or directory with UUID `e8ac2973-287b-4121-a75d-7e0619eb8e87`. + +### Group + +A group has an owner and several member users. An owner decides what users are in the +group and what users are not. Any user can grant permissions to the group. + +### Granting & Revoking + +Granting is the act of creating a permission association to a user or group from +the current user. A permission association also holds an object called `extra` +which holds additional claims associated with the permission association. +These are arbitrary and can be used in any way by the subsystem or extension that +is checking the permission. `extra` is usually just an empty object. + +Revoking is the act of removing a permission association. + +### Permission Options + +Permission options are an association between a permission and an actor that can not +be revoked by another actor. For example, the user `ed` always has access to files +under `/ed`. The user `system` always has all permissions granted. These can also be +considered "terminals" because they will always be at +the end of a pathway through granted permissions between users. +This are also called "implied" permissions because they are implied by the system. + +### Permission Pathways + +A permission pathway is the path between users or groups that leads to a permission. + +For example, `ed` can grant the permission `a:b` to `fred`, then `fred` can grant +that permission to the group `cool_group`, and then `alice` may be in the group +`cool_group`. Assuming `ed` holds the implied permission `a:b`, a permission path +exists between `alice` and `ed` via `cool_group` and `fred`: + +``` +alice <--<> cool_group <-- fred <-- ed (a:b) +``` + +If any link in this chain breaks the permission is effectively revoked from `alice` +unless there is another pathway leading to a valid permission option for `a:b`. + +### Reading - AKA Permission Scan Result + +A permission reading is a JSON-serializable object which contains all the pathways +a specified actor has to permissions options matching the specified permission strings. + +The following is an example reading for the user `ed3` on the permission +`fs:24729b88-a4c5-4990-ad4e-272b87895732:read`. This file is owned by the +user `admin` who shared it with `ed3`. + +``` +[ + { + "$": "explode", + "from": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read", + "to": [ + "fs:24729b88-a4c5-4990-ad4e-272b87895732:read", + "fs:24729b88-a4c5-4990-ad4e-272b87895732:write", + "fs:24729b88-a4c5-4990-ad4e-272b87895732", + "fs" + ] + }, + { + "$": "path", + "via": "user", + "has_terminal": true, + "permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read", + "data": {}, + "holder_username": "ed3", + "issuer_username": "admin", + "reading": [ + { + "$": "explode", + "from": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read", + "to": [ + "fs:24729b88-a4c5-4990-ad4e-272b87895732:read", + "fs:24729b88-a4c5-4990-ad4e-272b87895732:write", + "fs:24729b88-a4c5-4990-ad4e-272b87895732", + "fs" + ] + }, + { + "$": "option", + "permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732:read", + "source": "implied", + "by": "is-owner", + "data": {} + }, + { + "$": "option", + "permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732:write", + "source": "implied", + "by": "is-owner", + "data": {} + }, + { + "$": "option", + "permission": "fs:24729b88-a4c5-4990-ad4e-272b87895732", + "source": "implied", + "by": "is-owner", + "data": {} + }, + { + "$": "time", + "value": 19 + } + ] + }, + { + "$": "time", + "value": 20 + } +] +``` + +Each object in the reading has a property named `$` which is the type for the object. +The most fundamental types for permission readings are `path` and `option`. A path +always contains another reading, which contains more paths or options. An option +specifies the permission string, the name of the rule that granted the permission, +and a data object which may hold additional claims. + +Readings begin with an `explode` if there are multiple strings that may grant the +permission. + +Readings end with a `time` that repots how long the reading took to help manage +the potential performance impact of complex permission graphs. + +## Permission Service + +### check(actor, permissions) + +Returns true if the current actor has a path to any permission options matching +any of the permission strings specified by `permissions`. This is done by invoking +`scan()` and returning `true` if there are more than 0 permission options. + +### scan(actor, permissions) + +Returns a "reading". A permission reading is a JSON-serializable structure. +Readings are described above. + +## Permission Scan Sequence + +The `scan()` method of **PermissionService** invokes the permission scan sequence. +The permission scan sequence is a [Sequence](https://github.com/HeyPuter/puter/blob/0e0bfd6d7c92eed5080518a099c9a66a2f2dc9ec/src/backend/src/codex/Sequence.js) +that is defined in [scan-permission.js](src/backend/src/structured/sequence/scan-permission.js). +It invokes many "permission scanners" which are defined in +[permission-scanners.js](src/backend/src/unstructured/permission-scanners.js) + +The Permission Scan Sequence is as follows: +- `grant_if_system` - if system user, push an option to the reading and stop +- `rewrite_permission` - process the permission through any permission string + rewriters that were registered with PermissionService by other services. + For example, since path-based file permissions aren't currently supported + the FilesystemService regsiters a rewriter that converts any `fs:/` + permission into a corresponding UUID permission. +- `explode_permission` - break the permission into multiple permissions + than are sufficient to grant the permission being scanned. For example if + there are multiple components, like `a.b.c`, having either permission `a.b` or + `a` granted implis having `a.b.c` granted. Other services can also register + "permission exploders" which handle non-hierarchical cases such as + `fs:AAAA:write` implying `fs:AAAA:read`. +- `run_scanners` - run the permission scanners. + +Each permission scanner has a name, documentation text, and a scan function. +The scan function has access to the scan sequence's context and can push +objects onto the permission reading. + +For information on individual scanners, refer to permission-scanners.js. diff --git a/src/backend/doc/Kernel.md b/src/backend/doc/Kernel.md new file mode 100644 index 0000000000000000000000000000000000000000..5f55984809db9ca1118f6887dec96b0c067d9eaa --- /dev/null +++ b/src/backend/doc/Kernel.md @@ -0,0 +1,65 @@ +# Puter Kernel Documentation + +## Overview + +The **Puter Kernel** is the core runtime component of the Puter system. It provides the foundational infrastructure for: + +- Initializing the runtime environment +- Managing internal and external modules (extensions) +- Setting up and booting core services +- Configuring logging and debugging utilities +- Integrating with third-party modules and performing dependency installs at runtime + +This kernel is responsible for orchestrating the startup sequence and ensuring that all necessary services, modules, and environmental configurations are properly loaded before the application enters its operational state. + +--- + +## Features + +1. **Modular Architecture**: + The Kernel supports both internal and external modules: + - **Internal Modules**: Provided to Kernel by an initializing script, such + as `tools/run-selfhosted.js`, via the `add_module()` method. + - **External Modules**: Discovered in configured module directories and installed + dynamically. This includes resolving and executing `package.json` entries and + running `npm install` as needed. + +2. **Service Container & Registry**: + The Kernel initializes a service container that manages a wide range of services. Services can: + - Register modules + - Initialize dependencies + - Emit lifecycle events (`boot.consolidation`, `boot.activation`, `boot.ready`) to + orchestrate a stable and consistent environment. + +3. **Runtime Environment Setup**: + The Kernel sets up a `RuntimeEnvironment` to determine configuration paths and environment parameters. It also provides global helpers like `kv` for key-value storage and `cl` for simplified console logging. + +4. **Logging and Debugging**: + Uses a temporary `BootLogger` for the initialization phase until LogService is + initialized, at which point it will replace the boot logger. Debugging features + (`ll`, `xtra_log`) are enabled in development environments for convenience. + +## Initialization & Boot Process + +1. **Constructor**: + When a Kernel instance is created, it sets up basic parameters, initializes an empty + module list, and prepares `useapi()` integration. + +2. **Booting**: + The `boot()` method: + - Parses CLI arguments using `yargs`. + - Calls `_runtime_init()` to set up the `RuntimeEnvironment` and boot logger. + - Initializes global debugging/logging utilities. + - Sets up the service container (usually called `services`c instance of **Container**). + - Invokes module installation and service bootstrapping processes. + +3. **Module Installation**: + Internal modules are registered and installed first. + External modules are discovered, packaged, installed, and their code is executed. + External modules are given a special context with access to `useapi()`, a dynamic + import mechanism for Puter modules and extensions. + +4. **Service Bootstrapping**: + After modules and extensions are installed, services are initialized and activated. + For more information about how this works, see [boot-sequence.md](./contributors/boot-sequence.md). + diff --git a/src/backend/doc/README.md b/src/backend/doc/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fd0ba2c4405371c5691aec7f8dcd734e8fd8d998 --- /dev/null +++ b/src/backend/doc/README.md @@ -0,0 +1,19 @@ +## Backend - Contributor Documentation + +### Where to Start + +Start with [Backend File Structure](./contributors/structure.md). + +There also also some videos. In one of the videos Eric does a +Steve Ballmer impression so it's definitely worth it. +- [Services and Modules in Puter](https://www.youtube.com/watch?v=TOeS67QXMVU) +- [Puter's Boot Sequence](https://www.youtube.com/watch?v=a8bOLNnW1Uo) +- [Building a Driver on Puter](https://www.youtube.com/watch?v=8znQmrKgNxA) + +### Index + +- [Backend File Structure](./contributors/structure.md) +- [Boot Sequence](./contributors/boot-sequence.md) +- [Kernel](./Kernel.md) +- [Modules](./contributors/modules.md) +- [Configuring Logs](./log_config.md) diff --git a/src/backend/doc/assets/puter-backend-map.drawio.png b/src/backend/doc/assets/puter-backend-map.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..58bc569512400839755f4e227c18b9d5426ec344 --- /dev/null +++ b/src/backend/doc/assets/puter-backend-map.drawio.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79845d82c3fb42cc349e4edbe4b6e8986332ffeac9cdc572d40261c497cfd091 +size 167708 diff --git a/src/backend/doc/contributors/boot-sequence.md b/src/backend/doc/contributors/boot-sequence.md new file mode 100644 index 0000000000000000000000000000000000000000..4937fb1c74933699b0e7bef1c810185ab242f4eb --- /dev/null +++ b/src/backend/doc/contributors/boot-sequence.md @@ -0,0 +1,93 @@ +# Puter Backend Boot Sequence + +This document describes the boot sequence of Puter's backend. + +**Runtime Environment** + - Configuration directory is determined + - Runtime directory is determined + - Mod directory is determined + - Services are instantiated + +**Construction** + - Data structures are created + +**Initialization** + - Registries are populated + - Services prepare for next phase + +**Consolidation** + - Service event bus receives first event (`boot.consolidation`) + - Services perform coordinated setup behaviors + - Services prepare for next phase + +**Activation** + - Blocking listeners of `boot.consolidation` have resolved + - HTTP servers start listening + +**Ready** + - Services are informed that Puter is providing service + +## Boot Phases + +### Construction + +Services implement a method called `construct` which initializes members +of an instance. Services do not override the class constructor of +**BaseService**. This makes it possible to use the `new` operator without +invoking a service's constructor behavior during debugging. + +The first phase of the boot sequence, "construction", is simply a loop to +call `construct` on all registered services. + +The `_construct` override should not: +- call other services +- emit events + +### Initialization + +At initialization, the `init()` method is called on all services. +The `_init` override can be used to: +- register information with other services, when services don't + need to register this information in a specific sequence. + An example of this is registering commands with CommandService. +- perform setup that is required before the consolidation phase starts. + +### Consolidation + +Consolidation is a phase where services should emit events that +are related to bringing up the system. For example, WebServerService +('web-server') emits an event telling services to install middlewares, +and later emits an event telling services to install routes. + +Consolidation starts when Kernel emits `boot.consolidation` to the +services event bus, which happens after `init()` resolves for all +services. + +### Activation + +Activation is a phase where services begin listening on external +interfaces. For example, this is when the web server starts listening. + +Activation starts when Kernel emits `boot.activation`. + +### Ready + +Ready is a phase where services are informed that everything is up. + +Ready starts when Kernel emits `boot.ready`. + +## Events and Asynchronous Execution + +The services event bus is implemented so you can `await` a call to `.emit()`. +Event listeners can choose to have blocking behavior by returning a promise. + +During emission of a particular event, listeners of this event will not +block each other, but all listeners must resolve before the call to +`.emit()` is resolved. (i.e. `emit` uses `Promise.all`) + +## Legacy Services + +Some services were implemented before the `BaseService` class - which +implements the `init` method - was created. These services are called +"legacy services" and they are instantiated _after_ initialization but +_before_ consolidation. diff --git a/src/backend/doc/contributors/coding-style.md b/src/backend/doc/contributors/coding-style.md new file mode 100644 index 0000000000000000000000000000000000000000..590c3c209c2fb9298e42acd0335c58d4d690f0f8 --- /dev/null +++ b/src/backend/doc/contributors/coding-style.md @@ -0,0 +1,212 @@ +# Backend Style + +## File Structure + +### Copyright Notice + +All files should begin with the standard copyright notice: + +```javascript +/* + * Copyright (C) 2025-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +``` + +### Imports + +```javascript +const express = require('express'); +const passport = require('passport'); + +const { get_user } = require("../../helpers"); +const BaseService = require("../../services/BaseService"); +const config = require("../../config"); + +const path = require('path'); +const fs = require('fs'); +``` + +Import order is generally: +1. Third party dependencies. Having these occur first makes it easy to quickly + determine what this source file is likely to be responsible for. +2. Files within the module. +3. Standard library, "builtins" + +## Code Formatting + +### Indentation and Spacing + +```javascript +const fn = async () => { + const a = 5; // Spaces between operators + + // Note: "=" in for loop initializer does not require space around + // Note: operators in condition part have space around + for ( let i=0; i < 10; i++ ) { + console.log('hello'); + } + + // Control structures have space inside parenthesis + for ( const thing of stuff ) { + // NOOP + } + + // Function calls do not have space inside parenthesis + await something(1, 2); +} +``` + +- Use 4 spaces for indentation. +- Use spaces around operators (`=`, `+`, etc.); not required in + for loop initializer. +- Use a space after keywords like `if`, `for`, `while`, etc. + ```javascript + return [1,2,3]; // Sure + return[1,2,4]; // Definitely not + ``` +- Use spaces between parenthesis in control structures unless + parenthesis are empty. + ```javascript + if ( a === b ) { + return null; + } + ``` +- No trailing whitespace at the end of lines +- Use a space after commas in arrays and objects +- Empty blocks should have the comment `// NOOP` within braces + +### Line Length + +- Try to keep lines under 100 characters for better readability + - Try to keep them under 80, but this is not always practical +- For long function calls or objects, break them into multiple lines + + +### Trailing Commas + +```javascript +// This is great +{ + "apple", + "banana", + "cactus", // <-- Good! +} + +// This is also fine +[ + 1, 2, 3, + 4, 5, 6, + 7, 8, 9, +] + +[ + something(), + another_thing(), + the_last_thing() // <-- Nope, please add trailing comma! +] +``` + +We use trailing commas where applicable because it's easier to re-order +lines, especially when using vim motions. + +### Braces and Blocks + +- Single statement blocks must either be on the same line as + the corresponding control structure, or surrounding by braces: + ```javascript + if ( a === b ) return null; // Sure + if ( a === b ) + return null; // Please no 🤮 + if ( a === b ) { + return null; // Nice + } + ``` +- Opening braces go on the same line as the statement +- Put a space before the opening brace + + +## Naming Conventions + +### Variables + +- Variables are generally in camelCase +- Variables might have a prefix_beforeThem + +```javascript +const svc_systemData = this.services.get('system-data'); +const svc_su = this.services.get('su'); +effective_policy = await svc_su.sudo(async () => { + return await svc_systemData.interpret(effective_policy.data); +}); +``` + +In the example above we see the `svc_` prefix is used to indicate a +reference to a backend service. The name of the service is `system-data` +which is not a valid identifier, so we use `svc_systemData` for our +variable name. + +### Classes + +- Use PascalCase for class names +- Use snake_case for class methods +- Instance variables are often `snake_case` because it's easier to + read. `camelCase` is acceptable too. +- Instance variables only used internally should have a + `trailing_underscore_` even if in `camelCase_`. We avoid using + `#privateProperties` because it unnecessarily inhibits debugging + and patching. + +### File Names + +- Use PascalCase for class files (e.g., `UserService.js`) +- Use kebab-case for non-class files (e.g., `auth-helper.js`) + +## Documentation + +### JSDoc Comments + +- Backend services (classes extending `BaseService`) should have JSDoc comments +- Public methods of backend services should have JSDoc comments +- Include parameter descriptions, return values, and examples where appropriate + +```javascript +/** + * @class UserService + * @description Service for managing user operations + */ + +/** + * Get a user by their ID + * @param {string} id - The user ID + * @returns {Promise} The user object + * @throws {Error} If user not found + */ +async function getUserById(id) { + // ... +} +``` + +### Inline Comments + +- Use inline comments to explain complex logic +- Prefix comments with tags like `track:` to indicate specific purposes + +```javascript +// track: slice a prefix +const uid = uid_part.slice('uid#'.length); +``` diff --git a/src/backend/doc/contributors/modules.md b/src/backend/doc/contributors/modules.md new file mode 100644 index 0000000000000000000000000000000000000000..8a3ac543bad45690a8c4fd64f340446b93b68d89 --- /dev/null +++ b/src/backend/doc/contributors/modules.md @@ -0,0 +1,103 @@ +# Puter Kernel Moduels and Services + +## Modules + +A Puter kernel module is simply a collection of services that run when +the module is installed. You can find an example of this in the +`run-selfhosted.js` script at the root of the Puter monorepo. + +Here is the relevant excerpt in `run-selfhosted.js` at the time of +writing this documentation: + +```javascript +const { + Kernel, + CoreModule, + DatabaseModule, + LocalDiskStorageModule, + SelfHostedModule +} = (await import('@heyputer/backend')).default; + +const k = new Kernel(); +k.add_module(new CoreModule()); +k.add_module(new DatabaseModule()); +k.add_module(new LocalDiskStorageModule()); +k.add_module(new SelfHostedModule()); +k.boot(); +``` + +A few modules are added to Puter before booting. If you want to install +your own modules into Puter you can edit this file for self-hosted runs +or create your own script that boots Puter. This makes it possible to +have deployments of Puter with custom functionality. + +To function properly, Puter needs **CoreModule**, a database module, +and a storage module. + +A module extends +[AdvancedBase](../../../putility/README.md) +and implements +an `install` method. The install method has one parameter, a +[Context](../../src/util/context.js) +object containing all the values kernel modules have access to. This +includes the `services` +[Container](../../src/services/Container.js`). + +A module adds services to Puter.eA typical module may look something +like this: + +```javascript +class MyPuterModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const MyService = require('./path/to/MyService.js'); + services.registerService('my-service', MyService, { + some_options: 'for-my-service', + }); + } +} +``` + +## Services + +Services extend +[BaseService](../../src/services/BaseService.js) +and provide additional functionality for Puter. They can add HTTP +endpoints and register objects with other services. + +When implementing a service it is important to understand +Puter's [boot sequence](./boot-sequence.md) + +A typical service may look like this: + +```javascript +class MyService extends BaseService { + static MODULES = { + // Use node's `require` function to populate this object; + // this makes these available to `this.require` and offers + // dependency-injection for unit testing. + ['some-module']: require('some-module') + } + + // Do not override the constructor of BaseService - use this instead! + async _construct () { + this.my_list = []; + } + + // This method is called after _construct has been called on all + // other services. + async _init () { + const services = this.services; + + // We can get the instances of other services here + const svc_otherService = services.get('other-service'); + } + + // The service container can listen on the "service event bus" + async ['__on_boot.consolidation'] () {} + async ['__on_boot.activation'] () {} + async ['__on_start.webserver'] () {} + async ['__on_install.routes'] () {} +} +``` diff --git a/src/backend/doc/contributors/structure.md b/src/backend/doc/contributors/structure.md new file mode 100644 index 0000000000000000000000000000000000000000..487f56b09402884641c84c641c39edf5187dffa0 --- /dev/null +++ b/src/backend/doc/contributors/structure.md @@ -0,0 +1,84 @@ +# Puter Backend - Directory Structure + +## MFU - Most Frequently Used + +These locations under `/src/backend/src` are the most important +to know about. Whether you're contributing a feature or fixing a bug, +you might only need to look at code in these locations. + +### `modules` directory + +The `modules` directory contains Puter backend kernel modules only. +Everything in here has a `Module.js` file and one or more +`Service.js` files. + +> **Note:** A "backend kernel module" is simply a class understood by + [`src/backend/src/Kernel.js`](../../src/Kernel.js) + that registers a number of "Service" classes. + You can look at [Puter's init file](../../../../tools/run-selfhosted.js) + to see how modules are added to Puter. + +The `README.md` file inside any module directory is generated with +the `module-docgen` script in the Puter repo's `/tools` directory. +The actual documentation for the module exists in jsdoc comments +in the source files. + +Each module might contain these directories: +- `doc/` - additional module documentation, like sample requests +- `lib/` - utility code that isn't a Module or Service class. + This utility code may be exposed by a service in the module + to Puter's runtime import mechanism for extension support. + +### `services` directory + +This directory existed before the `modules` directory. Most of +the services here go on a module called **CoreModule** +(CoreModule.js is directly in `/src/backend/src`), but this +directory can be thought of as "services that are not yet +organized in a distinct module". + +### `routers` directory + +While routes are typically registered by Services, the implementation +of a route might be placed under `src/backend/src/routers` to keep the +service's code tidy or for legacy reasons. + +These are some services that reference files under `src/backend/src/routers`: +- [PermissionAPIService](../../src/services/PermissionAPIService.js) - + This service registers routes that allow a user to configure permissions they + grant to apps and groups. This is a relatively recent case of using files under the + `routers` directory to clean up the service. +- [UserProtectedEndpointsService](../../src/services/web/UserProtectedEndpointsService.js) - + This service follows a slightly different approach where files under + `routers/user-protected` contain an "endpoint specification" instead of an express + handler function. This might be good inspiration for future routes. +- [PuterAPIService](../../src/services/PuterAPIService.js) - + This service is a catch-all for routes that existed before separation of concerns + into backend kernel modules. + +### `filesystem` directory + +The filesystem is likely the most complex portion of Puter's source code. This code +is in its own directory as a matter of circumstance more than intention. Ideally the +filesystem's concerns will be split across a few modules as we prepare to add +support for mounting different file systems and improved cache behavior. +For example, Puter's native filesystem implementation should be mostly moved to +`src/backend/src/modules/puterfs` as we continue this development. + +Since this directory is in flux, don't trust this documentation completely. +If you're contributing to filesystem, +[tag @KernelDeimos on the community Discord](https://discord.gg/PQcx7Teh8u) +if you have questions. + +These are the key locations in the `filesystem` directory: +- `FSNodeContext.js` - When you have a reference to a file or directory in backend code, + it is an instance of the FSNodeContext class. +- `ll_operations` - Runnables that implement the behavior of a filesystem operation. + These used to include the behavior of Puter's filesystem, but they now delegate + the actual behavior to the implementation in the `.provider` member of a + FSNodeContext (filesystem node / a file or directory) so that we can eventually + support "mountpoints" (multiple filesystem implementations). +- `hl_operations` - Runnables that implement the behavior of higher-level versions + of filesystem operations. For example, the high-level mkdir operation might create + multiple directories in chain; the high-level write might change the name of the + file to avoid conflicts if you specify the `dedupe_name` flag. diff --git a/src/backend/doc/dev_socket.md b/src/backend/doc/dev_socket.md new file mode 100644 index 0000000000000000000000000000000000000000..263f1897a810ed0765c3a77d9460fe47ee7746dd --- /dev/null +++ b/src/backend/doc/dev_socket.md @@ -0,0 +1,15 @@ +## Backend - dev socket + +The "dev socket" allows you to interact with Puter's backend by running commands. +It's a UNIX socket created in Puter's runtime directory +(typically `./volatile/runtime`, or `/var/puter` for production instances). + +When in the runtime directory, you can connect to the socket with your tool +of choice. For example, using `nc` as well as `rlwrap` to get readline history: + +``` +rlwrap nc -U ./dev.sock +``` + +If it is successful you will see a message with instructions. At this point +you may enter a command. Enter the `help` command to see a list of commands. diff --git a/src/backend/doc/extensions/README.md b/src/backend/doc/extensions/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c24e6e9b2c520ba025c49647894d075210ef403b --- /dev/null +++ b/src/backend/doc/extensions/README.md @@ -0,0 +1,84 @@ +# Puter Backend Extensions + +## What Are Extensions + +Extensions can extend the functionality of Puter's backend by handling specific +events or importing/exporting runtime libraries. + +## Creating an Extension + +The easiest way to create an extension is to place a new file or directory under +the `extensions/` directory immediately under the root directory of the Puter +repository. If your extension is a single `.js` file called `my-extension.js` it +will be implicitly converted into a CJS module with the following structure: + +``` +extensions/ + | + |- my-extension/ + | + |- package.json + |- main.js +``` + +The location of the extensions directory can be changed in +[the config file](../../../../doc/self-hosters/config.md) +by setting `mod_directories` to an array of valid locations. +The `mod_directories` parameter has the following default value: +```json +["{repo}/mods/mods_enabled", "{repo}/extensions"] +``` + +### Events + +The primary mechanism of communication between extensions and Puter, +and between different extensions, is through events. The `extension` +pseudo-global provides `.on(fn)` to add event listemers and +`.emit('name', { arbitrary: 'data' })` to emit events. + +To try working with events, you could make a simple extension that +emits an event after adding a listener for its own event: + +```javascript +// Listen to a test event called 'test-event' +extension.on('test-event', event => { + console.log(`We got the test event from ${sender}`); +}); + +// Listen to init; a good time to emit events +extension.on('init', event => { + extension.emit('test-event', { sender: 'Quinn' }); +}); +``` + +### Puter Extension Imports + +Your extensions may need to invoke specific actions in Puter's backend +in response to an event. Puter provides libraries at runtime which you +can access via `extension.imports`: + +```javascript +const { kv } = extension.imports('data'); +kv.set('some-key', 'some value'); +``` + +#### The `data` import + +The data import makes it possible to access Puter's database, persistent +key-value store, and in-memory cache. +- [Read more about the 'data' import](./builtins/data.md) + + +### Adding Features to Puter +- [Implementing Drivers](./pages/drivers.md) + +## Extensions - Planned Features + +Extensions are under refactor currently. This is the checklist: +- [x] Add RuntimeModule construct for imports and exports +- [x] Add support to implement drivers in extensions +- [ ] Add the ability to target specific extensions when + emitting events +- [ ] Add event name aliasing and configurable import mapping +- [ ] Extract extension loading from the core +- [ ] List exports in console diff --git a/src/backend/doc/extensions/builtins/data.md b/src/backend/doc/extensions/builtins/data.md new file mode 100644 index 0000000000000000000000000000000000000000..318134a67280c0dc5d729c0918984dbf5075f26a --- /dev/null +++ b/src/backend/doc/extensions/builtins/data.md @@ -0,0 +1,128 @@ +## Extensions - the `data` extension + +The `data` extension can be imported in custom extensions for access +to the database and key-value store. + +You can import these from `'data'`: +- `db` - Puter's main SQL database +- `kv` - A persistent key-value store +- `cache` - In-memory [kv.js](https://github.com/HeyPuter/kv.js/) store + +```javascript +const { db, kv, cache } = extension.import('data'); +``` + +### Database (`db`) + +Don't forget to import it first! +```javascript +const { db } = extension.import('data'); +``` + +#### `db.read` + +Usage: + +```javascript +const rows = await db.read('SELECT * FROM apps WHERE `name` = ?', [ + 'editor' +]); +``` +#### `db.write` + +Usage: + +```javascript +const { + insertId, // internal ID of new row (if this is an INSERT) + anyRowsAffected, // true if 1 or more rows were affected +} = await db.write( + // A query like INSERT, UPDATE, DELETE, etc... + 'INSERT INTO example_table (a, b, c) VALUES (?, ?, ?)', + // Parameters (all user input should go here) + [ + "Value for column a", + "Value for column b", + "Value for column c", + ] +); +``` + +### Persistent KV Store (`kv`) + +Don't forget to import it first! +```javascript +const { kv } = extension.import('data'); +``` + +#### `kv.get({ key })` + +```javascript +// Short-Form (like kv.js) +const someValue = kv.get('some-key'); + +// Long-Form (the `puter-kvstore` driver interface) +const someValue = kv.get({ key: 'some-key' }); +``` + +#### `kv.set({ key, value })` + +```javascript +await kv.set('some-key', 'some value'); + +// or... + +await kv.set({ + key: 'some-key', + value: 'some value', +}); +``` + +#### `kv.expire({ key, ttl })` + +This key will persist for 20 minutes, even if the server restarts. + +```javascript +kv.expire({ + key: 'some-key', + ttl: 1000 * 60 * 20, // 20 minutes +}); +``` + +### `kv.expireAt({ key, timestamp })` + +The following example expires a key 1 second before +["the apocalypse"](https://en.wikipedia.org/wiki/Year_2038_problem). +(don't worry, KV won't break in 2038) + +```javascript +kv.expireAt( + key: 'some-key', + // Expires Jan 19 2038 3:14:07 GMT + timestamp: 2147483647, +); +``` + +### In-Memory Cache (`cache`) + +Don't forget to import it first! +```javascript +const { cache } = extension.import('data'); +``` + +The in-memory cache is provided by [kv.js](https://github.com/HeyPuter/kv.js). +Below is a simple example. +For comprehensive documentation, see the [kv.js repository's readme](https://github.com/HeyPuter/kv.js/blob/main/README.md). + +```javascript +const { cache } = extension.require('data'); + +cache.set('some-key', 'some value'); +const value = cache.get('some-key'); // some value + +// This value only exists for 5 minutes +cache.set('temporary', 'abcdefg', { EX: 5 * 60 }); + +cache.incr('qwerty'); // cache.get('qwerty') is now: 1 +cache.incr('qwerty'); // cache.get('qwerty') is now: 2 +``` diff --git a/src/backend/doc/extensions/pages/core-devs.md b/src/backend/doc/extensions/pages/core-devs.md new file mode 100644 index 0000000000000000000000000000000000000000..526bcb53b3c6cbad1c8791b7c6644a6f5a590b01 --- /dev/null +++ b/src/backend/doc/extensions/pages/core-devs.md @@ -0,0 +1,135 @@ +## Extensions - Technical Context for Core Devs + +This document provides technical context for extensions from the perspective of +core backend modules and services, including the backend kernel. + +### Lifecycle + +For extensions, the concept of an "init" event handler is different from core. +This is because a developer of an extension expects `init` to occur after core +modules and services have been initialized. For this reason, extensions receive +`init` when backend services receive `boot.consolidation`. + +It is still possible to handle core's `init` event in an extension. This is done +using the `preinit` event. + +``` +Backend Core Lifecycle + Modules -> Construction -> Initialization -> Consolidation -> Activation -> Ready +Extension Lifecycle + index.js executed -> (no event) -> 'preinit' -> 'init' -> (no event) -> 'ready' +``` + +Extensions have an implicit Service instance that needs to listen for events on +the **Service Event Bus** such as `install.routes` (emitted by WebServerService). +Since extensions need to affect the behavior of the service when these events +occur (for example using `extension.post()` to add a POST handler) it is necessary +for their entry files to be loaded during a module installation phase, when +services are being registered and `_construct()` has not yet been called on any +service. + +Kernel.js loads all core modules/services before any extensions. This allows +core modules and services to create [runtime modules](./runtime-modules.md) +which can be imported by services. + +### How Extensions are Loaded + +Before extensions are loaded, all of Puter's core modules have their `.install()` +methods called. The core modules are the ones added with `kernel.add_module`, +for example in [run-selfhosted.js](../../../../../tools/run-selfhosted.js). + +Then, `Kernel.install_extern_mods_` is called. This is where a `readdir` is +performed on each directory listed in the `"mod_directories"` configuration +parameter, which has a default value of `["{repo}/extensions"]` (the +placeholder `{repo}` is automatically replaced with the path to the Puter +repository). + +For each item in each mod directory, except for ignored items like `.git` +directories, a mod is installed. First a directory is created in Puter's +runtime directory (`volatile/runtime` locally, `/var/puter` on a server). +If the item is a file then a `package.json` will be created for it after +`//@extension` directives are processed. If the item is a directory then +it is copied as is and `//@extension` directives are not supported +(`puter.json` is used instead). Source files for the mod are copied to +the mod directory under the runtime directory. + +It is at this point the pseudo-globals are added be prepending `cost` +declarations at the top of `.js` files in the extension. This is not +a great way to do this, but there is a severe lack of options here. +See the heading below - "Extension Pseudo-Globals" - for details. + +Before the entry file for the extension is `require()`'d a couple of +objects are created: an `ExtensionModule` and an `Extension`. +The `ExtensionModule` is a Puter module just like any of the Puter core +modules, so it has an `.install()` method that installs services before +Puter's kernel starts the initialization sequence. In this case it will +install the implied service that an extension creates if it registers +routes or performs any other action that's typically done inside services +in core modules. + +A RuntimeModule is also created. This could be thought of as analygous +to node's own `Module` class, but instead of being for imports/exports +between npm modules it's for imports/exports between Puter extensions +loaded at runtime. (see [runtime modules](./runtime-modules.md)) + +### Extension Pseudo-Globals + +The `extension` global is a different object per extension, which will +make it possible to develop "remapping" for imports/exports when +extension names collide among other functions that need context about +which extension is calling them. Implementing this per-extension global +was very tricky and many solutions were considered, including using the +`node:vm` builtin module to run the extension in a different instance. +Unfortunately `node:vm` support for EMCAScript Modules is lacking; +`vm.Module` has a drastically different API from `vm.Script`, requires +an experimental feature flag to be passed to node, and does not provide +any alternative to `createRequire` to make a valid linker for the +dependencies of a package being run in `node:vm`. + +The current solution - which sucks - is as follows: prepend `const` +definitions to the top of every `.js` file in the extension's installation +directory unless it's under a directory called `node_modules` or `gui`. +This type of "pseudo-global" has a quirk when compared to real globals, +which is that they can't be shadowed at the root scope without an error +being thrown. The naive solution of wrapping the rest of the file's +contents in a scope limiter (`{ ... }`) would break ES Module support +because `import` directives must be in the top-level scope, and the naive +solution to that problem of moving imports to the top of the file after +adding the scope limiter requires invoking a javascript parser do +determine the difference between a line starting with `import` because +it's actually an import and this unholy abomination of a situation: +``` +console.log(` +import { me, and, everything, breaks } from 'lackOfLexicalAnalysis'; +`); +``` + +Exposing the same instance for `extension` to all extensions with a +real global and using AsyncLocalStorage to get the necessary information +about the calling extension on each of `extension`'s methods was another +idea. This would cause surprising behavior for extension developers when +calling methods on `extension` in callbacks that lose the async context +fail because of missing extension information. + +Eventually a better compromise will be to have commonjs extensions +run using `vm.Script` and ESM extensions continue to run using this hack. + +### Event Listener Sub-Context + +In extensions, event handlers are registered using `extension.on`. These +handlers, when called, are supplemented with identifying information for +the extension through AsyncLocalStorage. This means any methods called +on the object passed from the event (usually just called `event`) will +be able to access the extension's name. + +This is used by CommandService's `create.commands` event. For example +the following extension code will register the command `utils:say-hello` +if it is invoked form an extension named `utils`: + +```javascript +extension.on('create.commands', event => { + event.createCommand('say-hello', async (args, console) => { + console.log('Hello,', ...args); + }); +}); +``` diff --git a/src/backend/doc/extensions/pages/drivers.md b/src/backend/doc/extensions/pages/drivers.md new file mode 100644 index 0000000000000000000000000000000000000000..0d100750d8fc163a6936033fa8e69ec164910c18 --- /dev/null +++ b/src/backend/doc/extensions/pages/drivers.md @@ -0,0 +1,145 @@ +## Extensions - Implementing Drivers + +Puter's concept of drivers has existed long before the extension system +was refined, and to keep things moving forward it has become easier to +develop Puter drivers in extensions than anywhere else in Puter's source. +If you want to build a driver, an extension is the recommended way to do it. + +### What are Puter drivers? + +Puter drivers are all called through the `/drivers/call` endpoint, so they +can be thought of as being "above" the HTTP layer. When a method on a driver +throws an error you will still receive a `200` HTTP status response because +the the invocation - from the HTTP layer - was successful. + +A driver response follows this structure: +```json +{ + "success": true, + "service": { + "name": "implementation-name" + }, + "result": "any type of value goes here", + "metadata": {} +} +``` + +There exists an example driver called `hello-world`. This driver implements +a method called `greet` with the optional parameter `subject` which returns +a string greeting either `World` (default) or the specified subject. + +```javascript +await puter.call('hello-world', 'no-frills', 'greet', { subject: 'Dave' }); +``` + +Let's break it down: + +#### `'hello-world'` + +`'hello-world'` is the name of an "interface". An interface can be thought of +a contract of what inputs are allowed and what outputs are expected. For +example the `hello-world` interface specifies that there must be a method +called `greet` and it should return a string representing a greeting. + +To add another example, an interface called `weather` specify a method called +`forcast5day` that always returns a list of 5 objects with a particular +structure. + +#### `no-frills` + +`'no-frills'` is a simple - "no frills" (nothing extra) - implementation of +the `hello-world` interface. All it does is return the string: +```javascript +`Hello, ${subject ?? 'World'}!` +``` + + +#### `'greet'` + +`greet` is the method being called. It's the only method on the `hello-world` +interface. + +#### `{ subject: 'Dave' }` + +These are the arguments to the `greet` method. The arguments specify that we +want to say "Hello" to Dave. Hopefully he doesn't ask us to open the pod bay +doors, or if he does we hopefully have extensions to add a driver interface +and driver implementation for the pod bay doors so that we can interact with +them. + +### Drivers in Extensions + +The `hellodriver` extension adds the `hello-world` interface like this: +```javascript +extension.on('create.interfaces', event => { + // createInterface is the only method on this `event` + event.createInterface('hello-world', { + description: 'Provides methods for generating greetings', + methods: { + greet: { + description: 'Returns a greeting', + parameters: { + subject: { + type: 'string', + optional: true + }, + locale: { + type: 'string', + optional: true + }, + } + } + } + }) +}); +``` + +The `hellodriver` extension adds the `no-frills` implementation for +`hello-world` like this: +```javascript +extension.on('create.drivers', event => { + event.createDriver('hello-world', 'no-frills', { + greet ({ subject }) { + return `Hello, ${subject ?? 'World'}!`; + } + }); +});` +``` + +You can pass an instance of a class for a driver implementation as well: +```javascript +class Greeter { + greet ({ subject }) { + return `Hello, ${subject ?? 'World'}!`; + } +} + +extension.on('create.drivers', event => { + event.createDriver('hello-world', 'no-frills', new Greeter()); +});` +``` + +Instances of classes being supported +may seem to be implied by the example before this +one, but that is not the case. What's shown here is that function members +of the object passed to `createDriver` will not be "bound" (have their +`.bind()` method called with a different object as the instance variable). + +### Permission Denied + +When you try to access a driver as any user other than the default +`admin` user, it will not work unless permission has been granted. + +The `hellodriver` extension grants permission to all clients using +the following snippet: +```javascript +extension.on('create.permissions', event => { + event.grant_to_everyone('service:no-frills:ii:hello-world'); +}); +``` + +The `create.permissions` event's `event` object has a few methods +you can use depending on the desired granularity: +- `grant_to_everyone` - grants permission to all users +- `grant_to_users` - grants permission to only registered users + (i.e. not to temporary/guest users) diff --git a/src/backend/doc/extensions/pages/import-and-export.md b/src/backend/doc/extensions/pages/import-and-export.md new file mode 100644 index 0000000000000000000000000000000000000000..c8b9cab812b4fe081206a27a5364b4188347acd7 --- /dev/null +++ b/src/backend/doc/extensions/pages/import-and-export.md @@ -0,0 +1,28 @@ +## Extensions - Importing & Exporting + +Here are two extensions. One extension has an "extension export" (an export to +other extensions) and an "extension import" (an import from another extension). +This is different from regular `import` or `require()` because it resolves to +a Puter extension loaded at runtime rather than an `npm` module. + +To import and export in Puter extensions, we use `extension.import()` and `extension.exports`. + +`exports-something.js` +```javascript +//@puter priority -1 +// ^ setting load priority to "-1" allows other extensions to import +// this extension's exports before the initialization event occurs + +// Just like "module.exports", but for extensions! +extension.exports = { + test_value: 'Hello, extensions!', +}; +``` + +`imports-something.js` +```javascript +const { test_value } = extension.import('exports-something'); + +console.log(test_value); // 'Hello, extensions!' +``` + diff --git a/src/backend/doc/extensions/pages/runtime-modules.md b/src/backend/doc/extensions/pages/runtime-modules.md new file mode 100644 index 0000000000000000000000000000000000000000..a2736b85496e4e7885c7307529897462ea3e6579 --- /dev/null +++ b/src/backend/doc/extensions/pages/runtime-modules.md @@ -0,0 +1,49 @@ +## Extensions - Runtime Modules + +Runtime modules are modules that extensions can import with tihs syntax: + +```javascript +const somelib = extension.import('somelib'); +``` + +These modules are registered in the [runtime module registry](../../../src/extension/RuntimeModuleRegistry.js) +which is instantiated by [Kernel.js](../../../src/Kernel.js). + +All extensions implicitly have a Runtime Module. The runtime module shares the name +of the extension that it corresponds to. Extensions can export to their module by +using `extension.exports`: + +```javascript +extension.exports = { /* ... */ }; +``` + +The [Extension](../../../src/Extension.js) object proxies this call to the +runtime module (called `this.runtime` in the snippet): + +```javascript +class Extension extends AdvancedBase { + // ... + set exports (value) { + this.runtime.exports = value; + } + // ... +} +``` + +You may be wondering why RuntimeModule is a separate class from Extension, +rather than just registering extensions into this registry. + +Separating RuntimeModule allows core code that has not yet been migrated +to extensions to export values as if they came from extensions. +Since core modules are loaded before extensions, this allows any legacy +`useapi` definitions be be exported where modules are installed. + +For example, in [CoreModule.js](../../../src/CoreModule.js) this snippet +of code is used to add a runtime module called `core`: + +```javascript +// Extension compatibility +const runtimeModule = new RuntimeModule({ name: 'core' }); +context.get('runtime-modules').register(runtimeModule); +runtimeModule.exports = useapi.use('core'); +``` diff --git a/src/backend/doc/features/batch-and-symlinks.md b/src/backend/doc/features/batch-and-symlinks.md new file mode 100644 index 0000000000000000000000000000000000000000..3aaa69031218ee8e27adfd347793925eed948df8 --- /dev/null +++ b/src/backend/doc/features/batch-and-symlinks.md @@ -0,0 +1,77 @@ +# Batch and Symlinks + +2024-10-08 + +### Batch and Symlinks + +All filesystem operations will eventually be available through batch requests. +Since batch requests can also handle the cases for single files, it seems silly +to support those endpoints too, so eventually most calls will be done through +`/batch`. Puter's legacy filesystem endpoints will always be supported, but a +future `api.___/fs/v2.0` urlspace for the filesystem API might not include them. + +This is batch: + +```javascript +await (async () => { + const endpoint = 'http://api.puter.localhost:4100/batch'; + + const ops = [ + { + op: 'mkdir', + path: '/default_user/Desktop/some-dir', + }, + { + op: 'write', + path: '/default_user/Desktop/some-file.txt', + } + ]; + + const blob = new Blob(["12345678"], { type: 'text/plain' }); + const formData = new FormData(); + for ( const op of ops ) { + formData.append('operation', JSON.stringify(op)); + } + formData.append('fileinfo', JSON.stringify({ + name: 'file.txt', + size: 8, + mime: 'text/plain', + })); + formData.append('file', blob, 'hello.txt'); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Authorization': `Bearer ${puter.authToken}` }, + body: formData + }); + return await response.json(); +})(); +``` +Symlinks are also created via `/batch` + +```javascript +await (async () => { + const endpoint = 'http://api.puter.localhost:4100/batch'; + + const ops = [ + { + op: 'symlink', + path: '~/Desktop', + name: 'link', + target: '/bb/Desktop/some' + }, + ]; + + const formData = new FormData(); + for ( const op of ops ) { + formData.append('operation', JSON.stringify(op)); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Authorization': `Bearer ${puter.authToken}` }, + body: formData + }); + return await response.json(); +})(); +``` diff --git a/src/backend/doc/features/protected-apps.md b/src/backend/doc/features/protected-apps.md new file mode 100644 index 0000000000000000000000000000000000000000..86492654515834557df318b1c4c5e54b6ee84dfd --- /dev/null +++ b/src/backend/doc/features/protected-apps.md @@ -0,0 +1,29 @@ +# Protected Apps and Subdomains + +## Protected Sites + +If a site is not protected, anyone can access the site. +When a site is protected, the following changes: + +- The site can only be accessed inside a Puter app iframe +- Only users with explicit permission will be able to load + the page associated with the site. + +## Protected Apps + +If an app is not protected, anyone with the name of the +app or its UUID will be able to access the app. +If the app is **approved for listing** (todo: doc this) +all users can access the app. +If an app is protected, the following changes: + +- The app can only be "seen" (listed) by users + with explicit permission. +- App metadata can only be accessed by users + with explicit permission. + +Note that an app being protected does not imply that the +site is protected. If a user action results in an app +being protected it should also result in the site (subdomain) +being protected **if they own it**. If the site will not +be protected the user should have some indication. diff --git a/src/backend/doc/features/service-scripts.md b/src/backend/doc/features/service-scripts.md new file mode 100644 index 0000000000000000000000000000000000000000..b22ae90f3a3a8662ec31e86b57389599368f53d0 --- /dev/null +++ b/src/backend/doc/features/service-scripts.md @@ -0,0 +1,150 @@ +> **NOTICE:** This documentation is new and might contain errors. +> Feel free to open a Github issue if you run into any problems. + +# Service Scripts + +## What is a Service Script? + +Service scripts allow backend services to provide client-side code that +runs in Puter's GUI. This is useful if you want to make a mod or plugin +for Puter that has backend functionality. For example, you might want +to add a tab to the settings panel to make use of or configure the service. + +Service scripts are made possible by the `puter-homepage` service, which +allows you to register URLs for additional javascript files Puter's +GUI should load. + +## ES Modules - A Problem of Ordering + +In browsers, script tags with `type=module` implicitly behave according +to those with the `defer` attribute. This means after the DOM is loaded +the scripts will run in the order in which they appear in the document. + +Relying on this execution order however does not work. This is because +`import` is implicitly asynchronous. Effectively, this means these +scripts will execute in arbitrary order if they all have imports. + +In a situation where all the client-side code is bundled with rollup +or webpack this is not an issue as you typically only have one +entry script. To facilitate loading service scripts, which are not +bundled with the GUI, we require that service scripts call the global +`service_script` function to access the API for service scripts. + +## Providing a Service Script + +For a service to provide a service script, it simply needs to serve +static files (the "service script") on some URL, and register that +URL with the `puter-homepage` service. + +In this example below we use builtin functionality of express to serve +static files. + +```javascript +class MyService extends BaseService { + async _init () { + // First we tell `puter-homepage` that we're going to be serving + // a javascript file which we want to be included when the GUI + // loads. + const svc_puterHomepage = this.services.get('puter-homepage'); + svc_puterHomepage.register_script('/my-service-script/main.js'); + } + + async ['__on_install.routes'] (_, { app }) { + // Here we ask express to serve our script. This is made possible + // by WebServerService which provides the `app` object when it + // emits the 'install.routes` event. + app.use('/my-service-script', + express.static( + PathBuilder.add(__dirname).add('gui').build() + ) + ); + } +} +``` + +## A Simple Service Script + + + +```javascript +import SomeModule from "./SomeModule.js"; + +service_script(api => { + api.on_ready(() => { + // This callback is invoked when the GUI is ready + + // We can use api.get() to import anything exposed to + // service scripts by Puter's GUI; for example: + const Button = api.use('ui.components.Button'); + // ^ Here we get Puter's Button component, which is made + // available to service scripts. + }); +}); +``` + +## Adding a Settings Tab + +Starting with the following example: + +```javascript +import MySettingsTab from "./MySettingsTab.js"; + +globalThis.service_script(api => { + api.on_ready(() => { + const svc_settings = globalThis.services.get('settings'); + svc_settings.register_tab(MySettingsTab(api)); + }); +}); +``` + +The module **MySettingsTab** exports a function for scoping the `api` +object, and that function returns a settings tab. The settings tab is +an object with a specific format that Puter's settings window understands. + +Here are the contents of `MySettingsTab.js`: + +```javascript +import MyWindow from "./MyWindow.js"; + +export default api => ({ + id: 'my-settings-tab', + title_i18n_key: 'My Settings Tab', + icon: 'shield.svg', + factory: () => { + const NotifCard = api.use('ui.component.NotifCard'); + const ActionCard = api.use('ui.component.ActionCard'); + const JustHTML = api.use('ui.component.JustHTML'); + const Flexer = api.use('ui.component.Flexer'); + const UIAlert = api.use('ui.window.UIAlert'); + + // The root component for our settings tab will be a "flexer", + // which by default displays its child components in a vertical + // layout. + const component = new Flexer({ + children: [ + // We can insert raw HTML as a component + new JustHTML({ + no_shadow: true, // use CSS for settings window + html: '

Some Heading

', + }), + new NotifCard({ + text: 'I am a card with some text', + style: 'settings-card-success', + }), + new ActionCard({ + title: 'Open an Alert', + button_text: 'Click Me', + on_click: async () => { + // Here we open an example window + await UIAlert({ + message: 'Hello, Puter!', + }); + } + }) + ] + }); + + return component; + } +}); +``` diff --git a/src/backend/doc/howto_make_driver.md b/src/backend/doc/howto_make_driver.md new file mode 100644 index 0000000000000000000000000000000000000000..33ce2c2fe89455e42500fec868f6c9ae7c12368c --- /dev/null +++ b/src/backend/doc/howto_make_driver.md @@ -0,0 +1,239 @@ +# How to Make a Puter Driver + +## What is a Driver? + +A driver can be one of two things depending on what you're +talking about: +- a **driver interface** describes a general type of service + and what its parameters and result look like. + For example, `puter-chat-completion` is a driver interface + for AI Chat services, and it specifies that any service + on Puter for AI Chat needs a method called `complete` that + accepts a JSON parameter called `messages`. +- a **driver implementation** exists when a **Service** on + Puter implements a **trait** with the same name as a + driver interface. + +## Part 1: Choose or Create a Driver Interface + +Available driver interfaces exist at this location in the repo: +[/src/backend/src/services/drivers/interfaces.js](../src/services/drivers/interfaces.js). + +When creating a new Puter driver implementation, you should check +this file to see if there's an appropriate interface. We're going +to make a driver that returns greeting strings, so we can use the +existing `hello-world` interface. If there wasn't an existing +interface, it would need to be created. Let's break down this +interface: + +```javascript +'hello-world': { + description: 'A simple driver that returns a greeting.', + methods: { + greet: { + description: 'Returns a greeting.', + parameters: { + subject: { + type: 'string', + optional: true, + }, + }, + result: { type: 'string' }, + } + } +}, +``` + +The **description** describes what the interface is for. This +should be provided that both driver developers and users can +quickly identify what types of services should use it. + +The **methods** object should have at least one entry, but it +may have more. The key of each entry is the name of a method; +in here we see `greet`. Each method also has a description, +a **parameters** object, and a **result** object. + +The **parameters** object has an entry for each parameter that +may be passed to the method. Each entry is an object with a +`type` property specifying what values are allowed, and possibly +an `optional: true` entry. + +All methods for Puter drivers use _named parameters_. There are no +positional parameters in Puter driver methods. + +The **result** object specifies the type of the result. A service +called DriverService will use this to determine the response format +and headers of the response. + +## Part 2: Create a Service + +Creating a service is very easy, provided the service doesn't do +anything. Simply add a class to `src/backend/src/services` or into +the module of your choice (`src/backend/src/modules/`) +that looks like this: + +```javascript +const BaseService = require('./BaseService') +// NOTE: the path specified ^ HERE might be different depending +// on the location of your file. + +class PrankGreetService extends BaseService { +} +``` + +Notice I called the service "PrankGreet". This is a good service +name because you already know what the service is likely to +implement: this service generates a greeting, but it is a greeting +that intends to play a prank on whoever is beeing greeted. + +Then, register the service into a module. If you put the service +under `src/backend/src/services`, then it goes in +[CoreModule](..//src/CoreModule.js) somewhere near the end of +the `install()` method. Otherwise, it will go in the `*Module.js` +file in the module where you placed your service. + +The code to register the service is two lines of code that will +look something like this: + +```javascript +const { PrankGreetServie } = require('./path/to/PrankGreetServie.js'); +services.registerService('prank-greet', PrankGreetServie); +``` + +## Part 3: Verify that the Service is Registered + +It's always a good idea to verify that the service is loaded +when starting Puter. Otherwise, you might spend time trying to +determine why your code doesn't work, when in fact it's not +running at all to begin with. + +To do this, we'll add an `_init` handler to the service that +logs a message after a few seconds. We wait a few seconds so that +any log noise from boot won't bury our message. + +```javascript +class PrankGreetService extends BaseService { + async _init () { + // Wait for 5 seconds + await new Promise(rslv => setTimeout(rslv), 5000); + + // Display a log message + console.debug('Hello from PrankGreetService!'); + } +} +``` + +## Part 4: Implement the Driver Interface in your Service + +Now that it has been verified that the service is loaded, we can +start implementing the driver interface we chose eralier. + +```javascript +class PrankGreetService extends BaseService { + async _init () { + // ... same as before + } + + // Now we add this: + static IMPLEMENTS = { + ['hello-world']: { + async greet ({ subject }) { + if ( subject ) { + return `Hello ${subject}, tell me about updog!`; + } + return `Hello, tell me about updog!`; + } + } + } +} +``` + +## Part 5: Test the Driver Implementation + +We have now created the `prank-greet` implementation of `hello-world`. +Let's make a request in the browser to check it out. The example below +is a `fetch` call using `http://api.puter.localhost:4100` as the API +origin, which is the default when you're running Puter's backend locally. + +Also, in this request I refer to `puter.authToken`. If you run this +snippet in the Dev Tools window of your browser from a tab with Puter +open (your local Puter, to be precise), this should contain the current +value for your auth token. + +```javascript +await (await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'hello-world', + service: 'prank-greet', + method: 'greet', + args: { + subject: 'World', + }, + }), + "method": "POST", +})).json(); +``` + +**You might see a permissions error!** Don't worry, this is expected; +in the next step we'll add the required permissions. + +## Part 6: Permissions + +In the previous step, you will only have gotten a successful response +if you're logged in as the `admin` user. If you're logged in as another +user you won't have access to the service's driver implementations be +default. + +To grant permission for all users, update +[hardcoded-permissions.js](../src/data/hardcoded-permissions.js). + +First, look for the constant `hardcoded_user_group_permissions`. +Whereever you see an entry for `service:hello-world:ii:hello-world`, add +the corresponding entry for your service, which will be called +``` +service:prank-greet:ii:hello-world +``` + +To help you remember the permission string, its helpful to know that +`ii` in the string stands for "invoke interface". i.e. the scope of the +permission is under `service:prank-greet` (the `prank-greet` service) +and we want permission to invoke the interface `hello-world` on that +service. + +You'll notice each entry in `hardcoded_user_group_permissions` has a value +determined by a call to the utility function `policy_perm(...)`. The policy +called `user.es` is a permissive policy for storage drivers, and we can +re-purpose it for our greeting implementor. + +The policy of a permission determines behavior like rate limiting. This is +an advanced topic that is not covered in this guide. + +If you want apps to be able to access the driver implementation without +explicit permission from a user, you will need to also register it in the +`default_implicit_user_app_permissions` constant. Additionally, you can +use the `implicit_user_app_permissions` constant to grant implicit +permission to the builtin Puter apps only. + +Permissions to implementations on services can also be granted at runtime +to a user or group of users using the permissions API. This is beyond the +scope of this guide. + +## Part 7: Verify Successful Response + +If all went well, you should see the response in your console when you +try the request from Part 5. Try logging into a user other than `admin` +to verify permisison is granted. + +```json +"Hello World, tell me about updog!" +``` + +## Part 8: Next Steps + +- [Access Configuration](./services/config.md) +- [Output Logs](./services/log.md) +- [Add HTTP Routes](./services/http.md) diff --git a/src/backend/doc/license_header.txt b/src/backend/doc/license_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..33114e1e774cc6876ca2517f2ee763713cfaa8e2 --- /dev/null +++ b/src/backend/doc/license_header.txt @@ -0,0 +1,16 @@ +Copyright (C) 2024 Puter Technologies Inc. + +This file is part of Puter. + +Puter is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/src/backend/doc/lists-of-things/list-of-permissions.md b/src/backend/doc/lists-of-things/list-of-permissions.md new file mode 100644 index 0000000000000000000000000000000000000000..8d223e421fde029e538b543ecc30d6e9b5b17aa4 --- /dev/null +++ b/src/backend/doc/lists-of-things/list-of-permissions.md @@ -0,0 +1,55 @@ +# Permissions + +## Filesystem Permissions + +### `fs::` + +- `` specifies the file that this permission + is associated with. + The ACL service + (which checks filesystem permissions) + knows if the value is a path or UUID based on the presence + of a leading slash; if it starts with `"/"` it's a path. +- `` specifies one of: + `write`, `read`, `list`, `see`; where each item in that + list implies all the access levels which follow. +- A permission that grants access to a directory, + such as `/user/shared`, implies access + of the same **access level** to all child file or directory + nodes under that location, **recursively**; + `fs:/user/shared:read` implies `fs:/user/shared/nested/file.txt:read` +- The "real" permission is `fs::`; + whenever path is specified the permission is rewritten. + **note:** future support for other filesystems + could make this rewrite rule conditional. + +## App and Subdomain permissions + +### `site::access` +- `` specifies the subdomain that this + permission is associated with. + Here, "subdomain" means the **"name of the subdomain"**, + which means a site accessed via `my-name.example.site` + will be specified here with `my-name`. +- This permission is always rewritten as the permission + described below (backend does this automatically). + +### `site:uid#:access` +- If the subdomain is **not** [protected](../features/protected-apps.md), + this permission is ignored by the system. +- If the subdomain **is** protected, this permission will + allow access to the site via a Puter app iframe with + a token for the entity to which permission was granted + +### `app::access` + +- `` specifies the app that this + permission is associated with. +- This permission is always rewritten as the permission + described below (backend does this automatically). + +### `app:uid#:access` +- If the app is **not** [protected](../features/protected-apps.md), + this permission is ignored by the system. +- If the app **is** protected, this permission will + allow reading the app's metadata and seeing that the app exists. diff --git a/src/backend/doc/lists-of-things/list-of-tto-types.md b/src/backend/doc/lists-of-things/list-of-tto-types.md new file mode 100644 index 0000000000000000000000000000000000000000..50f23a379ee63d3a72e41d3e4902b50e3c1da915 --- /dev/null +++ b/src/backend/doc/lists-of-things/list-of-tto-types.md @@ -0,0 +1,29 @@ +# Types for Type-Tagged Objects + +## Internal Use + +### `{ $: 'share-intent' }` + +- Used in the `/share` endpoint +- Permissions get applied to existing users +- For email shares, is trasnformed into a `token:share` + which is stored in the `share` database table. + +- **variants:** + - `share-intent:file` + - `share-intent:app` +- **properties:** + - `permissions` - a list of permissions to grant + +### `{ $: 'internal:share' }` +- Stored in the `share` database table +- **properties:** + - `permissions` - a list of permissions to grant + +### `{ $: 'token:share }` + +- Stored in a JWT called the "share token" +- Contains only the share UUID + +- **properties:** + - `uid` - UUID of a share diff --git a/src/backend/doc/log_config.md b/src/backend/doc/log_config.md new file mode 100644 index 0000000000000000000000000000000000000000..c6cebb43fc026325f5fee6bd93f0a32a456d0f8e --- /dev/null +++ b/src/backend/doc/log_config.md @@ -0,0 +1,45 @@ +## Backend - Configuring Logs + +### Log visibility specified by configuration file + +The configuration file can define an array parameter called `logging`. +This configures the visibility of specific logs in core areas based on +which string flags are present. + +For example, the following configuration will cause FileCacheService to +log information about cache hits and misses: +```json +{ + "logging": ['file-cache'] +} +``` + +Sometimes "enabling" a log means moving its log level from `debug` to `info`. + +#### Available logging flags: +- `file-cache`: file cache hits and misses +- `http`: http requests +- `fsentries-not-found`: information about files that were stat'd but weren't there + +#### Other log options + +- Setting `log_upcoming_alarms` to `true` will log alarms before they are created. + This would be useful if AlarmService itself is failing. +- Setting `trace_logs` to `true` will display a stack trace below every log message. + This can be useful if you don't know where a particular log is coming from and + want to track it down. + +#### Service-level log configuration + +Services can be configured to change their logging behavior. Services will have one of +two behaviors: + +1. **info logging** - `log.info` can be used to create an `[INFO]` log message +2. **debug logging only** - `log.info` is redirected to `log.debug` + +Services will have **info logging** enabled by default, unless the class definition +has the static member `static LOG_DEBUG = true` (in which case **debug logging only** +is the default). + +In a service's configuration block the desired behavior can be specified by setting +either `"log_debug": true` or `"log_info": true` diff --git a/src/backend/doc/modules/filesystem/API_SPEC.md b/src/backend/doc/modules/filesystem/API_SPEC.md new file mode 100644 index 0000000000000000000000000000000000000000..64404f705dbb6e8807ef1de15f405db53450465b --- /dev/null +++ b/src/backend/doc/modules/filesystem/API_SPEC.md @@ -0,0 +1,69 @@ +# Filesystem API + +Filesystem endpoints allow operations on files and directories in the Puter filesystem. + +## POST `/mkdir` (auth required) + +### Description + +Creates a new directory in the filesystem. Currently support 2 formats: + +- Full path: `{"path": "/foo/bar", args ...}` — this API is used by apitest (`./tools/api-tester/apitest.js`) and aligns more closely with the POSIX spec (https://linux.die.net/man/3/mkdir) +- Parent + path: `{"parent": "/foo", "path": "bar", args ...}` — this API is used by `puter-js` via `puter.fs.mkdir` + +A future work would be use a unified format for all filesystem operations. + +### Parameters + +- **path** _- required_ + - **accepts:** `string` + - **description:** The path where the directory should be created + - **notes:** Cannot be empty, null, or undefined + +- **parent** _- optional_ + - **accepts:** `string | UUID` + - **description:** The parent directory path or UUID + - **notes:** If not provided, path is treated as full path + +- **overwrite** _- optional_ + - **accepts:** `boolean` + - **default:** `false` + - **description:** Whether to overwrite existing files/directories + +- **dedupe_name** _- optional_ + - **accepts:** `boolean` + - **default:** `false` + - **description:** Whether to automatically rename if name exists + +- **create_missing_parents** _- optional_ + - **accepts:** `boolean` + - **default:** `false` + - **description:** Whether to create parent directories if they don't exist + - **aliases:** `create_missing_ancestors` + +- **shortcut_to** _- optional_ + - **accepts:** `string | UUID` + - **description:** Creates a shortcut/symlink to the specified target + +### Example + +```json +{ + "path": "/user/Desktop/new-directory" +} +``` + +```json +{ + "parent": "/user", + "path": "Desktop/new-directory" +} +``` + +### Response + +Returns the created directory's metadata including name, path, uid, and any parent directories created. + +## Other Filesystem Endpoints + +[Additional endpoints would be documented here...] \ No newline at end of file diff --git a/src/backend/doc/modules/puterai/README.md b/src/backend/doc/modules/puterai/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3e14a54f1fce2d4c1439ab814f5d8e09cfff9167 --- /dev/null +++ b/src/backend/doc/modules/puterai/README.md @@ -0,0 +1,15 @@ +# PuterAI Module + +The PuterAI module provides AI capabilities to Puter through various services including: + +- Text generation and chat completion +- Text-to-speech synthesis +- Image generation +- Document analysis + +## Metered Services + +All AI services in this module are metered using Puter's MeteringService. This allows us to charge per `unit` usage, where a `unit` is defined by the specific service: +for example, most LLMs will charge per token, AWS Polly charges per character, and AWS Textract charges per page. the metering service tracks usage units, and relies on its centralized cost maps to determine if a user has enough credits to perform an operation, and to record usage after the operation is complete. + +see [MeteringService](../../../src/services/MeteringService/MeteringService.ts) for more details on how metering works. \ No newline at end of file diff --git a/src/backend/doc/notes/2024-10-03_email_in_use_checks.md b/src/backend/doc/notes/2024-10-03_email_in_use_checks.md new file mode 100644 index 0000000000000000000000000000000000000000..a4df2baebb72430a75ffe3a2260a28236db71ef2 --- /dev/null +++ b/src/backend/doc/notes/2024-10-03_email_in_use_checks.md @@ -0,0 +1,74 @@ +## 2024-10-03 + +### Plan (constantly changing as per what's below) + +- `signup.js` only says "email already used" if the one that's + already been used is confirmed. +- "change email" needs to follow the same logic; show an error when + an email already exists on an account with a confirmed email. + Then, upon confirming the update, Ensure that in the meanwhile no + new account came up with that email set. +- ensure `clean_email` is updated whenever the email is updated + +### Email duplicate check on confirmation + +- signup.js:149 -> this is where email dupe is currently checked +- signup.js:290 -> This is where we send the confirmation email. + There is also a branch that sends a "confirm token". + I don't recall what this is for. + +### Investigating the "confirm token" + +- email template is `email_verification_code` + instead of `email_verification_link` +- This happens when either: + - user.requires_email_confirmation is TRUE + - send_confirmation_code is TRUE in REQUEST + +### Figuring out when `requires_email_confirmation` is TRUE + +I'm mostly curious about this state on a user. +It's strange that `signup.js` would do anything on EXISTING users. + +1. `pseudo_user` may be populated if `req.body.email` exists + AND a user with no password exists with that email +2. `uuid_user` may be populated if a user exists with the specified + UUID, but it has no usefulness unless `uuid_user` has the same + id as `pseudo_user`. + +`uuid_user` is only used to set `email_confirmation_required` to 0 + IFF `pseudo_user` has same id as `uuid_user` + AND `psuedo_user` has an email + +When does `pseudo_user` have an email? + +### Figuring out when a pseudo user can have an email +- asking NJ, I'm at a loss on this one for the moment + +### Figuring out if account takeover is possible on signup.js with a uuid +- Nope, looks like `uuid_user` is only used to set + `email_confirmation_required = 0` + +### Figuring out when `send_confirmation_code` is TRUE in REQUEST +- IFF `require_email_verification_to_publish_website` is TRUE + - it's not currently, but we need this to be possible to enable +- ^ That seems to be the ONLY place when this matters + +### Current Thoughts + +- `email_verification_code` will be difficult to test because there is + nothing currently in the system that's using it. However, I could try + enabling `require_email_verification_to_publish_website` locally and + see if this behavior begins to work as expected. + +- `email_verification_link` where we can confirm an email. If another email + was already confirmed since the time the link was sent, we need to display + an error message to the user. + +### Find places where (on backend) email change process is triggered + +Right now there are two handlers: +- `/user-protected/change-email` (UserProtectedEndpointsService) + - Invokes the process (sends confirmation email) +- `/change_email/confirm` (PuterAPIService) + - Endpoint that the email link points to diff --git a/src/backend/doc/services/config.md b/src/backend/doc/services/config.md new file mode 100644 index 0000000000000000000000000000000000000000..6abb7bf19c8f10c78aac7b1b42681c4663bdea65 --- /dev/null +++ b/src/backend/doc/services/config.md @@ -0,0 +1,46 @@ +# Service Configuration + +To locate your configuration file, see [Configuring Puter](https://github.com/HeyPuter/puter/wiki/self_hosters-config). + +### Accessing Service Configuration + +Service configuration appears under the `"services"` property in the +configuration file for Puter. If Puter's configuration had no other +values except for a service config with one key, it might look like +this: + +```json +{ + "services": { + "my-service": { + "somekey": "some value" + } + } +} +``` + +Services have their configuration object assigned to `this.config`. + +```javascript +class MyService extends BaseService { + async _init () { + // You can access configuration for a service like this + this.log.info('value of my key is: ' + this.config.somekey); + } +} +``` + +### Accessing Global Configuration + +Services can access global configuration. This can be useful for knowing how +Puter itself is configured, but using this global config object for service +configuration is discouraged as it could create conflicts between services. + +```javascript +class MyService extends BaseService { + async _init () { + // You can access configuration for a service like this + this.log.info('Puter is hosted on: ' + this.global_config.domain); + } +} +``` diff --git a/src/backend/doc/services/event_buses.md b/src/backend/doc/services/event_buses.md new file mode 100644 index 0000000000000000000000000000000000000000..0394645eb08b01b77ebc973eac09143861938451 --- /dev/null +++ b/src/backend/doc/services/event_buses.md @@ -0,0 +1,33 @@ +# Event Buses + +Puter's backend has two event buses: +- Service Event Bus +- Application Event Bus + +## Service Event Bus + +This is a simple event bus that lives in the [Container](../../src/services/Container.js) +class. There is only one instance of **Container** and it is called the "services container". +When Puter boots, all the services registered by modules are registered into the services +container. + +Services handle events from the Service Event Bus by implementing methods which are named +with the prefix `__on_`. This prefix looks a little strange at first so it's worth +breaking it down: +- `__` (two underscores) prevents collision with common method names, and also + common conventions like beginning a method name with a single underscore + to indicate a method that should be overridden. +- `on` is the meaningful name. +- `_`, the last underscore, is for readability, as the event name conventionally + begins with a lowercase letter. + +Note that you will need to use the + +Example: +```javascript +class MyService extends BaseService { + ['__on_boot.ready'] () { + // + } +} +``` diff --git a/src/backend/doc/services/http.md b/src/backend/doc/services/http.md new file mode 100644 index 0000000000000000000000000000000000000000..86b18f1e0cc12098fb6af45e1005980905415016 --- /dev/null +++ b/src/backend/doc/services/http.md @@ -0,0 +1,4 @@ +# Adding HTTP Routes to Services + +Services can serve HTTP routes when the [WebModule](../../src/modules/web/WebModule.js) +is enabled by listening for the `install.routes` event on the [Service Event Bus](./) \ No newline at end of file diff --git a/src/backend/doc/services/log.md b/src/backend/doc/services/log.md new file mode 100644 index 0000000000000000000000000000000000000000..b57e4e9208c7839f841cff6bffb448c0990d6dc6 --- /dev/null +++ b/src/backend/doc/services/log.md @@ -0,0 +1,44 @@ +# Logging in Services + +# NOTE: You can, and maybe should, just use console log methods, as they are overriden to log through our logger + +Services all have a logger available at `this.log`. + +```javascript +class MyService extends BaseService { + async init () { + this.log.info('Hello, Logger!'); + } +} +``` + +There are multiple "log levels", similar to `logrus` or other common logging +libraries. + +```javascript +class MyService extends BaseService { + async init () { + this.log.info('I\'m just a regular log.'); + this.log.debug('I\'m only for developers.'); + this.log.warn('It is statistically unlikely I will be awknowledged.'); + this.log.error('Something is broken! Pay attention!'); + this.log.noticeme('This will be noticed, unlike warnings. Use sparingly.'); + this.log.system('I am a system event, like shutdown.'); + this.log.tick('A periodic behavior like cache pruning is occurring.'); + } +} +``` + +Log methods can take a second parameter, an object specifying fields. + +```javascript + +class MyService extends BaseService { + async init () { + this.log.info('I have fields!', { + why: "why not", + random_number: 1, // chosen by coin toss, guarenteed to be random + }); + } +} +``` diff --git a/src/backend/exports.js b/src/backend/exports.js new file mode 100644 index 0000000000000000000000000000000000000000..d53cc0fa6e558d950b89cf2cebbe2b50f27f92d4 --- /dev/null +++ b/src/backend/exports.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import CoreModule from './src/CoreModule.js'; +import DatabaseModule from './src/DatabaseModule.js'; +import { testlaunch } from './src/index.js'; +import { Kernel } from './src/Kernel.js'; +import LocalDiskStorageModule from './src/LocalDiskStorageModule.js'; +import MemoryStorageModule from './src/MemoryStorageModule.js'; +import { PuterAIModule } from './src/modules/ai/PuterAIChatModule.js'; +import { AppsModule } from './src/modules/apps/AppsModule.js'; +import { BroadcastModule } from './src/modules/broadcast/BroadcastModule.js'; +import { CaptchaModule } from './src/modules/captcha/CaptchaModule.js'; +import { Core2Module } from './src/modules/core/Core2Module.js'; +import { DevelopmentModule } from './src/modules/development/DevelopmentModule.js'; +import { DNSModule } from './src/modules/dns/DNSModule.js'; +import { DomainModule } from './src/modules/domain/DomainModule.js'; +import { EntityStoreModule } from './src/modules/entitystore/EntityStoreModule.js'; +import { HostOSModule } from './src/modules/hostos/HostOSModule.js'; +import { InternetModule } from './src/modules/internet/InternetModule.js'; +import { KVStoreModule } from './src/modules/kvstore/KVStoreModule.js'; +import { PerfMonModule } from './src/modules/perfmon/PerfMonModule.js'; +import { PuterFSModule } from './src/modules/puterfs/PuterFSModule.js'; +import SelfHostedModule from './src/modules/selfhosted/SelfHostedModule.js'; +import { TestConfigModule } from './src/modules/test-config/TestConfigModule.js'; +import { TestDriversModule } from './src/modules/test-drivers/TestDriversModule.js'; +import { WebModule } from './src/modules/web/WebModule.js'; +import BaseService from './src/services/BaseService.js'; +import { Context } from './src/util/context.js'; + +export default { + helloworld: () => { + console.log('Hello, World!'); + process.exit(0); + }, + testlaunch, + + // Kernel API + BaseService, + Context, + + Kernel, + + EssentialModules: [ + Core2Module, + PuterFSModule, + HostOSModule, + CoreModule, + WebModule, + // TemplateModule, + AppsModule, + CaptchaModule, + EntityStoreModule, + KVStoreModule, + ], + + // Pre-built modules + CoreModule, + WebModule, + DatabaseModule, + LocalDiskStorageModule, + MemoryStorageModule, + SelfHostedModule, + TestDriversModule, + TestConfigModule, + PuterAIModule, + BroadcastModule, + InternetModule, + CaptchaModule, + KVStoreModule, + DNSModule, + DomainModule, + + // Development modules + PerfMonModule, + DevelopmentModule, +}; diff --git a/src/backend/package.json b/src/backend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d89827b25230fe118090caaaa5024e68cfda75a7 --- /dev/null +++ b/src/backend/package.json @@ -0,0 +1,108 @@ +{ + "name": "@heyputer/backend", + "version": "2.5.1", + "description": "Backend/Kernel for Puter", + "main": "exports.js", + "scripts": { + "test": "npx mocha src/**/*.test.js && node ./tools/test.mjs", + "build:worker": "cd src/services/worker && npm run build" + }, + "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.940.0", + "@aws-sdk/client-polly": "^3.622.0", + "@aws-sdk/client-textract": "^3.621.0", + "@google/generative-ai": "^0.21.0", + "@heyputer/kv.js": "^0.1.9", + "@heyputer/multest": "^0.0.2", + "@heyputer/putility": "^1.0.0", + "@mistralai/mistralai": "^1.3.4", + "@opentelemetry/api": "^1.4.1", + "@opentelemetry/auto-instrumentations-node": "^0.43.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.40.0", + "@opentelemetry/sdk-metrics": "^1.14.0", + "@opentelemetry/sdk-node": "^0.49.1", + "@pagerduty/pdjs": "^2.2.4", + "@smithy/node-http-handler": "^2.2.2", + "args": "^5.0.3", + "axios": "^1.8.2", + "bcrypt": "^5.1.0", + "better-sqlite3": "^11.9.0", + "busboy": "^1.6.0", + "chai-as-promised": "^7.1.1", + "clean-css": "^5.3.2", + "composite-error": "^1.0.2", + "compression": "^1.7.4", + "convertapi": "^1.15.0", + "cookie-parser": "^1.4.6", + "dedent": "^1.5.3", + "dns2": "^2.1.0", + "express": "^4.18.2", + "file-type": "^18.5.0", + "firebase-admin": "^13.3.0", + "form-data": "^4.0.0", + "groq-sdk": "^0.5.0", + "handlebars": "^4.7.8", + "helmet": "^7.0.0", + "hi-base32": "^0.5.1", + "html-entities": "^2.3.3", + "is-glob": "^4.0.3", + "isbot": "^3.7.1", + "jimp": "^0.22.8", + "js-sha256": "^0.9.0", + "json5": "^2.2.3", + "jsonwebtoken": "^9.0.0", + "knex": "^3.1.0", + "lorem-ipsum": "^2.0.8", + "lru-cache": "^11.0.2", + "micromatch": "^4.0.5", + "mime-types": "^2.1.35", + "moment": "^2.29.4", + "morgan": "^1.10.0", + "multer": "^2.0.2", + "multi-progress": "^4.0.0", + "murmurhash": "^2.0.1", + "music-metadata": "^7.14.0", + "nodemailer": "^6.9.3", + "on-finished": "^2.4.1", + "openai": "^6.7.0", + "otpauth": "9.2.4", + "prompt-sync": "^4.2.0", + "proxyquire": "^2.1.3", + "recursive-readdir": "^2.2.3", + "response-time": "^2.3.2", + "seedrandom": "^3.0.5", + "sharp": "^0.34.3", + "sharp-bmp": "^0.1.5", + "sharp-ico": "^0.1.5", + "socket.io": "^4.6.2", + "socket.io-client": "^4.6.2", + "ssh2": "^1.13.0", + "string-hash": "^1.1.3", + "string-length": "^6.0.0", + "svg-captcha": "^1.4.0", + "svgo": "^3.0.2", + "tiktoken": "^1.0.16", + "together-ai": "^0.33.0", + "tweetnacl": "^1.0.3", + "ua-parser-js": "^1.0.38", + "uglify-js": "^3.17.4", + "uuid": "^9.0.0", + "validator": "^13.9.0", + "winston": "^3.9.0", + "winston-daily-rotate-file": "^4.7.1", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/node": "^20.5.3", + "chai": "^4.3.7", + "jsdom": "^27.2.0", + "mocha": "^10.2.0", + "nodemon": "^3.1.0", + "nyc": "^15.1.0", + "sinon": "^15.2.0", + "typescript": "^5.9.3", + "vitest": "^4.0.14" + }, + "author": "Puter Technologies Inc.", + "license": "AGPL-3.0-only" +} diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js new file mode 100644 index 0000000000000000000000000000000000000000..95e7016ca06c0d0b8ac3bf86f23478f92a604c9c --- /dev/null +++ b/src/backend/src/CoreModule.js @@ -0,0 +1,418 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { NotificationES } = require('./om/entitystorage/NotificationES'); +const { ProtectedAppES } = require('./om/entitystorage/ProtectedAppES'); +const { Context } = require('./util/context'); +const { LLOWrite } = require('./filesystem/ll_operations/ll_write'); +const { LLRead } = require('./filesystem/ll_operations/ll_read'); +const { RuntimeModule } = require('./extension/RuntimeModule.js'); +const { TYPE_DIRECTORY, TYPE_FILE } = require('./filesystem/FSNodeContext.js'); +const { TDetachable } = require('@heyputer/putility/src/traits/traits.js'); +const { MultiDetachable } = require('@heyputer/putility/src/libs/listener.js'); +const { OperationFrame } = require('./services/OperationTraceService'); + +/** + * @footgun - real install method is defined above + */ +const install = async ({ context, services, app, useapi, modapi }) => { + const config = require('./config'); + + // === LIBRARIES === + + useapi.withuse(() => { + def('Service', require('./services/BaseService')); + def('Module', AdvancedBase); + + def('core.util.helpers', require('./helpers')); + def('core.util.permission', require('./services/auth/permissionUtils.mjs').PermissionUtil); + def('puter.middlewares.auth', require('./middleware/auth2')); + def('puter.middlewares.configurable_auth', require('./middleware/configurable_auth')); + def('puter.middlewares.anticsrf', require('./middleware/anticsrf')); + + def('core.APIError', require('./api/APIError')); + def('core.Context', Context); + + def('core', require('./services/auth/Actor'), { assign: true }); + def('core', { + TDetachable, + MultiDetachable, + }, { assign: true }); + def('core.config', config); + + // Note: this is an incomplete export; it was added for a proprietary + // extension. Contributors may wish to add definitions in the 'fs.' + // scope. Needing to add these individually is possibly a symptom of an + // anti-pattern; "export filesystem operations to extensions" is one + // statement in English, so maybe it should be one statement of code. + def('core.fs', { + LLOWrite, + LLRead, + TYPE_DIRECTORY, + TYPE_FILE, + OperationFrame, + }); + def('core.fs.selectors', require('./filesystem/node/selectors')); + def('core.util.stream', require('./util/streamutil')); + def('web', require('./util/expressutil')); + def('core.validation', require('./validation')); + + def('core.database', require('./services/database/consts.js')); + + // Add otelutil functions to `core.` + def('core.spanify', require('./util/otelutil').spanify); + def('core.abtest', require('./util/otelutil').abtest); + + // Extension compatibility + const runtimeModule = new RuntimeModule({ name: 'core' }); + context.get('runtime-modules').register(runtimeModule); + runtimeModule.exports = useapi.use('core'); + }); + + modapi.libdir('core.util', './util'); + + // === SERVICES === + + // TODO: move these to top level imports or await imports and esm this file + + const { CommandService } = require('./services/CommandService'); + const { HTTPThumbnailService } = require('./services/thumbnails/HTTPThumbnailService'); + const { PureJSThumbnailService } = require('./services/thumbnails/PureJSThumbnailService'); + const { NAPIThumbnailService } = require('./services/thumbnails/NAPIThumbnailService'); + const { RateLimitService } = require('./services/sla/RateLimitService'); + const { AuthService } = require('./services/auth/AuthService'); + const { SLAService } = require('./services/sla/SLAService'); + const { PermissionService } = require('./services/auth/PermissionService'); + const { ACLService } = require('./services/auth/ACLService'); + const { CoercionService } = require('./services/drivers/CoercionService'); + const { PuterSiteService } = require('./services/PuterSiteService'); + const { ContextInitService } = require('./services/ContextInitService'); + const { IdentificationService } = require('./services/abuse-prevention/IdentificationService'); + const { AuthAuditService } = require('./services/abuse-prevention/AuthAuditService'); + const { RegistryService } = require('./services/RegistryService'); + const { RegistrantService } = require('./services/RegistrantService'); + const { SystemValidationService } = require('./services/SystemValidationService'); + const { EntityStoreService } = require('./services/EntityStoreService'); + const SQLES = require('./om/entitystorage/SQLES'); + const ValidationES = require('./om/entitystorage/ValidationES'); + const { SetOwnerES } = require('./om/entitystorage/SetOwnerES'); + const AppES = require('./om/entitystorage/AppES'); + const WriteByOwnerOnlyES = require('./om/entitystorage/WriteByOwnerOnlyES'); + const SubdomainES = require('./om/entitystorage/SubdomainES'); + const { MaxLimitES } = require('./om/entitystorage/MaxLimitES'); + const { AppLimitedES } = require('./om/entitystorage/AppLimitedES'); + const { ReadOnlyES } = require('./om/entitystorage/ReadOnlyES'); + const { OwnerLimitedES } = require('./om/entitystorage/OwnerLimitedES'); + const { ESBuilder } = require('./om/entitystorage/ESBuilder'); + const { Eq, Or } = require('./om/query/query'); + const { MakeProdDebuggingLessAwfulService } = require('./services/MakeProdDebuggingLessAwfulService'); + const { ConfigurableCountingService } = require('./services/ConfigurableCountingService'); + const { FSLockService } = require('./services/fs/FSLockService'); + const { StrategizedService } = require('./services/StrategizedService'); + const FilesystemAPIService = require('./services/FilesystemAPIService'); + const ServeGUIService = require('./services/ServeGUIService'); + const PuterAPIService = require('./services/PuterAPIService'); + const { RefreshAssociationsService } = require('./services/RefreshAssociationsService'); + // Service names beginning with '__' aren't called by other services; + // these provide data/functionality to other services or produce + // side-effects from the events of other services. + + // === Services which extend BaseService === + services.registerService('system-validation', SystemValidationService); + services.registerService('commands', CommandService); + services.registerService('__api-filesystem', FilesystemAPIService); + services.registerService('__api', PuterAPIService); + services.registerService('__gui', ServeGUIService); + services.registerService('registry', RegistryService); + services.registerService('__registrant', RegistrantService); + services.registerService('fslock', FSLockService); + services.registerService('es:app', EntityStoreService, { + entity: 'app', + upstream: ESBuilder.create([ + SQLES, { table: 'app', debug: true }, + AppES, + AppLimitedES, { + permission_prefix: 'apps-of-user', + // When apps query es:apps, they're allowed to see apps which + // are approved for listing and they're allowed to see their + // own entry. + exception: async () => { + const actor = Context.get('actor'); + return new Or({ + children: [ + new Eq({ + key: 'approved_for_listing', + value: 1, + }), + new Eq({ + key: 'uid', + value: actor.type.app.uid, + }), + ], + }); + }, + }, + WriteByOwnerOnlyES, + ValidationES, + SetOwnerES, + ProtectedAppES, + MaxLimitES, { max: 5000 }, + ]), + }); + + const { EntriService } = require('./services/EntriService.js'); + services.registerService('entri-service', EntriService); + + const { InformationService } = require('./services/information/InformationService'); + services.registerService('information', InformationService); + + const { TraceService } = require('./services/TraceService.js'); + services.registerService('traceService', TraceService); + + const { FilesystemService } = require('./filesystem/FilesystemService'); + services.registerService('filesystem', FilesystemService); + + services.registerService('es:subdomain', EntityStoreService, { + entity: 'subdomain', + upstream: ESBuilder.create([ + SQLES, { table: 'subdomains', debug: true }, + SubdomainES, + AppLimitedES, { permission_prefix: 'subdomains-of-user' }, + WriteByOwnerOnlyES, + ValidationES, + SetOwnerES, + MaxLimitES, { max: 5000 }, + ]), + }); + services.registerService('es:notification', EntityStoreService, { + entity: 'notification', + upstream: ESBuilder.create([ + SQLES, { table: 'notification', debug: true }, + NotificationES, + OwnerLimitedES, + ReadOnlyES, + SetOwnerES, + MaxLimitES, { max: 200 }, + ]), + }); + services.registerService('rate-limit', RateLimitService); + services.registerService('auth', AuthService); + // services.registerService('preauth', PreAuthService); + services.registerService('permission', PermissionService); + services.registerService('sla', SLAService); + services.registerService('acl', ACLService); + services.registerService('coercion', CoercionService); + services.registerService('puter-site', PuterSiteService); + services.registerService('context-init', ContextInitService); + services.registerService('identification', IdentificationService); + services.registerService('auth-audit', AuthAuditService); + services.registerService('counting', ConfigurableCountingService); + services.registerService('thumbnails', StrategizedService, { + strategy_key: 'engine', + default_strategy: 'purejs', + strategies: { + napi: [NAPIThumbnailService], + purejs: [PureJSThumbnailService], + http: [HTTPThumbnailService], + }, + }); + services.registerService('__refresh-assocs', RefreshAssociationsService); + services.registerService('__prod-debugging', MakeProdDebuggingLessAwfulService); + const { EventService } = require('./services/EventService'); + services.registerService('event', EventService); + + const { PuterVersionService } = require('./services/PuterVersionService'); + services.registerService('puter-version', PuterVersionService); + + const { SessionService } = require('./services/SessionService'); + services.registerService('session', SessionService); + + const { EdgeRateLimitService } = require('./services/abuse-prevention/EdgeRateLimitService'); + services.registerService('edge-rate-limit', EdgeRateLimitService); + + const { CleanEmailService } = require('./services/CleanEmailService'); + services.registerService('clean-email', CleanEmailService); + + const { Emailservice } = require('./services/EmailService'); + services.registerService('email', Emailservice); + + const { TokenService } = require('./services/auth/TokenService'); + services.registerService('token', TokenService); + + const { OTPService } = require('./services/auth/OTPService'); + services.registerService('otp', OTPService); + + const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService'); + services.registerService('__user-protected-endpoints', UserProtectedEndpointsService); + + const { AntiCSRFService } = require('./services/auth/AntiCSRFService'); + services.registerService('anti-csrf', AntiCSRFService); + + const { LockService } = require('./services/LockService'); + services.registerService('lock', LockService); + + const { PuterHomepageService } = require('./services/PuterHomepageService'); + services.registerService('puter-homepage', PuterHomepageService); + + const { GetUserService } = require('./services/GetUserService'); + services.registerService('get-user', GetUserService); + + const { DetailProviderService } = require('./services/DetailProviderService'); + services.registerService('whoami', DetailProviderService); + + const { DriverService } = require('./services/drivers/DriverService'); + services.registerService('driver', DriverService); + + const { ScriptService } = require('./services/ScriptService'); + services.registerService('script', ScriptService); + + const { NotificationService } = require('./services/NotificationService'); + services.registerService('notification', NotificationService); + + const { ShareService } = require('./services/ShareService'); + services.registerService('share', ShareService); + + const { GroupService } = require('./services/auth/GroupService'); + services.registerService('group', GroupService); + + const { VirtualGroupService } = require('./services/auth/VirtualGroupService'); + services.registerService('virtual-group', VirtualGroupService); + + const { PermissionAPIService } = require('./services/PermissionAPIService'); + services.registerService('__permission-api', PermissionAPIService); + + const { AnomalyService } = require('./services/AnomalyService'); + services.registerService('anomaly', AnomalyService); + + const { HelloWorldService } = require('./services/HelloWorldService'); + services.registerService('hello-world', HelloWorldService); + + const { SystemDataService } = require('./services/SystemDataService'); + services.registerService('system-data', SystemDataService); + + const { SUService } = require('./services/SUService'); + services.registerService('su', SUService); + + const { ShutdownService } = require('./services/ShutdownService'); + services.registerService('shutdown', ShutdownService); + + const { BootScriptService } = require('./services/BootScriptService'); + services.registerService('boot-script', BootScriptService); + + const { FeatureFlagService } = require('./services/FeatureFlagService'); + services.registerService('feature-flag', FeatureFlagService); + + const { KernelInfoService } = require('./services/KernelInfoService'); + services.registerService('kernel-info', KernelInfoService); + + const { DriverUsagePolicyService } = require('./services/drivers/DriverUsagePolicyService'); + services.registerService('driver-usage-policy', DriverUsagePolicyService); + + const { ReferralCodeService } = require('./services/ReferralCodeService'); + services.registerService('referral-code', ReferralCodeService); + + const { VerifiedGroupService } = require('./services/VerifiedGroupService'); + services.registerService('__verified-group', VerifiedGroupService); + + const { UserService } = require('./services/UserService'); + services.registerService('user', UserService); + + const { WSPushService } = require('./services/WSPushService'); + services.registerService('__event-push-ws', WSPushService); + + const { SNSService } = require('./services/SNSService'); + services.registerService('sns', SNSService); + + const { WispService } = require('./services/WispService'); + services.registerService('wisp', WispService); + // const { AWSSecretsPopulator } = require('./services/AWSSecretsPopulator.js'); + // services.registerService('awsthing', AWSSecretsPopulator); + const { WebDavFS } = require('./services/WebDAV/WebDAVService.js'); + services.registerService('dav', WebDavFS); + + const { RequestMeasureService } = require('./services/RequestMeasureService'); + services.registerService('request-measure', RequestMeasureService); + + const { ChatAPIService } = require('./services/ChatAPIService'); + services.registerService('__chat-api', ChatAPIService); + + const { WorkerService } = require('./services/worker/WorkerService'); + services.registerService('worker-service', WorkerService); + + const { MeteringServiceWrapper } = require('./services/MeteringService/MeteringServiceWrapper.mjs'); + services.registerService('meteringService', MeteringServiceWrapper); + + const { PermissionShortcutService } = require('./services/auth/PermissionShortcutService'); + services.registerService('permission-shortcut', PermissionShortcutService); + + const { FileCacheService } = require('./services/file-cache/FileCacheService'); + services.registerService('file-cache', FileCacheService); +}; + +const install_legacy = async ({ services }) => { + const { OperationTraceService } = require('./services/OperationTraceService'); + const { ClientOperationService } = require('./services/ClientOperationService'); + const { EngPortalService } = require('./services/EngPortalService'); + + // === Services which do not yet extend BaseService === + // services.registerService('filesystem', FilesystemService); + services.registerService('operationTrace', OperationTraceService); + services.registerService('client-operation', ClientOperationService); + services.registerService('engineering-portal', EngPortalService); + +}; + +/** + * Core module for the Puter platform that includes essential services including + * authentication, filesystems, rate limiting, permissions, and various API endpoints. + * + * This is a monolithic module. Incrementally, services should be migrated to + * Core2Module and other modules instead. Core2Module has a smaller scope, and each + * new module will be a cohesive concern. Once CoreModule is empty, it will be removed + * and Core2Module will take on its name. + */ +class CoreModule extends AdvancedBase { + dirname () { + return __dirname; + } + async install (context) { + const services = context.get('services'); + const app = context.get('app'); + const useapi = context.get('useapi'); + const modapi = context.get('modapi'); + await install({ context, services, app, useapi, modapi }); + } + + /** + * Installs legacy services that don't extend BaseService and require special handling. + * These services were created before the BaseService class existed and don't listen + * to the init event. They need to be installed after the init event is dispatched + * due to initialization order dependencies. + * + * @param {Object} context - The context object containing service references + * @param {Object} context.services - Service registry for registering legacy services + * @returns {Promise} Resolves when legacy services are installed + */ + async install_legacy (context) { + const services = context.get('services'); + await install_legacy({ services }); + } +} + +module.exports = CoreModule; diff --git a/src/backend/src/DatabaseModule.js b/src/backend/src/DatabaseModule.js new file mode 100644 index 0000000000000000000000000000000000000000..0a393710e84ceb1515b7cc3b0528d1a003135e82 --- /dev/null +++ b/src/backend/src/DatabaseModule.js @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); + +class DatabaseModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { StrategizedService } = require('./services/StrategizedService'); + const { SqliteDatabaseAccessService } = require('./services/database/SqliteDatabaseAccessService'); + services.registerService('database', StrategizedService, { + strategy_key: 'engine', + strategies: { + sqlite: [SqliteDatabaseAccessService], + }, + }); + } +} + +module.exports = DatabaseModule; diff --git a/src/backend/src/Extension.js b/src/backend/src/Extension.js new file mode 100644 index 0000000000000000000000000000000000000000..07e5d92630f122e8371a604ac93ea18396ecb6cc --- /dev/null +++ b/src/backend/src/Extension.js @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const EmitterFeature = require('@heyputer/putility/src/features/EmitterFeature'); +const { Context } = require('./util/context'); +const { ExtensionServiceState } = require('./ExtensionService'); + +const module_epoch_d = new Date(); +const display_time = (now) => { + const pad2 = n => String(n).padStart(2, '0'); + + const yyyy = now.getFullYear(); + const mm = pad2(now.getMonth() + 1); + const dd = pad2(now.getDate()); + const HH = pad2(now.getHours()); + const MM = pad2(now.getMinutes()); + const SS = pad2(now.getSeconds()); + const time = `${HH}:${MM}:${SS}`; + + const needYear = yyyy !== module_epoch_d.getFullYear(); + const needMonth = needYear || (now.getMonth() !== module_epoch_d.getMonth()); + const needDay = needMonth || (now.getDate() !== module_epoch_d.getDate()); + + if ( needYear ) return `${yyyy}-${mm}-${dd} ${time}`; + if ( needMonth ) return `${mm}-${dd} ${time}`; + if ( needDay ) return `${dd} ${time}`; + return time; +}; + +let memoized_errors = null; + +/** + * This class creates the `extension` global that is seen by Puter backend + * extensions. + */ +class Extension extends AdvancedBase { + static FEATURES = [ + EmitterFeature({ + decorators: [ + fn => Context.get(undefined, { + allow_fallback: true, + }).abind(fn), + ], + }), + ]; + + constructor (...a) { + super(...a); + this.service = null; + this.log = null; + this.ensure_service_(); + + // this.terminal_color = this.randomBrightColor(); + this.terminal_color = 94; + + this.log = (...a) => { + this.log_context.info(a.join(' ')); + }; + this.LOG = (...a) => { + this.log_context.noticeme(a.join(' ')); + }; + ['info', 'warn', 'debug', 'error', 'tick', 'noticeme', 'system'].forEach(lvl => { + this.log[lvl] = (...a) => { + this.log_context[lvl](...a); + }; + }); + + this.only_one_preinit_fn = null; + this.only_one_init_fn = null; + + this.registry = { + register: this.register.bind(this), + of: (typeKey) => { + return { + named: name => { + if ( arguments.length === 0 ) { + return this.registry_[typeKey].named; + } + return this.registry_[typeKey].named[name]; + }, + all: () => [ + ...Object.values(this.registry_[typeKey].named), + ...this.registry_[typeKey].anonymous, + ], + }; + }, + }; + } + + randomBrightColor () { + // Bright colors in ANSI (foreground codes 90–97) + const brightColors = [ + // 91, // Bright Red + 92, // Bright Green + // 93, // Bright Yellow + 94, // Bright Blue + 95, // Bright Magenta + // 96, // Bright Cyan + ]; + + return brightColors[Math.floor(Math.random() * brightColors.length)]; + } + + example () { + console.log('Example method called by an extension.'); + } + + // === [START] RuntimeModule aliases === + set exports (value) { + this.runtime.exports = value; + } + get exports () { + return this.runtime.exports; + } + import (name) { + return this.runtime.import(name); + } + // === [END] RuntimeModule aliases === + + /** + * This will get a database instance from the default service. + */ + get db () { + const db = this.service.values.get('db'); + if ( ! db ) { + throw new Error('extension tried to access database before it was ' + + 'initialized'); + } + return db; + } + + get services () { + const services = this.service.values.get('services'); + if ( ! services ) { + throw new Error('extension tried to access "services" before it was ' + + 'initialized'); + } + return services; + } + + get log_context () { + const log_context = this.service.values.get('log_context'); + if ( ! log_context ) { + throw new Error('extension tried to access "log_context" before it was ' + + 'initialized'); + } + return log_context; + } + + get errors () { + return memoized_errors ?? (() => { + return this.services.get('error-service').create(this.log_context); + })(); + } + + /** + * Register anonymous or named data to a particular type/category. + * @param {string} typeKey Type of data being registered + * @param {string} [key] Key of data being registered + * @param {any} data The data to be registered + */ + register (typeKey, keyOrData, data) { + if ( ! this.registry_[typeKey] ) { + this.registry_[typeKey] = { + named: {}, + anonymous: [], + }; + } + + const typeRegistry = this.registry_[typeKey]; + + if ( arguments.length <= 1 ) { + throw new Error('you must specify what to register'); + } + + if ( arguments.length === 2 ) { + data = keyOrData; + if ( Array.isArray(data) ) { + for ( const datum of data ) { + typeRegistry.anonymous.push(datum); + } + return; + } + typeRegistry.anonymous.push(data); + return; + } + + const key = keyOrData; + typeRegistry.named[key] = data; + } + + /** + * Alias for .register() + * @param {string} typeKey Type of data being registered + * @param {string} [key] Key of data being registered + * @param {any} data The data to be registered + */ + reg (...a) { + this.register(...a); + } + + /** + * This will create a GET endpoint on the default service. + * @param {*} path - route for the endpoint + * @param {*} handler - function to handle the endpoint + * @param {*} options - options like noauth (bool) and mw (array) + */ + get (path, handler, options) { + // this extension will have a default service + this.ensure_service_(); + + // handler and options may be flipped + if ( typeof handler === 'object' ) { + [handler, options] = [options, handler]; + } + if ( ! options ) options = {}; + + this.service.register_route_handler_(path, handler, { + ...options, + methods: ['GET'], + }); + } + + /** + * This will create a POST endpoint on the default service. + * @param {*} path - route for the endpoint + * @param {*} handler - function to handle the endpoint + * @param {*} options - options like noauth (bool) and mw (array) + */ + post (path, handler, options) { + // this extension will have a default service + this.ensure_service_(); + + // handler and options may be flipped + if ( typeof handler === 'object' ) { + [handler, options] = [options, handler]; + } + if ( ! options ) options = {}; + + this.service.register_route_handler_(path, handler, { + ...options, + methods: ['POST'], + }); + } + + /** + * This will create a DELETE endpoint on the default service. + * @param {*} path - route for the endpoint + * @param {*} handler - function to handle the endpoint + * @param {*} options - options like noauth (bool) and mw (array) + */ + put (path, handler, options) { + // this extension will have a default service + this.ensure_service_(); + + // handler and options may be flipped + if ( typeof handler === 'object' ) { + [handler, options] = [options, handler]; + } + if ( ! options ) options = {}; + + this.service.register_route_handler_(path, handler, { + ...options, + methods: ['PUT'], + }); + } + /** + * This will create a DELETE endpoint on the default service. + * @param {*} path - route for the endpoint + * @param {*} handler - function to handle the endpoint + * @param {*} options - options like noauth (bool) and mw (array) + */ + + delete (path, handler, options) { + // this extension will have a default service + this.ensure_service_(); + + // handler and options may be flipped + if ( typeof handler === 'object' ) { + [handler, options] = [options, handler]; + } + if ( ! options ) options = {}; + + this.service.register_route_handler_(path, handler, { + ...options, + methods: ['DELETE'], + }); + } + + use (...args) { + this.ensure_service_(); + this.service.expressThings_.push({ + type: 'router', + value: args, + }); + } + + get preinit () { + return (function (callback) { + this.on('preinit', callback); + }).bind(this); + } + set preinit (callback) { + if ( this.only_one_preinit_fn === null ) { + this.on('preinit', (...a) => { + this.only_one_preinit_fn(...a); + }); + } + if ( callback === null ) { + this.only_one_preinit_fn = () => { + }; + } + this.only_one_preinit_fn = callback; + } + + get init () { + return (function (callback) { + this.on('init', callback); + }).bind(this); + } + set init (callback) { + if ( this.only_one_init_fn === null ) { + this.on('init', (...a) => { + this.only_one_init_fn(...a); + }); + } + if ( callback === null ) { + this.only_one_init_fn = () => { + }; + } + this.only_one_init_fn = callback; + } + + get console () { + const extensionConsole = Object.create(console); + const logfn = level => (...a) => { + let svc_log; + + try { + svc_log = this.services.get('log-service'); + } catch ( _e ) { + // NOOP + } + + if ( ! svc_log ) { + const realConsole = globalThis.original_console_object ?? console; + realConsole[(level => { + if ( ['error', 'warn', 'debug'].includes(level) ) return level; + return 'log'; + })(level)](`${display_time(new Date())} \x1B[${this.terminal_color};1m(extension/${this.name})\x1B[0m`, ...a); + return; + } + + const extensionLogger = svc_log.create(`extension/${this.name}`); + const util = require('node:util'); + const consoleStyle = a.map(arg => { + if ( typeof arg === 'string' ) return arg; + return util.inspect(arg, undefined, undefined, true); + }).join(' '); + extensionLogger[level](consoleStyle); + }; + extensionConsole.log = logfn('info'); + extensionConsole.error = logfn('error'); + extensionConsole.warn = logfn('warn'); + return extensionConsole; + } + + get tracer () { + const trace = this.import('tel').trace; + return trace.getTracer(`extension:${this.name}`); + } + + get span () { + const span = (label, fn) => { + const spanify = this.import('core').spanify; + return spanify(label, fn, this.tracer); + }; + + // Add `.run` for more readable immediate invocation + span.run = (label, fn) => { + if ( typeof label === 'function' ) { + fn = label; + label = fn.name || 'span.run'; + } + return span(label, fn)(); + }; + + return span; + } + + /** + * This method will create the "default service" for an extension. + * This is specifically for Puter extensions that do not define their + * own service classes. + * + * @returns {void} + */ + ensure_service_ () { + if ( this.service ) { + return; + } + + this.service = new ExtensionServiceState({ + extension: this, + }); + } +} + +module.exports = { + Extension, +}; diff --git a/src/backend/src/ExtensionModule.js b/src/backend/src/ExtensionModule.js new file mode 100644 index 0000000000000000000000000000000000000000..ca9c909f41f70eb7f9f59b7411166b6b74074f2c --- /dev/null +++ b/src/backend/src/ExtensionModule.js @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const uuid = require('uuid'); +const { ExtensionService } = require('./ExtensionService'); + +class ExtensionModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + this.extension.name = this.extension.name ?? context.name; + this.extension.emit('install', { context, services }); + + if ( this.extension.service ) { + services.registerService(uuid.v4(), ExtensionService, { + state: this.extension.service, + }); // uuid for now + } + } +} + +module.exports = { + ExtensionModule, +}; diff --git a/src/backend/src/ExtensionService.js b/src/backend/src/ExtensionService.js new file mode 100644 index 0000000000000000000000000000000000000000..a6b11ecebdd1b13afc12a3669315cdbc63da97bc --- /dev/null +++ b/src/backend/src/ExtensionService.js @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const BaseService = require('./services/BaseService'); +const { Endpoint } = require('./util/expressutil'); +const configurable_auth = require('./middleware/configurable_auth'); +const { Context } = require('./util/context'); +const { DB_WRITE } = require('./services/database/consts'); +const { Actor } = require('./services/auth/Actor'); + +/** + * State shared with the default service and the `extension` global so that + * methods on `extension` can register routes (and make other changes in the + * future) to the default service. + */ +class ExtensionServiceState extends AdvancedBase { + constructor (...a) { + super(...a); + + this.extension = a[0].extension; + + this.expressThings_ = []; + + // Values shared between the `extension` global and its service + this.values = new Context(); + } + register_route_handler_ (path, handler, options = {}) { + // handler and options may be flipped + if ( typeof handler === 'object' ) { + [handler, options] = [options, handler]; + } + + const mw = options.mw ?? []; + + // TODO: option for auth middleware is harcoded here, but eventually + // all exposed middlewares should be registered under the simpele names + // used in this options object (probably; still not 100% decided on that) + if ( ! options.noauth ) { + const auth_conf = typeof options.auth === 'object' ? + options.auth : {}; + mw.push(configurable_auth(auth_conf)); + } + + const endpoint = Endpoint({ + methods: options.methods ?? ['GET'], + mw, + route: path, + handler: handler, + ...(options.subdomain ? { subdomain: options.subdomain } : {}), + otherOpts: options.otherOpts || {}, + }); + + this.expressThings_.push({ type: 'endpoint', value: endpoint }); + } +} + +/** + * A service that does absolutely nothing by default, but its behavior can be + * extended by adding route handlers and event listeners. This is used to + * provide a default service for extensions. + */ +class ExtensionService extends BaseService { + _construct () { + this.expressThings_ = []; + } + async _init (args) { + this.state = args.state; + + this.state.values.set('services', this.services); + this.state.values.set('log_context', this.services.get('log-service').create( + this.state.extension.name)); + + // Create database access object for extension + const db = this.services.get('database').get(DB_WRITE, 'extension'); + this.state.values.set('db', db); + + // Propagate all events from Puter's event bus to extensions + const svc_event = this.services.get('event'); + svc_event.on_all(async (key, data, meta = {}) => { + meta.from_outside_of_extension = true; + + await Context.sub({ + extension_name: this.state.extension.name, + }).arun(async () => { + const promises = [ + // push event to the extension's event bus + this.state.extension.emit(key, data, meta), + // legacy: older extensions prefix "core." to events from Puter + this.state.extension.emit(`core.${key}`, data, meta), + ]; + // await this.state.extension.emit(key, data, meta); + await Promise.all(promises); + }); + // await Promise.all(promises); + }); + + // Propagate all events from extension to Puter's event bus + this.state.extension.on_all(async (key, data, meta) => { + if ( meta.from_outside_of_extension ) return; + + await svc_event.emit(key, data, meta); + }); + + this.state.extension.kv = (() => { + const impls = this.services.get_implementors('puter-kvstore'); + const impl_kv = impls[0].impl; + + return new Proxy(impl_kv, { + get: (target, prop) => { + if ( typeof target[prop] !== 'function' ) { + return target[prop]; + } + + return (...args) => { + if ( typeof args[0] !== 'object' ) { + // Luckily named parameters don't have positional + // overlaps between the different kv methods, so + // we can just set them all. + args[0] = { + key: args[0], + as: args[0], + value: args[1], + amount: args[2], + timestamp: args[2], + ttl: args[2], + }; + } + return Context.sub({ + actor: Actor.get_system_actor(), + }).arun(() => target[prop](...args)); + }; + }, + }); + })(); + + this.state.extension.emit('preinit'); + } + + async ['__on_boot.consolidation'] (...a) { + const svc_su = this.services.get('su'); + await svc_su.sudo(async () => { + await this.state.extension.emit('init', {}, { + from_outside_of_extension: true, + }); + }); + } + async ['__on_boot.activation'] (...a) { + const svc_su = this.services.get('su'); + await svc_su.sudo(async () => { + await this.state.extension.emit('activate', {}, { + from_outside_of_extension: true, + }); + }); + } + async ['__on_boot.ready'] (...a) { + const svc_su = this.services.get('su'); + await svc_su.sudo(async () => { + await this.state.extension.emit('ready', {}, { + from_outside_of_extension: true, + }); + }); + } + + ['__on_install.routes'] (_, { app }) { + if ( ! this.state ) debugger; + for ( const thing of this.state.expressThings_ ) { + if ( thing.type === 'endpoint' ) { + thing.value.attach(app); + continue; + } + if ( thing.type === 'router' ) { + app.use(...thing.value); + continue; + } + } + } + +} + +module.exports = { + ExtensionService, + ExtensionServiceState, +}; diff --git a/src/backend/src/Kernel.js b/src/backend/src/Kernel.js new file mode 100644 index 0000000000000000000000000000000000000000..45051cb68ccaec8da732db4da61fddce8e748ce6 --- /dev/null +++ b/src/backend/src/Kernel.js @@ -0,0 +1,616 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase, libs } = require('@heyputer/putility'); +const { Context } = require('./util/context'); +const BaseService = require('./services/BaseService'); +const useapi = require('useapi'); +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); +const { Extension } = require('./Extension'); +const { ExtensionModule } = require('./ExtensionModule'); +const { spawn } = require('node:child_process'); + +const fs = require('fs'); +const path_ = require('path'); +const { prependToJSFiles } = require('./kernel/modutil'); + +const uuid = require('uuid'); +const readline = require('node:readline/promises'); +const { RuntimeModuleRegistry } = require('./extension/RuntimeModuleRegistry'); +const { RuntimeModule } = require('./extension/RuntimeModule'); +const deep_proto_merge = require('./config/deep_proto_merge'); +const { kv } = require('./util/kvSingleton'); + +const { quot } = libs.string; + +class Kernel extends AdvancedBase { + constructor ({ entry_path } = {}) { + super(); + + this.modules = []; + this.useapi = useapi(); + + this.useapi.withuse(() => { + def('Module', AdvancedBase); + def('Service', BaseService); + }); + + this.entry_path = entry_path; + this.extensionExports = {}; + this.extensionInfo = {}; + this.registry = {}; + + this.runtimeModuleRegistry = new RuntimeModuleRegistry(); + } + + add_module (module) { + this.modules.push(module); + } + + _runtime_init (boot_parameters) { + global.kv = kv; + global.cl = console.log; + + const { RuntimeEnvironment } = require('./boot/RuntimeEnvironment'); + const { BootLogger } = require('./boot/BootLogger'); + + // Temporary logger for boot process; + // LoggerService will be initialized in app.js + const bootLogger = new BootLogger(); + this.bootLogger = bootLogger; + + // Determine config and runtime locations + const runtimeEnv = new RuntimeEnvironment({ + entry_path: this.entry_path, + logger: bootLogger, + boot_parameters, + }); + const environment = runtimeEnv.init(); + this.environment = environment; + + // polyfills + require('./polyfill/to-string-higher-radix'); + } + + boot () { + const args = yargs(hideBin(process.argv)).argv; + + this._runtime_init({ args }); + + const config = require('./config'); + + globalThis.ll = o => o; + globalThis.xtra_log = () => { + }; + if ( config.env === 'dev' ) { + globalThis.ll = o => { + console.log(`debug: ${ require('node:util').inspect(o)}`); + return o; + }; + globalThis.xtra_log = (...args) => { + // append to file in temp + const fs = require('fs'); + const path = require('path'); + const log_path = path.join('/tmp/xtra_log.txt'); + fs.appendFileSync(log_path, `${args.join(' ') }\n`); + }; + } + + const { consoleLogManager } = require('./util/consolelog'); + consoleLogManager.initialize_proxy_methods(); + + // === START: Initialize Service Registry === + const { Container } = require('./services/Container'); + + const services = new Container({ logger: this.bootLogger }); + this.services = services; + + const root_context = Context.create({ + environment: this.environment, + useapi: this.useapi, + services, + config, + logger: this.bootLogger, + extensionExports: this.extensionExports, + extensionInfo: this.extensionInfo, + registry: this.registry, + args, + ['runtime-modules']: this.runtimeModuleRegistry, + }, 'app'); + globalThis.root_context = root_context; + + root_context.arun(async () => { + await this._install_modules(); + await this._boot_services(); + }); + + Error.stackTraceLimit = 20; + } + + async _install_modules () { + const { services } = this; + + // Internal modules + for ( const module_ of this.modules ) { + services.registerModule(module_.constructor.name, module_); + const mod_context = this._create_mod_context(Context.get(), { + name: module_.constructor.name, + ['module']: module_, + external: false, + }); + await module_.install(mod_context); + } + + for ( const k in services.instances_ ) { + const service_exports = new RuntimeModule({ name: `service:${k}` }); + this.runtimeModuleRegistry.register(service_exports); + service_exports.exports = services.instances_[k]; + } + + // External modules + await this.install_extern_mods_(); + + try { + await services.init(); + } catch (e) { + // First we'll try to mark the system as invalid via + // SystemValidationService. This might fail because this service + // may not be initialized yet. + + const svc_systemValidation = (() => { + try { + return services.get('system-validation'); + } catch (e) { + return null; + } + })(); + + if ( ! svc_systemValidation ) { + // If we can't mark the system as invalid, we'll just have to + // throw the error and let the server crash. + throw e; + } + + await svc_systemValidation.mark_invalid('failed to initialize services', + e); + } + + for ( const module of this.modules ) { + await module.install_legacy?.(Context.get()); + } + + services.ready.resolve(); + // provide services to helpers + + const { tmp_provide_services } = require('./helpers'); + tmp_provide_services(services); + } + + async _boot_services () { + const { services } = this; + + await services.ready; + await services.emit('boot.consolidation'); + + // === END: Initialize Service Registry === + + // self check + (async () => { + await services.ready; + globalThis.services = services; + const log = services.get('log-service').create('init'); + log.system('server ready', { + deployment_type: globalThis.deployment_type, + }); + })(); + + await services.emit('boot.activation'); + await services.emit('boot.ready'); + + // Notify process managers (e.g., PM2 wait_ready) that boot completed + if ( typeof process.send === 'function' ) { + try { + process.send('ready'); + } catch ( err ) { + this.bootLogger?.error?.('failed to send ready signal', err); + } + } + } + + async install_extern_mods_ () { + + // In runtime directory, we'll create a `mod_packages` directory.` + if ( fs.existsSync('mod_packages') ) { + fs.rmSync('mod_packages', { recursive: true, force: true }); + } + fs.mkdirSync('mod_packages'); + + // Initialize some globals that external mods depend on + globalThis.__puter_extension_globals__ = { + extensionObjectRegistry: {}, + useapi: this.useapi, + global_config: require('./config'), + }; + + // Install the mods... + + const mod_install_root_context = Context.get(); + + const mod_directory_promises = []; + const mod_installation_promises = []; + + const mod_paths = this.environment.mod_paths; + for ( const mods_dirpath of mod_paths ) { + const p = (async () => { + if ( ! fs.existsSync(mods_dirpath) ) { + this.services.logger.error(`mod directory not found: ${quot(mods_dirpath)}; skipping...`); + // intentional delay so error is seen + this.services.logger.info('boot will continue in 4 seconds'); + await new Promise(rslv => setTimeout(rslv, 4000)); + return; + } + const mod_dirnames = await fs.promises.readdir(mods_dirpath); + + const ignoreList = new Set([ + '.git', + ]); + + for ( const mod_dirname of mod_dirnames ) { + if ( ignoreList.has(mod_dirname) ) continue; + mod_installation_promises.push(this.install_extern_mod_({ + mod_install_root_context, + mod_dirname, + mod_path: path_.join(mods_dirpath, mod_dirname), + })); + } + })(); + if ( process.env.SYNC_MOD_INSTALL ) await p; + mod_directory_promises.push(p); + } + + await Promise.all(mod_directory_promises); + + const mods_to_run = (await Promise.all(mod_installation_promises)) + .filter(v => v !== undefined); + mods_to_run.sort((a, b) => a.priority - b.priority); + let i = 0; + while ( i < mods_to_run.length ) { + const currentPriority = mods_to_run[i].priority; + const samePriorityMods = []; + + // Collect all mods with the same priority + while ( i < mods_to_run.length && mods_to_run[i].priority === currentPriority ) { + samePriorityMods.push(mods_to_run[i]); + i++; + } + + // Run all mods with the same priority concurrently + await Promise.all(samePriorityMods.map(mod_entry => { + return this._run_extern_mod(mod_entry); + })); + } + } + + async install_extern_mod_ ({ + mod_install_root_context, + mod_dirname, + mod_path, + }) { + let stat = fs.lstatSync(mod_path); + while ( stat.isSymbolicLink() ) { + mod_path = fs.readlinkSync(mod_path); + stat = fs.lstatSync(mod_path); + } + + // Mod must be a directory or javascript file + if ( !stat.isDirectory() && !(mod_path.endsWith('.js')) ) { + return; + } + + let mod_name = path_.parse(mod_path).name; + const mod_package_dir = `mod_packages/${mod_name}`; + fs.mkdirSync(mod_package_dir); + + const mod_entry = { + priority: 0, + jsons: {}, + }; + + if ( ! stat.isDirectory() ) { + const rl = readline.createInterface({ + input: fs.createReadStream(mod_path), + }); + for await ( const line of rl ) { + if ( line.trim() === '' ) continue; + if ( ! line.startsWith('//@extension') ) break; + const tokens = line.split(' '); + if ( tokens[1] === 'priority' ) { + mod_entry.priority = Number(tokens[2]); + } + if ( tokens[1] === 'name' ) { + mod_name = `${ tokens[2]}`; + } + } + mod_entry.jsons.package = await this.create_mod_package_json(mod_package_dir, { + name: mod_name, + entry: 'main.js', + }); + await fs.promises.copyFile(mod_path, path_.join(mod_package_dir, 'main.js')); + } else { + // If directory is empty, we'll just skip it + if ( fs.readdirSync(mod_path).length === 0 ) { + this.bootLogger.warn(`Empty mod directory ${quot(mod_path)}; skipping...`); + return; + } + + const promises = []; + + // Create package.json if it doesn't exist + promises.push((async () => { + if ( ! fs.existsSync(path_.join(mod_path, 'package.json')) ) { + mod_entry.jsons.package = await this.create_mod_package_json(mod_package_dir, { + name: mod_name, + }); + } else { + const bin = await fs.promises.readFile(path_.join(mod_path, 'package.json')); + const str = bin.toString(); + mod_entry.jsons.package = JSON.parse(str); + } + })()); + + const puter_json_path = path_.join(mod_path, 'puter.json'); + if ( fs.existsSync(puter_json_path) ) { + promises.push((async () => { + const buffer = await fs.promises.readFile(puter_json_path); + const json = buffer.toString(); + const obj = JSON.parse(json); + mod_entry.priority = obj.priority ?? mod_entry.priority; + mod_entry.jsons.puter = obj; + })()); + } + + const config_json_path = path_.join(mod_path, 'config.json'); + if ( fs.existsSync(config_json_path) ) { + promises.push((async () => { + const buffer = await fs.promises.readFile(config_json_path); + const json = buffer.toString(); + const obj = JSON.parse(json); + mod_entry.priority = obj.priority ?? mod_entry.priority; + mod_entry.jsons.config = obj; + })()); + } + + // Copy mod contents to `/mod_packages` + promises.push(fs.promises.cp(mod_path, mod_package_dir, { + recursive: true, + })); + + await Promise.all(promises); + } + + mod_entry.priority = mod_entry.jsons.puter?.priority ?? mod_entry.priority; + + const extension_id = uuid.v4(); + + await prependToJSFiles(mod_package_dir, `${[ + 'const { use, def } = globalThis.__puter_extension_globals__.useapi;', + 'const { use: puter } = globalThis.__puter_extension_globals__.useapi;', + 'const extension = globalThis.__puter_extension_globals__' + + `.extensionObjectRegistry[${JSON.stringify(extension_id)}];`, + 'const console = extension.console;', + 'const runtime = extension.runtime;', + 'const config = extension.config;', + 'const registry = extension.registry;', + 'const register = registry.register;', + 'const global_config = globalThis.__puter_extension_globals__.global_config', + ].join('\n') }\n`); + + mod_entry.require_dir = path_.join(process.cwd(), mod_package_dir); + + await this.run_npm_install(mod_entry.require_dir); + + const mod = new ExtensionModule(); + mod.extension = new Extension(); + + const runtimeModule = new RuntimeModule({ name: mod_name }); + this.runtimeModuleRegistry.register(runtimeModule); + mod.extension.runtime = runtimeModule; + + mod_entry.module = mod; + + globalThis.__puter_extension_globals__.extensionObjectRegistry[extension_id] + = mod.extension; + + const mod_context = this._create_mod_context(mod_install_root_context, { + name: mod_name, + ['module']: mod, + external: true, + mod_path, + }); + + mod_entry.context = mod_context; + + return mod_entry; + }; + + async _run_extern_mod (mod_entry) { + let exportObject = null; + + const { + module: mod, + require_dir, + context, + } = mod_entry; + + const packageJSON = mod_entry.jsons.package; + + Object.defineProperty(mod.extension, 'config', { + get: () => { + const builtin_config = mod_entry.jsons.config ?? {}; + const user_config = require('./config').extensions?.[packageJSON.name] ?? {}; + return deep_proto_merge(user_config, builtin_config); + }, + }); + + mod.extension.name = packageJSON.name; + + const maybe_promise = (typ => typ.trim().toLowerCase())(packageJSON.type ?? '') === 'module' + ? await import(path_.join(require_dir, packageJSON.main ?? 'index.js')) + : require(require_dir); + + if ( maybe_promise && maybe_promise instanceof Promise ) { + exportObject = await maybe_promise; + } else exportObject = maybe_promise; + + const extension_name = exportObject?.name ?? packageJSON.name; + this.extensionExports[extension_name] = exportObject; + this.extensionInfo[extension_name] = { + name: extension_name, + priority: mod_entry.priority, + type: packageJSON?.type ?? 'commonjs', + }; + mod.extension.registry = this.registry; + mod.extension.name = extension_name; + + if ( exportObject.construct ) { + mod.extension.on('construct', exportObject.construct); + } + if ( exportObject.preinit ) { + mod.extension.on('preinit', exportObject.preinit); + } + + if ( exportObject.init ) { + mod.extension.on('init', exportObject.init); + } + + // This is where the 'install' event gets triggered + await mod.install(context); + } + + _create_mod_context (parent, options) { + const modapi = {}; + + let mod_path = options.mod_path; + if ( !mod_path && options.module.dirname ) { + mod_path = options.module.dirname(); + } + + if ( mod_path ) { + modapi.libdir = (prefix, directory) => { + const fullpath = path_.join(mod_path, directory); + const fsitems = fs.readdirSync(fullpath); + for ( const item of fsitems ) { + if ( ! item.endsWith('.js') ) { + continue; + } + if ( item.endsWith('.test.js') ) { + continue; + } + const stat = fs.statSync(path_.join(fullpath, item)); + if ( ! stat.isFile() ) { + continue; + } + + const name = item.slice(0, -3); + const path = path_.join(fullpath, item); + let lib = require(path); + + // TODO: This context can be made dynamic by adding a + // getter-like behavior to useapi. + this.useapi.def(`${prefix}.${name}`, lib); + } + }; + } + const mod_context = parent.sub({ modapi }, `mod:${options.name}`); + return mod_context; + + } + + async create_mod_package_json (mod_path, { name, entry }) { + // Expect main.js or index.js to exist + const options = ['main.js', 'index.js']; + + // If no entry specified, find file with conventional name + if ( ! entry ) { + for ( const option of options ) { + if ( fs.existsSync(path_.join(mod_path, option)) ) { + entry = option; + break; + } + } + } + + // If no entry specified or found, skip or error + if ( ! entry ) { + this.bootLogger.error(`Expected main.js or index.js in ${quot(mod_path)}`); + if ( ! process.env.SKIP_INVALID_MODS ) { + this.bootLogger.error('Set SKIP_INVALID_MODS=1 (environment variable) to run anyway.'); + process.exit(1); + } else { + return; + } + } + + const data = { + name, + version: '1.0.0', + main: entry ?? 'main.js', + }; + const data_json = JSON.stringify(data); + + this.bootLogger.debug(`WRITING TO: ${ path_.join(mod_path, 'package.json')}`); + + await fs.promises.writeFile(path_.join(mod_path, 'package.json'), data_json); + return data; + } + + async run_npm_install (path) { + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const proc = spawn(npmCmd, ['install'], { cwd: path, stdio: 'pipe' }); + + let buffer = ''; + + proc.stdout.on('data', (data) => { + buffer += data.toString(); + }); + + proc.stderr.on('data', (data) => { + buffer += data.toString(); + }); + + return new Promise((rslv, rjct) => { + proc.on('close', code => { + if ( code !== 0 ) { + // Print buffered output on error + if ( buffer ) process.stdout.write(buffer); + rjct(new Error(`exit code: ${code}`)); + return; + } + rslv(); + }); + proc.on('error', err => { + // Print buffered output on error + if ( buffer ) process.stdout.write(buffer); + rjct(err); + }); + }); + } +} + +module.exports = { Kernel }; diff --git a/src/backend/src/LocalDiskStorageModule.js b/src/backend/src/LocalDiskStorageModule.js new file mode 100644 index 0000000000000000000000000000000000000000..43c9df28e2a1e113e545e67d48110d4d690cb444 --- /dev/null +++ b/src/backend/src/LocalDiskStorageModule.js @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); + +class LocalDiskStorageModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + const LocalDiskStorageService = require('./services/LocalDiskStorageService'); + services.registerService('local-disk-storage', LocalDiskStorageService); + + const HostDiskUsageService = require('./services/HostDiskUsageService'); + services.registerService('host-disk-usage', HostDiskUsageService); + } +} + +module.exports = LocalDiskStorageModule; diff --git a/src/backend/src/MemoryStorageModule.js b/src/backend/src/MemoryStorageModule.js new file mode 100644 index 0000000000000000000000000000000000000000..b69ca1f2a561eb27e52586c96ab274163aee80dd --- /dev/null +++ b/src/backend/src/MemoryStorageModule.js @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +class MemoryStorageModule { + async install (context) { + const services = context.get('services'); + const MemoryStorageService = require('./services/MemoryStorageService'); + services.registerService('memory-storage', MemoryStorageService); + } +} + +module.exports = MemoryStorageModule; diff --git a/src/backend/src/annotatedobjects.js b/src/backend/src/annotatedobjects.js new file mode 100644 index 0000000000000000000000000000000000000000..25a12756d746198c5d3ef4c2646744fa1bf61720 --- /dev/null +++ b/src/backend/src/annotatedobjects.js @@ -0,0 +1,20 @@ +// This sucks, but the concept is simple... + +// When debugging memory leaks, sometimes plain objects (rather than instances +// of classes) are the culprit. However, theses are very difficult to identify +// in heap snapshots using the Memory tab in Chromium dev tools. + +// These annotated classes provide a solution to wrap plain objects. + +class AnnotatedObject { + constructor (o) { + for ( const k in o ) this[k] = o[k]; + } +} + +class object_returned_by_get_app extends AnnotatedObject { +}; + +module.exports = { + object_returned_by_get_app, +}; diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js new file mode 100644 index 0000000000000000000000000000000000000000..34c0438d3e58ddb56f721e9588adfb27d239f6ce --- /dev/null +++ b/src/backend/src/api/APIError.js @@ -0,0 +1,674 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { URLSearchParams } = require('node:url'); +const { quot } = require('@heyputer/putility').libs.string; + +/** + * APIError represents an error that can be sent to the client. + * @class APIError + * @property {number} status the HTTP status code + * @property {string} message the error message + * @property {object} source the source of the error + */ +class APIError { + static codes = { + // General + 'unknown_error': { + status: 500, + message: () => 'An unknown error occurred', + }, + 'format_error': { + status: 400, + message: ({ message }) => `format error: ${message}`, + }, + 'temp_error': { + status: 400, + message: ({ message }) => `error: ${message}`, + }, + 'disallowed_value': { + status: 400, + message: ({ key, allowed }) => + `value of ${quot(key)} must be one of: ${ + allowed.map(v => quot(v)).join(', ')}`, + }, + 'invalid_token': { + status: 400, + message: () => 'Invalid token', + }, + 'unrecognized_offering': { + status: 400, + message: ({ name }) => { + return `offering ${quot(name)} was not recognized.`; + }, + }, + 'error_400_from_delegate': { + status: 400, + message: ({ delegate, message }) => `Error 400 from delegate ${quot(delegate)}: ${message}`, + }, + // Things + 'disallowed_thing': { + status: 400, + message: ({ thing_type, accepted }) => + `Request contained a ${quot(thing_type)} in a ` + + `place where ${quot(thing_type)} isn't accepted${ + + accepted + ? '; ' + + `accepted types are: ${ + accepted.map(v => quot(v)).join(', ')}` + : ''}.`, + }, + + // Unorganized + 'item_with_same_name_exists': { + status: 409, + message: ({ entry_name }) => entry_name + ? `An item with name ${quot(entry_name)} already exists.` + : 'An item with the same name already exists.' + , + }, + 'cannot_move_item_into_itself': { + status: 422, + message: 'Cannot move an item into itself.', + }, + 'cannot_copy_item_into_itself': { + status: 422, + message: 'Cannot copy an item into itself.', + }, + 'cannot_move_to_root': { + status: 422, + message: 'Cannot move an item to the root directory.', + }, + 'cannot_copy_to_root': { + status: 422, + message: 'Cannot copy an item to the root directory.', + }, + 'cannot_write_to_root': { + status: 422, + message: 'Cannot write an item to the root directory.', + }, + 'cannot_overwrite_a_directory': { + status: 422, + message: 'Cannot overwrite a directory.', + }, + 'cannot_read_a_directory': { + status: 422, + message: 'Cannot read a directory.', + }, + 'source_and_dest_are_the_same': { + status: 422, + message: 'Source and destination are the same.', + }, + 'dest_is_not_a_directory': { + status: 422, + message: 'Destination must be a directory.', + }, + 'dest_does_not_exist': { + status: 422, + message: 'Destination was not found.', + }, + 'source_does_not_exist': { + status: 404, + message: 'Source was not found.', + }, + 'subject_does_not_exist': { + status: 404, + message: 'File or directory not found.', + }, + 'shortcut_target_not_found': { + status: 404, + message: 'Shortcut target not found.', + }, + 'shortcut_target_is_a_directory': { + status: 422, + message: 'Shortcut target is a directory; expected a file.', + }, + 'shortcut_target_is_a_file': { + status: 422, + message: 'Shortcut target is a file; expected a directory.', + }, + 'forbidden': { + status: 403, + message: 'Permission denied.', + }, + 'immutable': { + status: 403, + message: 'File is immutable.', + }, + 'field_empty': { + status: 400, + message: ({ key }) => `Field ${quot(key)} is required.`, + }, + 'too_many_keys': { + status: 400, + message: ({ key }) => `Field ${quot(key)} cannot contain more than 100 elements.`, + }, + 'field_missing': { + status: 400, + message: ({ key }) => `Field ${quot(key)} is required.`, + }, + 'fields_missing': { + status: 400, + message: ({ keys }) => `The following fields are required but missing: ${keys.map(quot).join(', ')}.`, + }, + 'xor_field_missing': { + status: 400, + message: ({ names }) => { + let s = 'One of these mutually-exclusive fields is required: '; + s += names.map(quot).join(', '); + return s; + }, + }, + 'field_only_valid_with_other_field': { + status: 400, + message: ({ key, other_key }) => `Field ${quot(key)} is only valid when field ${quot(other_key)} is specified.`, + }, + 'invalid_id': { + status: 400, + message: ({ id }) => { + return `Invalid id ${id}`; + }, + }, + 'invalid_operation': { + status: 400, + message: ({ operation }) => `Invalid operation: ${quot(operation)}.`, + }, + 'field_invalid': { + status: 400, + message: ({ key, expected, got }) => { + return `Field ${quot(key)} is invalid.${ + expected ? ` Expected ${expected}.` : '' + }${got ? ` Got ${got}.` : ''}`; + }, + }, + 'fields_invalid': { + status: 400, + message: ({ errors }) => { + let s = 'The following validation errors occurred: '; + s += errors.map(error => `Field ${quot(error.key)} is invalid.${ + error.expected ? ` Expected ${error.expected}.` : '' + }${error.got ? ` Got ${error.got}.` : ''}`).join(', '); + return s; + }, + }, + 'field_immutable': { + status: 400, + message: ({ key }) => `Field ${quot(key)} is immutable.`, + }, + 'field_too_long': { + status: 400, + message: ({ key, max_length }) => `Field ${quot(key)} is too long. Max length is ${max_length}.`, + }, + 'field_too_short': { + status: 400, + message: ({ key, min_length }) => `Field ${quot(key)} is too short. Min length is ${min_length}.`, + }, + 'already_in_use': { + status: 409, + message: ({ what, value }) => `The ${what} ${quot(value)} is already in use.`, + }, + 'invalid_file_name': { + status: 400, + message: ({ name, reason }) => `Invalid file name: ${quot(name)}${reason ? `; ${reason}` : '.'}`, + }, + 'storage_limit_reached': { + status: 400, + message: 'Storage capacity limit reached.', + }, + 'internal_error': { + status: 500, + message: ({ message }) => message + ? `An internal error occurred: ${quot(message)}` + : 'An internal error occurred.', + }, + 'response_timeout': { + status: 504, + message: 'Response timed out.', + }, + 'file_too_large': { + status: 413, + message: ({ max_size }) => `File too large. Max size is ${max_size} bytes.`, + }, + 'thumbnail_too_large': { + status: 413, + message: ({ max_size }) => `Thumbnail too large. Max size is ${max_size} bytes.`, + }, + 'upload_failed': { + status: 500, + message: 'Upload failed.', + }, + 'missing_expected_metadata': { + status: 400, + message: ({ keys }) => `These fields must come first: ${(keys ?? []).map(quot).join(', ')}.`, + }, + 'overwrite_and_dedupe_exclusive': { + status: 400, + message: 'Cannot specify both overwrite and dedupe_name.', + }, + 'not_empty': { + status: 422, + message: 'Directory is not empty.', + }, + 'readdir_of_non_directory': { + status: 422, + message: 'Readdir target must be a directory.', + }, + + // Write + 'offset_without_existing_file': { + status: 404, + message: 'An offset was specified, but the file doesn\'t exist.', + }, + 'offset_requires_overwrite': { + status: 400, + message: 'An offset was specified, but overwrite conditions were not met.', + }, + 'offset_requires_stream': { + status: 400, + message: 'The offset option for write is not available for this upload.', + }, + + // Batch + 'batch_too_many_files': { + status: 400, + message: 'Received an extra file with no corresponding operation.', + }, + 'batch_missing_file': { + status: 400, + message: 'Missing fileinfo entry or BLOB for operation.', + }, + 'invalid_file_metadata': { + status: 400, + message: 'Invalid file metadata.', + }, + 'unresolved_relative_path': { + status: 400, + message: ({ path }) => `Unresolved relative path: ${quot(path)}. ` + + "You may need to specify a full path starting with '/'.", + }, + 'missing_filesystem_capability': { + status: 422, + message: ({ action, subjectName, providerName, capability }) => { + return `Cannot perform action ${quot(action)} on ` + + `${quot(subjectName)} because it is inside a filesystem ` + + `of type ${providerName}, which does not implement the ` + + `required capability called ${quot(capability)}.`; + }, + }, + + // Open + 'no_suitable_app': { + status: 422, + message: ({ entry_name }) => `No suitable app found for ${quot(entry_name)}.`, + }, + 'app_does_not_exist': { + status: 422, + message: ({ identifier }) => `App ${quot(identifier)} does not exist.`, + }, + + // Apps + 'app_name_already_in_use': { + status: 409, + message: ({ name }) => `App name ${quot(name)} is already in use.`, + }, + + // Subdomains + 'subdomain_limit_reached': { + status: 400, + message: ({ limit, isWorker }) => isWorker ? `You have exceeded the maximum number of workers for your plan! (${limit})` : `You have exceeded the number of subdomains under your current plan (${limit}).`, + }, + 'subdomain_reserved': { + status: 400, + message: ({ subdomain }) => `Subdomain ${quot(subdomain)} is not available.`, + }, + 'subdomain_not_owned': { + status: 403, + message: ({ subdomain }) => `You must own the ${quot(subdomain)} subdomain on Puter to use it for this app.`, + }, + + // Users + 'email_already_in_use': { + status: 409, + message: ({ email }) => `Email ${quot(email)} is already in use.`, + }, + 'email_not_allowed': { + status: 400, + message: ({ email }) => `The email ${quot(email)} is not allowed.`, + }, + 'username_already_in_use': { + status: 409, + + message: ({ username }) => `Username ${quot(username)} is already in use.`, + }, + 'too_many_username_changes': { + status: 429, + message: 'Too many username changes this month.', + }, + 'token_invalid': { + status: 400, + message: () => 'Invalid token.', + }, + + // SLA + 'rate_limit_exceeded': { + status: 429, + message: ({ method_name, rate_limit }) => + `Rate limit exceeded for method ${quot(method_name)}: ${rate_limit.max} requests per ${rate_limit.period}ms.`, + }, + 'server_rate_exceeded': { + status: 503, + message: 'System-wide rate limit exceeded. Please try again later.', + }, + + // New cost system + 'insufficient_funds': { + status: 402, + message: 'Available funding is insufficient for this request.', + }, + + // auth + 'token_missing': { + status: 401, + message: 'Missing authentication token.', + }, + 'unexpected_undefined': { + status: 401, + message: msg => msg ?? 'unexpected string undefined', + }, + 'token_auth_failed': { + status: 401, + message: 'Authentication failed.', + }, + 'user_not_found': { + status: 401, + message: 'User not found.', + }, + 'token_unsupported': { + status: 401, + message: 'This authentication token is not supported here.', + }, + 'token_expired': { + status: 401, + message: 'Authentication token has expired.', + }, + 'account_suspended': { + status: 403, + message: 'Account suspended.', + }, + 'permission_denied': { + status: 403, + message: 'Permission denied.', + }, + 'access_token_empty_permissions': { + status: 403, + message: 'Attempted to create an access token with no permissions.', + }, + 'invalid_action': { + status: 400, + message: ({ action }) => `Invalid action: ${quot(action)}.`, + }, + '2fa_already_enabled': { + status: 409, + message: '2FA is already enabled.', + }, + '2fa_not_configured': { + status: 409, + message: '2FA is not configured.', + }, + + // protected endpoints + 'too_many_requests': { + status: 429, + message: 'Too many requests.', + }, + 'user_tokens_only': { + status: 403, + message: 'This endpoint must be requested with a user session', + }, + 'temporary_accounts_not_allowed': { + status: 403, + message: 'Temporary accounts cannot perform this action', + }, + 'password_required': { + status: 400, + message: 'Password is required.', + }, + 'password_mismatch': { + status: 403, + message: 'Password does not match.', + }, + + // Object Mapping + 'field_not_allowed_for_create': { + status: 400, + message: ({ key }) => `Field ${quot(key)} is not allowed for create.`, + }, + 'field_required_for_update': { + status: 400, + message: ({ key }) => `Field ${quot(key)} is required for update.`, + }, + 'entity_not_found': { + status: 422, + message: ({ identifier }) => `Entity not found: ${quot(identifier)}`, + }, + + // Share + 'user_does_not_exist': { + status: 422, + message: ({ username }) => `The user ${quot(username)} does not exist.`, + }, + 'invalid_username_or_email': { + status: 400, + message: ({ value }) => + `The value ${quot(value)} is not a valid username or email.`, + }, + 'invalid_path': { + status: 400, + message: ({ value }) => + `The value ${quot(value)} is not a valid path.`, + }, + 'future': { + status: 400, + message: ({ what }) => `Not supported yet: ${what}`, + }, + // Temporary solution for lack of error composition + 'field_errors': { + status: 400, + message: ({ key, errors }) => + `The value for ${quot(key)} has the following errors: ${ + errors.join('; ')}`, + }, + 'share_expired': { + status: 422, + message: 'This share is expired.', + }, + 'email_must_be_confirmed': { + status: 422, + message: ({ action }) => + `Email must be confirmed to ${action ?? 'apply a share'}.`, + }, + 'no_need_to_request': { + status: 422, + message: 'This share is already valid for this user; ' + + 'POST to /apply for access.', + }, + 'can_not_apply_to_this_user': { + status: 422, + message: 'This share can not be applied to this user.', + }, + 'no_origin_for_app': { + status: 400, + message: 'Puter apps must have a valid URL.', + }, + 'anti-csrf-incorrect': { + status: 400, + message: 'Incorrect or missing anti-CSRF token.', + }, + + 'not_yet_supported': { + status: 400, + message: ({ message }) => message, + }, + + // Captcha errors + 'captcha_required': { + status: 400, + message: ({ message }) => message || 'Captcha verification required', + }, + 'captcha_invalid': { + status: 400, + message: ({ message }) => message || 'Invalid captcha response', + }, + + // TTS Errors + 'invalid_engine': { + status: 400, + message: ({ engine, valid_engines }) => `Invalid engine: ${quot(engine)}. Valid engines are: ${valid_engines.map(quot).join(', ')}.`, + }, + + // Abuse prevention + 'moderation_failed': { + status: 422, + message: 'Content moderation failed', + }, + }; + + /** + * create() is a factory method for creating APIError instances. + * It accepts either a string or an Error object as the second + * argument. If a string is passed, it is used as the error message. + * If an Error object is passed, its message property is used as the + * error message. The Error object itself is stored in the source + * property. If no second argument is passed, the source property + * is set to null. The first argument is used as the status code. + * + * @static + * @param {number|string} status + * @param {Error} source + * @param {string|Error|object} fields one of the following: + * - a string to use as the error message + * - an Error object to use as the source of the error + * - an object with a message property to use as the error message + * @returns + */ + static create (status, source = {}, fields = {}) { + // Just the error code + if ( typeof status === 'string' ) { + const code = this.codes[status]; + if ( ! code ) { + return new APIError(500, 'Missing error message.', null, { + code: status, + }); + } + return new APIError(code.status, status, source, fields); + } + + // High-level errors like this: APIError.create(400, '...') + if ( typeof source === 'string' ) { + return new APIError(status, source, null, fields); + } + + // Errors from source like this: throw new Error('...') + if ( + typeof source === 'object' && + source instanceof Error + ) { + return new APIError(status, source?.message, source, fields); + } + + // Errors from sources like this: throw { message: '...', ... } + if ( + typeof source === 'object' && + source.constructor.name === 'Object' && + Object.prototype.hasOwnProperty.call(source, 'message') + ) { + const allfields = { ...source, ...fields }; + return new APIError(status, source.message, source, allfields); + } + + console.error('Invalid APIError source:', source); + return new APIError(500, 'Internal Server Error', null, {}); + } + static adapt (err) { + if ( err instanceof APIError ) return err; + + return APIError.create('internal_error'); + } + constructor (status, message, source, fields = {}) { + this.codes = this.constructor.codes; + this.status = status; + this._message = message; + this.source = source ?? new Error('error for trace'); + this.fields = fields; + + if ( Object.prototype.hasOwnProperty.call(this.codes, message) ) { + this.fields.code = message; + this._message = this.codes[message].message; + } + } + write (res) { + const message = typeof this.message === 'function' + ? this.message(this.fields) + : this.message; + return res.status(this.status).send({ + message, + ...this.fields, + }); + } + serialize () { + return { + ...this.fields, + $: 'heyputer:api/APIError', + message: this.message, + status: this.status, + }; + } + + querystringize (extra) { + return new URLSearchParams(this.querystringize_(extra)); + } + + querystringize_ (extra) { + const fields = {}; + for ( const k in this.fields ) { + fields[`field_${k}`] = this.fields[k]; + } + return { + ...extra, + error: true, + message: this.message, + status: this.status, + ...fields, + }; + } + + get message () { + const message = typeof this._message === 'function' + ? this._message(this.fields) + : this._message; + return message; + } + + toString () { + return `APIError(${this.status}, ${this.message})`; + } +}; + +module.exports = APIError; +module.exports.APIError = APIError; \ No newline at end of file diff --git a/src/backend/src/api/PathOrUIDValidator.js b/src/backend/src/api/PathOrUIDValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..24d9df5da4f0414d283d259b7ed2bc5312f38d48 --- /dev/null +++ b/src/backend/src/api/PathOrUIDValidator.js @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('./APIError'); +const _path = require('path'); + +/** + * PathOrUIDValidator validates that either `path` or `uid` is present + * in the request and requires a valid value for the parameter that was + * used. Additionally, resolves the path if a path was provided. + * + * @class PathOrUIDValidator + * @static + * @throws {APIError} if `path` and `uid` are both missing + * @throws {APIError} if `path` and `uid` are both present + * @throws {APIError} if `path` is not a string + * @throws {APIError} if `path` is empty + * @throws {APIError} if `uid` is not a valid uuid + */ +module.exports = class PathOrUIDValidator { + static validate (req) { + const params = req.method === 'GET' + ? req.query : req.body ; + + if ( !params.path && !params.uid ) + { + throw new APIError(400, '`path` or `uid` must be provided.'); + } + // `path` must be a string + else if ( params.path && !params.uid && typeof params.path !== 'string' ) + { + throw new APIError(400, '`path` must be a string.'); + } + // `path` cannot be empty + else if ( params.path && !params.uid && params.path.trim() === '' ) + { + throw new APIError(400, '`path` cannot be empty'); + } + // `uid` must be a valid uuid + else if ( params.uid && !params.path && !require('uuid').validate(params.uid) ) + { + throw new APIError(400, '`uid` must be a valid uuid'); + } + + // resolve path if provided + if ( params.path ) + { + params.path = _path.resolve('/', params.path); + } + } +}; diff --git a/src/backend/src/api/api_error_handler.js b/src/backend/src/api/api_error_handler.js new file mode 100644 index 0000000000000000000000000000000000000000..6ff23fa97c32d799ba6caf6bebd6408557cf23c2 --- /dev/null +++ b/src/backend/src/api/api_error_handler.js @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('./APIError'); + +/** + * api_error_handler() is an express error handler for API errors. + * It adheres to the express error handler signature and should be + * used as the last middleware in an express app. + * + * Since Express 5 is not yet released, this function is used by + * eggspress() to handle errors instead of as a middleware. + * + * @todo remove this function and use express error handling + * when Express 5 is released + * + * @param {*} err + * @param {*} req + * @param {*} res + * @param {*} next + * @returns + */ +module.exports = function (err, req, res, next) { + if ( res.headersSent ) { + console.error('error after headers were sent:', err); + return next(err); + } + + // API errors might have a response to help the + // developer resolve the issue. + if ( err instanceof APIError ) { + return err.write(res); + } + + if ( + typeof err === 'object' && + !(err instanceof Error) && + err.hasOwnProperty('message') + ) { + const apiError = APIError.create(400, err); + return apiError.write(res); + } + + console.error('internal server error:', err); + + const services = globalThis.services; + if ( services && services.has('alarm') ) { + const alarm = services.get('alarm'); + alarm.create('api_error_handler', err.message, { + error: err, + url: req.url, + method: req.method, + body: req.body, + headers: req.headers, + }); + } + + req.__error_handled = true; + + // Other errors should provide as little information + // to the client as possible for security reasons. + return res.send(500, 'Internal Server Error'); +}; diff --git a/src/backend/src/api/eggspress.js b/src/backend/src/api/eggspress.js new file mode 100644 index 0000000000000000000000000000000000000000..5ae16d58ddc45dfbf7e3c367dd84f3926639b2c9 --- /dev/null +++ b/src/backend/src/api/eggspress.js @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// This file is a legacy alias +module.exports = require('../modules/web/lib/eggspress.js'); diff --git a/src/backend/src/api/filesystem/FSNodeParam.js b/src/backend/src/api/filesystem/FSNodeParam.js new file mode 100644 index 0000000000000000000000000000000000000000..4ffb9ce1badff2919940f0cf63f64952676fd6bf --- /dev/null +++ b/src/backend/src/api/filesystem/FSNodeParam.js @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { is_valid_path } = require('../../filesystem/validation'); +const { is_valid_uuid4 } = require('../../helpers'); +const { Context } = require('../../util/context'); +const { PathBuilder } = require('../../util/pathutil'); +const APIError = require('../APIError'); + +class FSNodeParam { + constructor (srckey, options) { + this.srckey = srckey; + this.options = options ?? {}; + this.optional = this.options.optional ?? false; + } + + async consolidate ({ req, getParam }) { + const log = globalThis.services.get('log-service').create('fsnode-param'); + const fs = Context.get('services').get('filesystem'); + + let uidOrPath = getParam(this.srckey); + if ( uidOrPath === undefined ) { + if ( this.optional ) return undefined; + throw APIError.create('field_missing', null, { + key: this.srckey, + }); + } + + if ( uidOrPath.length === 0 ) { + if ( this.optional ) return undefined; + APIError.create('field_empty', null, { + key: this.srckey, + }); + } + + if ( ! ['/', '.', '~'].includes(uidOrPath[0]) ) { + if ( is_valid_uuid4(uidOrPath) ) { + return await fs.node({ uid: uidOrPath }); + } + + log.debug('tried uuid', { uidOrPath }); + throw APIError.create('field_invalid', null, { + key: this.srckey, + expected: 'unix-style path or uuid4', + }); + } + + if ( uidOrPath.startsWith('~') && req.user ) { + const homedir = `/${req.user.username}`; + uidOrPath = homedir + uidOrPath.slice(1); + } + + if ( ! is_valid_path(uidOrPath) ) { + log.debug('tried path', { uidOrPath }); + throw APIError.create('field_invalid', null, { + key: this.srckey, + expected: 'unix-style path or uuid4', + }); + } + + const resolved_path = PathBuilder.resolve(uidOrPath, { puterfs: true }); + return await fs.node({ path: resolved_path }); + } +}; + +module.exports = FSNodeParam; +module.exports.FSNodeParam = FSNodeParam; \ No newline at end of file diff --git a/src/backend/src/api/filesystem/FlagParam.js b/src/backend/src/api/filesystem/FlagParam.js new file mode 100644 index 0000000000000000000000000000000000000000..31c8d920c175e0cd163860eff3c76eb6cd034e73 --- /dev/null +++ b/src/backend/src/api/filesystem/FlagParam.js @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); + +module.exports = class FlagParam { + constructor (srckey, options) { + this.srckey = srckey; + this.options = options ?? {}; + this.optional = this.options.optional ?? false; + this.default = this.options.default ?? false; + } + + async consolidate ({ req, getParam }) { + const log = globalThis.services.get('log-service').create('flag-param'); + + const value = getParam(this.srckey); + if ( value === undefined || value === '' ) { + if ( this.optional ) return this.default; + throw APIError.create('field_missing', null, { + key: this.srckey, + }); + } + + if ( typeof value === 'string' ) { + if ( + value === 'true' || value === '1' || value === 'yes' + ) return true; + + if ( + value === 'false' || value === '0' || value === 'no' + ) return false; + + throw APIError.create('field_invalid', null, { + key: this.srckey, + expected: 'boolean', + }); + } + + if ( typeof value === 'boolean' ) { + return value; + } + + log.debug('tried boolean', { value }); + throw APIError.create('field_invalid', null, { + key: this.srckey, + expected: 'boolean', + }); + } +}; diff --git a/src/backend/src/api/filesystem/StringParam.js b/src/backend/src/api/filesystem/StringParam.js new file mode 100644 index 0000000000000000000000000000000000000000..78079fd2ceea9283e9111c79740fdeb772ccc9f4 --- /dev/null +++ b/src/backend/src/api/filesystem/StringParam.js @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); + +module.exports = class StringParam { + constructor (srckey, options) { + this.srckey = srckey; + this.options = options ?? {}; + this.optional = this.options.optional ?? false; + } + + async consolidate ({ req, getParam }) { + const log = globalThis.services.get('log-service').create('string-param'); + + const value = getParam(this.srckey); + if ( value === undefined ) { + if ( this.optional ) return undefined; + throw APIError.create('field_missing', null, { + key: this.srckey, + }); + } + + if ( value.length === 0 ) { + if ( this.optional ) return undefined; + APIError.create('field_empty', null, { + key: this.srckey, + }); + } + + if ( typeof value !== 'string' ) { + log.debug('tried string', { value }); + throw APIError.create('field_invalid', null, { + key: this.srckey, + expected: 'string', + }); + } + + return value; + } +}; diff --git a/src/backend/src/api/filesystem/UserParam.js b/src/backend/src/api/filesystem/UserParam.js new file mode 100644 index 0000000000000000000000000000000000000000..10a7803c3513df0131c10dd619f3cae1fccdbe38 --- /dev/null +++ b/src/backend/src/api/filesystem/UserParam.js @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = class UserParam { + consolidate ({ req }) { + return req.user; + } +}; diff --git a/src/backend/src/boot/BootLogger.js b/src/backend/src/boot/BootLogger.js new file mode 100644 index 0000000000000000000000000000000000000000..7fdcdaa07a001f22ce2e5dba3bffcd2a002e9010 --- /dev/null +++ b/src/backend/src/boot/BootLogger.js @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +class BootLogger { + info (...args) { + console.log('\x1B[36;1m[BOOT/INFO]\x1B[0m', + ...args); + } + debug (...args) { + if ( ! process.env.DEBUG ) return; + console.log('\x1B[37m[BOOT/DEBUG]', ...args, '\x1B[0m'); + } + error (...args) { + console.log('\x1B[31;1m[BOOT/ERROR]\x1B[0m', + ...args); + } + warn (...args) { + console.log('\x1B[33;1m[BOOT/WARN]\x1B[0m', + ...args); + } +} + +module.exports = { + BootLogger, +}; diff --git a/src/backend/src/boot/RuntimeEnvironment.js b/src/backend/src/boot/RuntimeEnvironment.js new file mode 100644 index 0000000000000000000000000000000000000000..180c2a467e69f3a8868776e7be73b1b2582fe6f0 --- /dev/null +++ b/src/backend/src/boot/RuntimeEnvironment.js @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { quot } = require('@heyputer/putility').libs.string; +const { TechnicalError } = require('../errors/TechnicalError'); +const { print_error_help } = require('../errors/error_help_details'); +const default_config = require('./default_config'); +const config = require('../config'); +const { ConfigLoader } = require('../config/ConfigLoader'); + +// highlights a string +const hl = s => `\x1b[33;1m${s}\x1b[0m`; + +// Save the original working directory +const original_cwd = process.cwd(); + +// === [ Puter Runtime Environment ] === +// This file contains the RuntimeEnvironment class which is +// responsible for locating the configuration and runtime +// directories for the Puter Kernel. + +// Depending on which path we're checking for configuration +// or runtime from config_paths, there will be different +// requirements. These are all possible requirements. +// +// Each check may result in the following: +// - false: this is not the desired path; skip it +// - true: this is the desired path, and it's valid +// - throw: this is the desired path, but it's invalid +const path_checks = ({ logger }) => ({ fs, path_ }) => ({ + require_if_not_undefined: ({ path }) => { + if ( path == undefined ) return false; + + const exists = fs.existsSync(path); + if ( ! exists ) { + throw new Error(`Path does not exist: ${path}`); + } + + return true; + }, + skip_if_not_exists: ({ path }) => { + const exists = fs.existsSync(path); + return exists; + }, + skip_if_not_in_repo: ({ path }) => { + const exists = fs.existsSync(path_.join(path, '../../.is_puter_repository')); + return exists; + }, + require_read_permission: ({ path }) => { + try { + fs.readdirSync(path); + } catch (e) { + throw new Error(`Cannot readdir on path: ${path}`); + } + return true; + }, + require_write_permission: ({ path }) => { + try { + fs.writeFileSync(path_.join(path, '.tmp_test_write_permission'), 'test'); + fs.unlinkSync(path_.join(path, '.tmp_test_write_permission')); + } catch (e) { + throw new Error(`Cannot write to path: ${path}`); + } + return true; + }, + contains_config_file: ({ path }) => { + const valid_config_names = [ + 'config.json', + 'config.json5', + ]; + for ( const name of valid_config_names ) { + const exists = fs.existsSync(path_.join(path, name)); + if ( exists ) { + return true; + } + } + throw new Error(`No valid config file found in path: ${path}`); + }, + env_not_set: name => () => { + return !process.env[name]; + }, +}); + +// Configuration paths in order of precedence. +// We will load configuration from the first path that's suitable. +const config_paths = ({ path_checks }) => ({ path_ }) => [ + { + label: '$CONFIG_PATH', + get path () { + return process.env.CONFIG_PATH; + }, + checks: [ + path_checks.require_if_not_undefined, + ], + }, + { + path: '/etc/puter', + checks: [ path_checks.skip_if_not_exists ], + }, + { + get path () { + return path_.join(original_cwd, 'volatile/config'); + }, + checks: [ path_checks.skip_if_not_in_repo ], + }, + { + get path () { + return path_.join(original_cwd, 'config'); + }, + checks: [ path_checks.skip_if_not_exists ], + }, +]; + +const valid_config_names = [ + 'config.json', + 'config.json5', +]; + +// Suitable working directories in order of precedence. +// We will `process.chdir` to the first path that's suitable. +const runtime_paths = ({ path_checks }) => ({ path_ }) => [ + { + label: '$RUNTIME_PATH', + get path () { + return process.env.RUNTIME_PATH; + }, + checks: [ + path_checks.require_if_not_undefined, + ], + }, + { + path: '/var/puter', + checks: [ + path_checks.skip_if_not_exists, + path_checks.env_not_set('NO_VAR_RUNTIME'), + ], + }, + { + get path () { + return path_.join(original_cwd, 'volatile/runtime'); + }, + checks: [ path_checks.skip_if_not_in_repo ], + }, + { + get path () { + return path_.join(original_cwd, 'runtime'); + }, + checks: [ path_checks.skip_if_not_exists ], + }, +]; + +// Suitable mod paths in order of precedence. +const mod_paths = ({ path_checks, entry_path }) => ({ path_ }) => [ + { + label: '$MOD_PATH', + get path () { + return process.env.MOD_PATH; + }, + checks: [ + path_checks.require_if_not_undefined, + ], + }, + { + path: '/var/puter/mods', + checks: [ + path_checks.skip_if_not_exists, + path_checks.env_not_set('NO_VAR_MODS'), + ], + }, + { + get path () { + return path_.join(path_.dirname(entry_path || require.main.filename), '../mods'); + }, + checks: [ path_checks.skip_if_not_exists ], + }, +]; + +class RuntimeEnvironment extends AdvancedBase { + static MODULES = { + fs: require('node:fs'), + path_: require('node:path'), + crypto: require('node:crypto'), + format: require('string-template'), + }; + + constructor ({ logger, entry_path, boot_parameters }) { + super(); + this.logger = logger; + this.entry_path = entry_path; + this.boot_parameters = boot_parameters; + this.path_checks = path_checks(this)(this.modules); + this.config_paths = config_paths(this)(this.modules); + this.runtime_paths = runtime_paths(this)(this.modules); + this.mod_paths = mod_paths(this)(this.modules); + } + + init () { + try { + return this.init_(); + } catch (e) { + this.logger.error(e); + print_error_help(e); + process.exit(1); + } + } + + init_ () { + // This variable, called "environment", will be passed back to Kernel + // with some helpful values. A partial-population of this object later + // in this function will be used when evaluating configured paths. + const environment = {}; + environment.source = this.modules.path_.dirname(this.entry_path || require.main.filename); + environment.repo = this.modules.path_.dirname(environment.source); + + const config_path_entry = this.get_first_suitable_path_({ pathFor: 'configuration' }, + this.config_paths, + [ + this.path_checks.require_read_permission, + // this.path_checks.contains_config_file, + ]); + + // Note: there used to be a 'mods_path_entry' here too + // but it was never used + const pwd_path_entry = this.get_first_suitable_path_({ pathFor: 'working directory' }, + this.runtime_paths, + [ this.path_checks.require_write_permission ]); + + process.chdir(pwd_path_entry.path); + + // Check for a valid config file in the config path + let using_config; + for ( const name of valid_config_names ) { + const exists = this.modules.fs.existsSync(this.modules.path_.join(config_path_entry.path, name)); + if ( exists ) { + using_config = name; + break; + } + } + + const owrite_config = this.boot_parameters.args.overwriteConfig; + + const { fs, path_, crypto } = this.modules; + if ( !using_config || owrite_config ) { + const generated_values = {}; + generated_values.cookie_name = crypto.randomUUID(); + generated_values.jwt_secret = crypto.randomUUID(); + generated_values.url_signature_secret = crypto.randomUUID(); + generated_values.private_uid_secret = crypto.randomBytes(24).toString('hex'); + generated_values.private_uid_namespace = crypto.randomUUID(); + if ( using_config ) { + this.logger.debug(`Overwriting ${quot(using_config)} because ` + + `${hl('--overwrite-config')} is set`); + // make backup + fs.copyFileSync(path_.join(config_path_entry.path, using_config), + path_.join(config_path_entry.path, `${using_config }.bak`)); + // preserve generated values + { + const config_raw = fs.readFileSync(path_.join(config_path_entry.path, using_config), + 'utf8'); + const config_values = JSON.parse(config_raw); + for ( const k in generated_values ) { + if ( ! config_values[k] ) continue; + generated_values[k] = config_values[k]; + } + } + } + const generated_config = { + ...default_config, + ...generated_values, + }; + generated_config[''] = null; // for trailing comma + fs.writeFileSync(path_.join(config_path_entry.path, 'config.json'), + `${JSON.stringify(generated_config, null, 4) }\n`); + using_config = 'config.json'; + } + + let config_to_load = 'config.json'; + if ( process.env.PUTER_CONFIG_PROFILE ) { + this.logger.debug(`${hl('PROFILE') } ${ + quot(process.env.PUTER_CONFIG_PROFILE) } ` + + 'because $PUTER_CONFIG_PROFILE is set'); + config_to_load = `${process.env.PUTER_CONFIG_PROFILE}.json`; + const exists = fs.existsSync(path_.join(config_path_entry.path, config_to_load)); + if ( ! exists ) { + fs.writeFileSync(path_.join(config_path_entry.path, config_to_load), + `${JSON.stringify({ + config_name: process.env.PUTER_CONFIG_PROFILE, + $imports: ['config.json'], + }, null, 4) }\n`); + } + } + + environment.config_path = path_.join(config_path_entry.path, config_to_load); + + const loader = new ConfigLoader(this.logger, config_path_entry.path, config); + loader.enable(config_to_load); + + if ( ! config.config_name ) { + throw new Error('config_name is required'); + } + this.logger.debug(`${hl('config name') } ${quot(config.config_name)}`); + + const mod_paths = []; + environment.mod_paths = mod_paths; + + // Trying this as a default for now... + if ( ! config.mod_directories ) { + config.mod_directories = [ + '{source}/../mods/mods_enabled', + '{source}/../extensions', + ]; + } + + // If configured, add a user-specified mod path + if ( config.mod_directories ) { + for ( const dir of config.mod_directories ) { + const mods_directory = this.modules.format(dir, environment); + mod_paths.push(mods_directory); + } + } + + return environment; + } + + get_first_suitable_path_ (meta, paths, last_checks) { + for ( const entry of paths ) { + const checks = [...(entry.checks ?? []), ...last_checks]; + this.logger.debug(`Checking path ${quot(entry.label ?? entry.path)} for ${meta.pathFor}...`); + + let checks_pass = true; + for ( const check of checks ) { + this.logger.debug(`-> doing ${quot(check.name)} on path ${quot(entry.path)}...`); + const result = check(entry); + if ( result === false ) { + this.logger.debug(`-> ${quot(check.name)} doesn't like this path`); + checks_pass = false; + break; + } + } + + if ( ! checks_pass ) continue; + + this.logger.info(`${hl(meta.pathFor)} ${quot(entry.path)}`); + + return entry; + } + + if ( meta.optional ) return; + throw new TechnicalError(`No suitable path found for ${meta.pathFor}.`); + } +} + +module.exports = { + RuntimeEnvironment, +}; \ No newline at end of file diff --git a/src/backend/src/boot/default_config.js b/src/backend/src/boot/default_config.js new file mode 100644 index 0000000000000000000000000000000000000000..8c9f83db7b9580ff8237278762ce2adefa1b47ff --- /dev/null +++ b/src/backend/src/boot/default_config.js @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = { + config_name: 'generated default config', + env: 'dev', + nginx_mode: true, // really means "serve http instead of https" + server_id: 'localhost', + http_port: 'auto', + domain: 'puter.localhost', + protocol: 'http', + contact_email: 'hey@example.com', + + services: { + database: { + engine: 'sqlite', + path: 'puter-database.sqlite', + }, + thumbnails: { + engine: 'purejs', + }, + 'file-cache': { + disk_limit: 16384, + disk_max_size: 16384, + precache_size: 16384, + path: './file-cache', + + }, + }, +}; diff --git a/src/backend/src/codex/CodeUtil.js b/src/backend/src/codex/CodeUtil.js new file mode 100644 index 0000000000000000000000000000000000000000..63bf245bf3240be1778562b8113ff4d04b272c4b --- /dev/null +++ b/src/backend/src/codex/CodeUtil.js @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +class CodeUtil { + /** + * Wrap a method*[1] with an implementation of a runnable class. + * The wrapper must be a class that implements `async run(values)`, + * and `run` should delegate to `this._run()` after setting this.values. + * The `BaseOperation` class is an example of such a class. + * + * [1]: since our runnable interface expects named parameters, this + * wrapping behavior is only useful for methods that accept a single + * object argument. + * @param {*} method + * @param {*} wrapper + */ + static mrwrap (method, wrapper, options = {}) { + const cls_name = options.name || method.name; + + const cls = class extends wrapper { + async _run () { + return await method.call(this.self, this.values); + } + }; + + Object.defineProperty(cls, 'name', { value: cls_name }); + + return async function (...a) { + const op = new cls(); + // eslint-disable-next-line no-invalid-this + op.self = this; // TODO: fix this odd structure, what is this even bound to ? + return await op.run(...a); + }; + } +} + +module.exports = { + CodeUtil, +}; diff --git a/src/backend/src/codex/README.md b/src/backend/src/codex/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dd32c318808140c8a73802f4fbea0540bf1c68f0 --- /dev/null +++ b/src/backend/src/codex/README.md @@ -0,0 +1,10 @@ +# What is this? + +ChatGPT told me to call this codex and that sounds really cool so +I couldn't resist. + +This directory contains utilities for modelling code as data, so that +we can use static analysis techniques and prevent detectable errors +from reaching produciton. This is an attempt at making things more robust, +but it's not guarenteed to work or even be useful; we need to try it and +collect data about its effectiveness. diff --git a/src/backend/src/codex/Sequence.js b/src/backend/src/codex/Sequence.js new file mode 100644 index 0000000000000000000000000000000000000000..badd6e1207e53d64270b4f0bcb47d2d2a3411c64 --- /dev/null +++ b/src/backend/src/codex/Sequence.js @@ -0,0 +1,383 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * @typedef {Object} A + * @property {(key: string) => unknown} get - Get a value from the sequence scope. + * @property {function(string, any): void} set - Set a value in the sequence scope. + * @property {(valsToSet?: T) => T extends undefined ? unknown : T} values - Get or set multiple values in the sequence scope. + * @property {function(string=): any} iget - Get a value from the instance (thisArg). + * @property {(methodName: string, ...params: any[] ) => any} icall - Call a method on the instance (thisArg). + * @property {function(string, ...any): any} idcall - Call a method on the instance with the sequence state as the first argument. + * @property {Object} log - Logger, if available on the instance. + * @property {function(any): any} stop - Stop the sequence early and optionally return a value. + * @property {number} i - Current step index. + */ + +/** + * @typedef {(...args: any) => Promise} SequenceCallable + * A callable function returned by the Sequence constructor. + * @param {Object|Sequence.SequenceState} [opt_values] - Initial values for the sequence scope, or a SequenceState. + * @returns {Promise} The return value of the last step in the sequence. + */ +/** + * Sequence is a callable object that executes a series of functions in order. + * The functions are expected to be asynchronous; if they're not it might still + * work, but it's neither tested nor supported. + * + * Note: arrow functions are supported, but they are not recommended; + * using keyword functions allows each step to be named. + * + * Example usage: + * + * const seq = new Sequence([ + * async function set_foo (a) { + * a.set('foo', 'bar') + * }, + * async function print_foo (a) { + * console.log(a.get('foo')); + * }, + * async function third_step (a) { + * // do something + * }, + * ]); + * + * await seq(); + * + * Example with controlled conditional branches: + * + * const seq = new Sequence([ + * async function first_step (a) { + * // do something + * }, + * { + * condition: async a => a.get('foo') === 'bar', + * fn: async function second_step (a) { + * // do something + * } + * }, + * async function third_step (a) { + * // do something + * }, + * ]); + * + * If it is called with an argument, it must be an object containing values + * which will populate the "sequence scope". + * + * If it is called on an instance with a member called `values` + * (i.e. if `this.values` is defined), then these values will populate the + * sequence scope. This is to maintain compatibility for Sequence to be used + * as an implementation of a runnable class. (See CodeUtil.mrwrap or BaseOperation) + * + * The object returned by the constructor is a function, which is used to + * make the object callable. The callable object will execute the sequence + * when called. The return value of the sequence is the return value of the + * last function in the sequence. + * + * Each function in the sequence is passed a SequenceState object + * as its first argument. Conventionally, this argument is called `a`, + * which is short for either "API", "access", or "the `a` variable" + * depending on which you prefer. Sequence provides methods for accessing + * the sequence scope. + * + * By accessing the sequence scope through the `a` variable, changes to the + * sequence scope can be monitored and recorded. (TODO: implement observe methods) + */ +/** + * Sequence is a callable object that executes a series of asynchronous functions in order. + * Each function receives a SequenceState instance for accessing and mutating the sequence scope. + * Supports conditional steps, deferred steps, and can be used as a runnable implementation for classes. + * @class @extends Function + */ +class Sequence { + /** + * SequenceState represents the state of a Sequence execution. + * Provides access to the sequence scope, step control, and utility methods for step functions. + */ + static SequenceState = class SequenceState { + /** + * Create a new SequenceState. + * @param {Sequence|function} sequence - The Sequence instance or its callable function. + * @param {Object} [thisArg] - The instance to bind as `this` for step functions. + */ + constructor (sequence, thisArg) { + if ( typeof sequence === 'function' ) { + sequence = sequence.sequence; + } + + this.sequence_ = sequence; + this.thisArg = thisArg; + this.steps_ = null; + this.value_history_ = []; + this.scope_ = {}; + this.last_return_ = undefined; + this.i = 0; + this.stopped_ = false; + + this.defer_ptr_ = undefined; + this.defer = this.constructor.defer_0; + } + + /** + * Get the current steps array for this sequence execution. + * @returns {Array} The steps to execute. + */ + get steps () { + return this.steps_ ?? this.sequence_?.steps_; + } + + /** + * Run the sequence from the current step index. + * @param {Object} [values] - Initial values for the sequence scope. + * @returns {Promise} + */ + async run (values) { + // Initialize scope + values = values || this.thisArg?.values || {}; + Object.setPrototypeOf(this.scope_, values); + + // Run sequence + for ( ; this.i < this.steps.length ; this.i++ ) { + let step = this.steps[this.i]; + if ( typeof step !== 'object' ) { + step = { + name: step.name, + fn: step, + }; + } + + if ( step.condition && !await step.condition(this) ) { + continue; + } + + const parent_scope = this.scope_; + this.scope_ = {}; + // We could do Object.assign(this.scope_, parent_scope), but + // setting the prototype should be faster (in theory) + Object.setPrototypeOf(this.scope_, parent_scope); + + if ( this.sequence_.options_.record_history ) { + this.value_history_.push(this.scope_); + } + + if ( this.sequence_.options_.before_each ) { + await this.sequence_.options_.before_each(this, step); + } + + this.last_return_ = await step.fn.call(this.thisArg, this); + + if ( this.last_return_ instanceof Sequence.SequenceState ) { + this.scope_ = this.last_return_.scope_; + } + + if ( this.sequence_.options_.after_each ) { + await this.sequence_.options_.after_each(this, step); + } + + if ( this.stopped_ ) { + break; + } + } + } + + // Why check a condition every time code is called, + // when we can check it once and then replace the code? + + /** + * The first time defer is called, clones the steps and sets up for deferred insertion. + * @param {function(Sequence.SequenceState): Promise} fn - The function to defer. + */ + static defer_0 = function (fn) { + this.steps_ = [...this.sequence_.steps_]; + this.defer = this.constructor.defer_1; + this.defer_ptr_ = this.steps_.length; + this.defer(fn); + }; + /** + * Subsequent calls to defer insert the function before the deferred pointer. + * @param {function(Sequence.SequenceState): Promise} fn - The function to defer. + */ + static defer_1 = function (fn) { + // Deferred functions don't affect the return value + const real_fn = fn; + fn = async () => { + await real_fn(this); + return this.last_return_; + }; + + // Insert deferred step before the pointer + this.steps_.splice(this.defer_ptr_, 0, fn); + }; + + /** + * Get a value from the sequence scope. + * @param {string} k - The key to retrieve. + * @returns {any} The value associated with the key. + */ + get (k) { + // TODO: record read1 + return this.scope_[k]; + } + + /** + * Set a value in the sequence scope. + * @param {string} k - The key to set. + * @param {any} v - The value to assign. + */ + set (k, v) { + // TODO: record mutation + this.scope_[k] = v; + } + + /** + * Get or set multiple values in the sequence scope. + * @param {Object} [opt_itemsToSet] - Optional object of key-value pairs to set. + * @returns {Object} Proxy to the current scope for value access. + */ + values (opt_itemsToSet) { + if ( opt_itemsToSet ) { + for ( const k in opt_itemsToSet ) { + this.set(k, opt_itemsToSet[k]); + } + } + + return new Proxy(this.scope_, { + get: (target, property) => { + if ( property in target ) { + // TODO: record read + return target[property]; + } + return undefined; + }, + }); + } + + /** + * Get a value from the instance (`thisArg`). + * @param {string} [k] - The property name to retrieve. If omitted, returns the instance. + * @returns {any} The value from the instance or the instance itself. + */ + iget (k) { + if ( k === undefined ) return this.thisArg; + return this.thisArg?.[k]; + } + + // Instance call: call a method on the instance + /** + * Call a method on the instance (`thisArg`). + * @param {string} k - The method name. + * @param {...any} args - Arguments to pass to the method. + * @returns {any} The result of the method call. + */ + icall (k, ...args) { + return this.thisArg?.[k]?.call(this.thisArg, ...args); + } + + // Instance dynamic call: call a method on the instance, + // passing the sequence state as the first argument + /** + * Call a method on the instance, passing the sequence state as the first argument. + * @param {string} k - The method name. + * @param {...any} args - Arguments to pass after the sequence state. + * @returns {any} The result of the method call. + */ + idcall (k, ...args) { + return this.thisArg?.[k]?.call(this.thisArg, this, ...args); + } + + /** + * Get the logger from the instance, if available. + * @returns {Object|undefined} The logger object. + */ + get log () { + return this.iget('log'); + } + + /** + * Stop the sequence early and optionally return a value. + * @param {any} [return_value] - Value to return from the sequence. + * @returns {any} The provided return value. + */ + stop (return_value) { + this.stopped_ = true; + return return_value; + } + }; + + /** + * + * @param {Array | {condition: (a: A) => boolean | Promise, fn: function(A): Promise}> | function(A): Promise | Object} args + * @returns {Sequence} + */ + /** + * Create a new Sequence. + * @param {...(Array|Object>|function(Sequence.SequenceState): Promise|Object)} args + * - Arrays of step functions or step objects, individual step functions, or options objects. + * - Step objects may have a `condition` property (function) and a `fn` property (function). + * - Options object may include `name`, `record_history`, `before_each`, `after_each`. + * @returns {SequenceCallable} A callable function that runs the sequence. + */ + constructor (...args) { + const sequence = this; + + const steps = []; + const options = {}; + + for ( const arg of args ) { + if ( Array.isArray(arg) ) { + steps.push(...arg); + } else if ( typeof arg === 'object' ) { + Object.assign(options, arg); + } else if ( typeof arg === 'function' ) { + steps.push(arg); + } else { + throw new TypeError(`Invalid argument to Sequence constructor: ${arg}`); + } + } + + /** + * Callable function to execute the sequence. + * @param {Object|Sequence.SequenceState} [opt_values] - Initial values or a SequenceState. + * @returns {Promise} The return value of the last step. + */ + const fn = async function (opt_values) { + if ( opt_values && opt_values instanceof Sequence.SequenceState ) { + opt_values = opt_values.scope_; + } + // eslint-disable-next-line no-invalid-this + const state = new Sequence.SequenceState(sequence, this); // TODO: fix this odd structure, what is this even bound to ? + await state.run(opt_values ?? undefined); + return state.last_return_; + }; + + this.steps_ = steps; + this.options_ = options || {}; + + Object.defineProperty(fn, 'name', { + value: options.name || 'Sequence', + }); + Object.defineProperty(fn, 'sequence', { value: this }); + + return fn; + } +} + +module.exports = { + Sequence, +}; diff --git a/src/backend/src/config.d.ts b/src/backend/src/config.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6a72a5759914972956460c889b9fd0d2b6b251b --- /dev/null +++ b/src/backend/src/config.d.ts @@ -0,0 +1,15 @@ +import { RecursiveRecord } from "./services/MeteringService/types"; + +type ConfigRecord = RecursiveRecord; + +export interface IConfig extends ConfigRecord { + load_config: (o: ConfigRecord) => void; + __set_config_object__: ( + object: ConfigRecord, + options?: { replacePrototype?: boolean; useInitialPrototype?: boolean } + ) => void; +} + +declare const config: IConfig; + +export = config; diff --git a/src/backend/src/config.js b/src/backend/src/config.js new file mode 100644 index 0000000000000000000000000000000000000000..b9ed8822f7274901d81ebc9ff059d1994a147d59 --- /dev/null +++ b/src/backend/src/config.js @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +'use strict'; +const deep_proto_merge = require('./config/deep_proto_merge'); +// const reserved_words = require('./config/reserved_words'); + +let config = {}; +config.__import_identity__ = require('uuid').v4(); + +// Static defaults +config.servers = []; + +config.disable_user_signup = false; +config.default_user_group = '78b1b1dd-c959-44d2-b02c-8735671f9997'; + +// Will disable the auto-generated temp users. If a user lands on the site, they will be required to sign up or log in. +config.disable_temp_users = false; +config.default_temp_group = 'b7220104-7905-4985-b996-649fdcdb3c8f'; + +config.max_file_size = 100_000_000_000; +config.max_thumb_size = 1_000; +config.max_fsentry_name_length = 767; + +config.username_regex = /^\w+$/; +config.username_max_length = 45; +config.subdomain_regex = /^[a-zA-Z0-9_-]+$/; +config.subdomain_max_length = 60; +config.app_name_regex = /^[a-zA-Z0-9_-]+$/; +config.app_name_max_length = 60; +config.app_title_max_length = 60; +config.min_pass_length = 6; + +config.strict_email_verification_required = false; +config.require_email_verification_to_publish_website = false; + +config.kv_max_key_size = 1024; +config.kv_max_value_size = 400 * 1024; + +// Captcha configuration +config.captcha = { + enabled: false, // Enable captcha by default + expirationTime: 10 * 60 * 1000, // 10 minutes default expiration time + difficulty: 'medium', // Default difficulty level +}; + +config.monitor = { + metricsInterval: 60000, + windowSize: 30, +}; + +config.max_subdomains_per_user = 2000; +config.storage_capacity = 1 * 1024 * 1024 * 1024; +config.static_hosting_domain = 'site.puter.localhost'; + +// Storage limiting is set to false by default +// Storage available on the mountpoint/drive puter is running is the storage available +config.is_storage_limited = false; +config.available_device_storage = null; + +config.thumb_width = 80; +config.thumb_height = 80; +config.app_max_icon_size = 5 * 1024 * 1024; + +config.defaultjs_asset_path = '../../'; + +config.short_description = 'Puter is a privacy-first personal cloud that houses all your files, apps, and games in one private and secure place, accessible from anywhere at any time.'; +config.title = 'Puter'; +config.company = 'Puter Technologies Inc.'; + +config.puter_hosted_data = { + puter_versions: 'https://version.puter.site/puter_versions.json', +}; + +{ + const path_ = require('path'); + config.assets = { + gui: path_.join(__dirname, '../../gui'), + gui_profile: 'development', + }; +} + +// words that cannot be used by others as subdomains or app names +// config.reserved_words = reserved_words; +config.reserved_words = []; + +{ + config.reserved_words.push(...require('./config/reserved_words')); +} + +// set default S3 settings for this server, if any +if ( config.server_id ) { + // see if this server has a specific bucket + for ( const server of config.servers ) { + if ( server.id !== config.server_id ) continue; + if ( ! server.s3_bucket ) continue; + + config.s3_bucket = server.s3_bucket; + config.s3_region = server.region; + } +} + +config.contact_email = `hey@${ config.domain}`; + +// TODO: default value will be changed to false in a future release; +// details to follow in a future announcement. +config.legacy_token_migrate = true; + +// === OS Information === +const os = require('os'); +const fs = require('fs'); +const { Context, context_config } = require('./util/context'); +config.os = {}; +config.os.platform = os.platform(); + +if ( config.os.platform === 'linux' ) { + try { + const osRelease = fs.readFileSync('/etc/os-release').toString(); + // CONTRIBUTORS: If this is the behavior you expect, please add your + // Linux distro here. + if ( osRelease.includes('ID=arch') ) { + config.os.distro = 'arch'; + config.os.archbtw = true; + } + } catch (_) { + // We don't care if we can't read this file; + // we'll just assume it's not a Linux distro. + } +} + +// config.os.refined specifies if Puter is running within a host environment +// where a higher level of user configuration and control is expected. +config.os.refined = config.os.archbtw; + +if ( config.os.refined ) { + config.no_browser_launch = true; +} + +// NEW_CONFIG_LOADING +const maybe_port = config => + config.pub_port !== 80 && config.pub_port !== 443 ? `:${ config.pub_port}` : ''; + +const computed_defaults = { + pub_port: config => config.http_port, + origin: config => `${config.protocol }://${ config.domain }${maybe_port(config)}`, + api_base_url: config => config.experimental_no_subdomain + ? config.origin + : `${config.protocol }://api.${ config.domain }${maybe_port(config)}`, + social_card: config => `${config.origin}/assets/img/screenshot.png`, +}; + +// We're going to export a config object that's decorated +// with additional behavior +let config_to_export; + +// We have a pointer to some config object which +// load_config() may replace +const config_pointer = {}; +{ + Object.setPrototypeOf(config_pointer, config); + config_to_export = config_pointer; +} + +// We have some methods that can be called on `config` +{ + // Add configuration values with precedence over the current config + const load_config = o => { + let replacement_config = { + ...o, + }; + replacement_config = deep_proto_merge(replacement_config, Object.getPrototypeOf(config_pointer), { + preserve_flag: true, + }); + Object.setPrototypeOf(config_pointer, replacement_config); + }; + + const config_api = { load_config }; + Object.setPrototypeOf(config_api, config_to_export); + config_to_export = config_api; +} + +// We have some values with computed defaults +{ + const get_implied = (target, prop) => { + if ( prop in computed_defaults ) { + return computed_defaults[prop](target); + } + return undefined; + }; + config_to_export = new Proxy(config_to_export, { + get: (target, prop, receiver) => { + if ( prop in target ) { + return target[prop]; + } else { + return get_implied(config_to_export, prop); + } + }, + }); +} + +// We'd like to store values changed at runtime separately +// for easier runtime debugging +{ + const config_runtime_values = { + $: 'runtime-values', + }; + let initialPrototype = config_to_export; + Object.setPrototypeOf(config_runtime_values, config_to_export); + config_to_export = config_runtime_values; + + config_to_export.__set_config_object__ = (object, options = {}) => { + // options for this method + const replacePrototype = options.replacePrototype ?? true; + const useInitialPrototype = options.useInitialPrototype ?? true; + + // maybe replace prototype + if ( replacePrototype ) { + const newProto = useInitialPrototype + ? initialPrototype + : Object.getPrototypeOf(config_runtime_values); + Object.setPrototypeOf(object, newProto); + } + + // use this object as the prototype + Object.setPrototypeOf(config_runtime_values, object); + }; + + // These can be difficult to find and cause painful + // confusing issues, so we log any time this happens + config_to_export = new Proxy(config_to_export, { + set: (target, prop, value, receiver) => { + const logger = Context.get('logger', { allow_fallback: true }); + // If no logger, just give up + if ( logger ) { + logger.debug('\x1B[36;1mCONFIGURATION MUTATED AT RUNTIME\x1B[0m', + { prop, value }); + } + target[prop] = value; + return true; + }, + }); +} + +// We configure the behavior in context.js from here to avoid a cyclic +// mutual dependency between it and this file. +// +// Previously we had this: +// context --(are we in "dev" environment?)--> config +// +// So we could not add this: +// config --(where is the logger?) --> context +// +// So instead we now have: +// config --(read this property to determine 'strict' mode)--> context +// config --(where is the logger?) --> context +// +Object.defineProperty(context_config, 'strict', { + get: () => config_to_export.env === 'dev', + configurable: true, +}); + +module.exports = config_to_export; diff --git a/src/backend/src/config/ConfigLoader.js b/src/backend/src/config/ConfigLoader.js new file mode 100644 index 0000000000000000000000000000000000000000..fcb37b3a20b42dec643db2d9b3cf6511b676800a --- /dev/null +++ b/src/backend/src/config/ConfigLoader.js @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { quot } = require('@heyputer/putility').libs.string; + +class ConfigLoader extends AdvancedBase { + static MODULES = { + path_: require('path'), + fs: require('fs'), + }; + + constructor (logger, path, config) { + super(); + this.logger = logger; + this.path = path; + this.config = config; + } + + enable (name, meta = {}) { + const { path_, fs } = this.modules; + + const config_path = path_.join(this.path, name); + + if ( ! fs.existsSync(config_path) ) { + throw new Error(`Config file not found: ${config_path}`); + } + + const config_values = JSON.parse(fs.readFileSync(config_path, 'utf8')); + if ( config_values.$requires ) { + const config_list = config_values.$requires; + delete config_values.$requires; + this.apply_requires(this.path, config_list, { by: name }); + } + this.logger.debug(`Applying config: ${path_.relative(this.path, config_path)}${ + meta.by ? ` (required by ${meta.by})` : ''}`); + this.config.load_config(config_values); + + } + + apply_requires (dir, config_list, { by } = {}) { + const { path_, fs } = this.modules; + + for ( const name of config_list ) { + const config_path = path_.join(dir, name); + if ( ! fs.existsSync(config_path) ) { + throw new Error(`could not find ${quot(config_path)} ` + + `required by ${quot(by)}`); + } + this.enable(name, { by }); + } + } +} + +module.exports = { ConfigLoader }; \ No newline at end of file diff --git a/src/backend/src/config/deep_proto_merge.js b/src/backend/src/config/deep_proto_merge.js new file mode 100644 index 0000000000000000000000000000000000000000..4f3c2fe5eb2faa08710f45c6343ca41edb3e17c0 --- /dev/null +++ b/src/backend/src/config/deep_proto_merge.js @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/** + * Sets replacement.__proto__ to `delegate` + * then iterates over members of `replacement` looking for + * objects that are not arrays. + * + * When an object is found, a recursive call is made to + * `deep_proto_merge` with the corresponding object in `delegate`. + * + * If `preserve_flag` is set to true, only objects containing + * a truthy property named `$preserve` will be merged. + * + * @param {*} replacement + * @param {*} delegate + */ +const deep_proto_merge = (replacement, delegate, options) => { + const is_object = (obj) => obj && + typeof obj === 'object' && !Array.isArray(obj); + + replacement.__proto__ = delegate; + + for ( const key in replacement ) { + if ( ! is_object(replacement[key]) ) continue; + + if ( options?.preserve_flag && !replacement[key].$preserve ) { + continue; + } + if ( ! is_object(delegate[key]) ) { + continue; + } + replacement[key] = deep_proto_merge(replacement[key], delegate[key], options); + } + + // use a Proxy object to ensure all keys are present + // when listing keys of `replacement` + replacement = new Proxy(replacement, { + // no get needed + // no set needed + ownKeys: (target) => { + const ownProps = Reflect.ownKeys(target); // Get own property names and symbols, including non-enumerable + const protoProps = Reflect.ownKeys(Object.getPrototypeOf(target)); // Get prototype's properties + + // Combine and deduplicate properties using a Set, then convert back to an array + const s = new Set([ + ...protoProps, + ...ownProps, + ]); + + if ( options?.preserve_flag ) { + // remove $preserve if it exists + s.delete('$preserve'); + } + + return Array.from(s); + }, + getOwnPropertyDescriptor: (target, prop) => { + // Real descriptor + let descriptor = Object.getOwnPropertyDescriptor(target, prop); + + if ( descriptor ) return descriptor; + + // Immediate prototype descriptor + const proto = Object.getPrototypeOf(target); + descriptor = Object.getOwnPropertyDescriptor(proto, prop); + + if ( descriptor ) return descriptor; + + return undefined; + }, + + }); + + return replacement; +}; + +module.exports = deep_proto_merge; diff --git a/src/backend/src/config/reserved_words.js b/src/backend/src/config/reserved_words.js new file mode 100644 index 0000000000000000000000000000000000000000..04a0820d8ffab367993aa20aba6c5f40ccf53feb --- /dev/null +++ b/src/backend/src/config/reserved_words.js @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = [ + // system and apps + 'about', + 'api', + 'camera', + 'changelog', + 'cloudjs', + 'cloud.js', + 'code', + 'dev-center', + 'draw', + 'editor', + 'markus', + 'pdf', + 'photopea', + 'player', + 'terminal', + 'viewer', + 'www', + + // UNIX directories + 'share', + 'usr', + 'dev', + 'var', + 'etc', + 'tmp', + 'lib', + 'mnt', + 'opt', + 'bin', + + // others + 'admin', + 'ads', + 'alt', + 'api', + 'app', + 'apps', + 'audio', + 'auth', + 'badge', + 'beta', + 'business', + 'buy', + 'cdn', + 'cli', + 'cloud', + 'cmd', + 'community', + 'careers', + 'config', + 'db', + 'demo', + 'dev', + 'developers', + 'dns1', + 'dns2', + 'dns3', + 'dns4', + 'dns5', + 'dns6', + 'dns7', + 'dns8', + 'dns9', + 'dns0', + 'doc', + 'docs', + 'email', + 'eng', + 'engineering', + 'exchange', + 'faq', + 'feeds', + 'files', + 'forum', + 'fs', + 'ftp', + 'gov', + 'groups', + 'help', + 'hq', + 'images', + 'img', + 'in', + 'inbound', + 'info', + 'jobs', + 'js', + 'lab', + 'learn', + 'live', + 'login', + 'mail', + 'media', + 'mobile', + 'mx', + 'mx1', + 'mx2', + 'mx3', + 'mx4', + 'mx5', + 'mx6', + 'mx7', + 'mx8', + 'mx9', + 'mx0', + 'my', + 'mysql', + 'news', + 'newsletter', + 'ns1', + 'ns2', + 'ns3', + 'ns4', + 'ns5', + 'ns6', + 'ns7', + 'ns8', + 'ns9', + 'ns0', + 'office', + 'out', + 'owa', + 'pop', + 'pop3', + 'portal', + 'private', + 'public', + 'puter', + 'remote', + 'sandbox', + 'sdk', + 'search', + 'secure', + 'service', + 'shell', + 'shop', + 'signin', + 'signup', + 'smtp', + 'smtpin', + 'socket', + 'ssl', + 'start', + 'static', + 'status', + 'store', + 'support', + 'test', + 'tutorials', + 'upload', + 'video', + 'videos', + 'vpn', + 'vps', + 'web', + 'wiki', + 'www', + + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', +]; diff --git a/src/backend/src/data/hardcoded-permissions.js b/src/backend/src/data/hardcoded-permissions.js new file mode 100644 index 0000000000000000000000000000000000000000..b535800e25c6a93ec2de5ee3b776a1eda170168a --- /dev/null +++ b/src/backend/src/data/hardcoded-permissions.js @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const default_implicit_user_app_permissions = { + 'driver:helloworld:greet': {}, + 'driver:puter-kvstore': {}, + 'driver:puter-ocr:recognize': {}, + 'driver:puter-chat-completion': {}, + 'driver:puter-image-generation': {}, + 'driver:puter-video-generation': {}, + 'driver:puter-tts': {}, + 'driver:puter-speech2speech': {}, + 'driver:puter-speech2txt': {}, + 'driver:puter-apps': {}, + 'driver:puter-subdomains': {}, + 'driver:temp-email': {}, + 'service': {}, + 'feature': {}, +}; + +const implicit_user_app_permissions = [ + { + id: 'builtin-apps', + apps: [ + 'app-0bef044f-918f-4cbf-a0c0-b4a17ee81085', // about + 'app-838dfbc4-bf8b-48c2-b47b-c4adc77fab58', // editor + 'app-58282b08-990a-4906-95f7-fa37ff92452b', // draw + 'app-5584fbf7-ed69-41fc-99cd-85da21b1ef51', // camera + 'app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1', // recorder + 'app-240a43f4-43b1-49bc-b9fc-c8ae719dab77', // dev-center + 'app-a2ae72a4-1ba3-4a29-b5c0-6de1be5cf178', // app-center + 'app-74378e84-b9cd-5910-bcb1-3c50fa96d6e7', // https://nj.puter.site + 'app-13a38aeb-f9f6-54f0-9bd3-9d4dd655ccfe', // https://cdpn.io + 'app-dce8f797-82b0-5d95-a2f8-ebe4d71b9c54', // https://null.jsbin.com + 'app-93005ce0-80d1-50d9-9b1e-9c453c375d56', // https://markus.puter.com + ], + permissions: { + 'driver:helloworld:greet': {}, + 'driver:puter-ocr:recognize': {}, + 'driver:puter-kvstore:get': {}, + 'driver:puter-kvstore:set': {}, + 'driver:puter-kvstore:del': {}, + 'driver:puter-kvstore:list': {}, + 'driver:puter-kvstore:flush': {}, + 'driver:puter-chat-completion:complete': {}, + 'driver:puter-image-generation:generate': {}, + 'driver:puter-video-generation:generate': {}, + 'driver:puter-speech2speech:convert': {}, + 'driver:puter-speech2txt:transcribe': {}, + 'driver:puter-speech2txt:translate': {}, + 'driver:puter-analytics:create_trace': {}, + 'driver:puter-analytics:record': {}, + }, + }, + { + id: 'local-testing', + apps: [ + 'app-a392f3e5-35ca-5dac-ae10-785696cc7dec', // https://localhost + 'app-a6263561-6a84-5d52-9891-02956f9fac65', // https://127.0.0.1 + 'app-26149f0b-8304-5228-b995-772dadcf410e', // http://localhost + 'app-c2e27728-66d9-54dd-87cd-6f4e9b92e3e3', // http://127.0.0.1 + ], + permissions: { + 'driver:helloworld:greet': {}, + 'driver:puter-ocr:recognize': {}, + 'driver:puter-kvstore:get': {}, + 'driver:puter-kvstore:set': {}, + 'driver:puter-kvstore:del': {}, + 'driver:puter-kvstore:list': {}, + 'driver:puter-kvstore:flush': {}, + }, + }, +]; + +const policy_perm = selector => ({ + policy: { + $: 'json-address', + path: '/admin/.policy/drivers.json', + selector, + }, +}); + +const hardcoded_user_group_permissions = { + system: { + 'ca342a5e-b13d-4dee-9048-58b11a57cc55': { + 'driver': {}, + 'service': {}, + 'feature': {}, + 'kernel-info': {}, + 'local-terminal:access': {}, + }, + 'b7220104-7905-4985-b996-649fdcdb3c8f': { + 'service:hello-world:ii:hello-world': policy_perm('temp.es'), + 'service:puter-kvstore:ii:puter-kvstore': policy_perm('temp.kv'), + 'driver:puter-kvstore': policy_perm('temp.kv'), + 'service:puter-notifications:ii:crud-q': policy_perm('temp.es'), + 'service:puter-apps:ii:crud-q': policy_perm('temp.es'), + 'service:puter-subdomains:ii:crud-q': policy_perm('temp.es'), + 'service:es\\Cnotification:ii:crud-q': policy_perm('user.es'), + 'service:es\\Capp:ii:crud-q': policy_perm('user.es'), + 'service:es\\Csubdomain:ii:crud-q': policy_perm('user.es'), + }, + '78b1b1dd-c959-44d2-b02c-8735671f9997': { + 'service:hello-world:ii:hello-world': policy_perm('user.es'), + 'service:puter-kvstore:ii:puter-kvstore': policy_perm('user.kv'), + 'driver:puter-kvstore': policy_perm('user.kv'), + 'service:es\\Cnotification:ii:crud-q': policy_perm('user.es'), + 'service:es\\Capp:ii:crud-q': policy_perm('user.es'), + 'service:es\\Csubdomain:ii:crud-q': policy_perm('user.es'), + }, + }, +}; + +module.exports = { + implicit_user_app_permissions, + default_implicit_user_app_permissions, + hardcoded_user_group_permissions, +}; diff --git a/src/backend/src/definitions/SimpleEntity.js b/src/backend/src/definitions/SimpleEntity.js new file mode 100644 index 0000000000000000000000000000000000000000..ac7ae64b1392a756446514f450a619289c0ee405 --- /dev/null +++ b/src/backend/src/definitions/SimpleEntity.js @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { Context } = require('../util/context'); + +module.exports = function SimpleEntity ({ name, methods, fetchers }) { + const create = function (values) { + const entity = { values }; + Object.assign(entity, methods); + for ( const fetcher_name in fetchers ) { + entity[`fetch_${ fetcher_name}`] = async function () { + if ( Object.prototype.hasOwnProperty.call(this.values, fetcher_name) ) { + return this.values[fetcher_name]; + } + const value = await fetchers[fetcher_name].call(this); + this.values[fetcher_name] = value; + return value; + }; + } + entity.context = values.context ?? Context.get(); + entity.services = entity.context.get('services'); + return entity; + }; + + create.name = name; + return create; +}; diff --git a/src/backend/src/entities/Group.js b/src/backend/src/entities/Group.js new file mode 100644 index 0000000000000000000000000000000000000000..877c10332d2c05163e485f11505ebb5234e10f15 --- /dev/null +++ b/src/backend/src/entities/Group.js @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const SimpleEntity = require('../definitions/SimpleEntity'); + +module.exports = SimpleEntity({ + name: 'group', + fetchers: { + async members () { + const svc_group = this.services.get('group'); + const members = await svc_group.list_members({ uid: this.values.uid }); + return members; + }, + }, + methods: { + async get_client_value (options = {}) { + if ( options.members ) { + await this.fetch_members(); + } + const group = { + uid: this.values.uid, + metadata: this.values.metadata, + ...(options.members ? { members: this.values.members } : {}), + }; + return group; + }, + }, +}); diff --git a/src/backend/src/env b/src/backend/src/env new file mode 100644 index 0000000000000000000000000000000000000000..90012116c03db04344ab10d50348553aa94f1ea0 --- /dev/null +++ b/src/backend/src/env @@ -0,0 +1 @@ +dev \ No newline at end of file diff --git a/src/backend/src/errors/TechnicalError.js b/src/backend/src/errors/TechnicalError.js new file mode 100644 index 0000000000000000000000000000000000000000..ebd385b62de380d7c9fbd3a5164b0053717ff52a --- /dev/null +++ b/src/backend/src/errors/TechnicalError.js @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/** + * @class TechnicalError + * @extends Error + * + * This error type is used for errors that may be presented in a + * technical context, such as a terminal or log file. + * + * @todo This could be a trait errors can have rather than a class. + */ +class TechnicalError extends Error { + constructor (message, ...details) { + super(message); + + for ( const detail of details ) { + detail(this); + } + } +} + +const ERR_HINT_NOSTACK = e => { + e.toString = () => e.message; +}; + +module.exports = { + TechnicalError, + ERR_HINT_NOSTACK, +}; diff --git a/src/backend/src/errors/error_help_details.js b/src/backend/src/errors/error_help_details.js new file mode 100644 index 0000000000000000000000000000000000000000..cbb6862ede2e2eb786ac1040636c4e9b8e2a9325 --- /dev/null +++ b/src/backend/src/errors/error_help_details.js @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { quot } = require('@heyputer/putility').libs.string; + +const reused = { + runtime_env_references: [ + { + subject: 'ENVIRONMENT.md file', + location: 'root of the repository', + use: 'describes which paths are checked', + }, + { + subject: 'boot logger', + location: 'above this text', + use: 'shows what checks were performed', + }, + { + subject: 'RuntimeEnvironment.js', + location: 'src/boot/ in repository', + use: 'code that performs the checks', + }, + ], +}; + +const programmer_errors = [ + 'Assignment to constant variable.', +]; + +const error_help_details = [ + { + match: ({ message }) => ( + message.startsWith('No suitable path found for') + ), + apply (more) { + more.references = [ + ...reused.runtime_env_references, + ]; + }, + }, + { + match: ({ message }) => ( + message.match(/^No (read|write) permission for/) + ), + apply (more) { + more.solutions = [ + { + title: 'Change permissions with chmod', + }, + { + title: 'Remove the path to use working directory', + }, + { + title: 'Set CONFIG_PATH or RUNTIME_PATH environment variable', + }, + ]; + more.references = [ + ...reused.runtime_env_references, + ]; + }, + }, + { + match: ({ message }) => ( + message.startsWith('No valid config file found in path') + ), + apply (more) { + more.solutions = [ + { + title: 'Create a valid config file', + }, + ]; + }, + }, + { + match: ({ message }) => ( + message === 'config_name is required' + ), + apply (more) { + more.solutions = [ + 'ensure config_name is present in your config file', + 'Seek help on https://discord.gg/PQcx7Teh8u (our Discord server)', + ]; + }, + }, + { + match: ({ message }) => ( + message == 'Assignment to constant variable.' + ), + apply (more) { + more.references = [ + { + subject: 'MDN Reference for this error', + location: 'on the internet', + use: 'describes why this error occurs', + url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_const_assignment', + }, + ]; + }, + }, + { + match: ({ message }) => ( + programmer_errors.includes(message) + ), + apply (more) { + more.notes = [ + 'It looks like this might be our fault.', + ]; + more.solutions = [ + { title: 'Check for an issue on https://github.com/HeyPuter/puter/issues' }, + { title: 'If there is no issue, please create one: https://github.com/HeyPuter/puter/issues/new' }, + ]; + }, + }, + { + match: ({ message }) => ( + message.startsWith('Expected double-quoted property') + ), + apply (more) { + more.notes = [ + 'There might be a trailing-comma in your config', + ]; + }, + }, +]; + +/** + * Print error help information to a stream in a human-readable format. + * + * @param {Error} err - The error to print help for. + * @param {*} out - The stream to print to; defaults to process.stdout. + * @returns {undefined} + */ +const print_error_help = (err, out = process.stdout) => { + if ( ! err.more ) { + err.more = {}; + err.more.references = []; + err.more.solutions = []; + for ( const detail of error_help_details ) { + if ( detail.match(err) ) { + detail.apply(err.more); + } + } + } + + let write = out.write.bind(out); + + write('\n'); + + const wrap_msg = s => + `\x1B[31;1m┏━━ [ HELP:\x1B[0m ${quot(s)} \x1B[31;1m]\x1B[0m`; + const wrap_list_title = s => + `\x1B[36;1m${s}:\x1B[0m`; + + write(`${wrap_msg(err.message) }\n`); + + write = (s) => out.write(`\x1B[31;1m┃\x1B[0m ${ s}`); + + const vis = (stok, etok, str) => { + return `\x1B[36;1m${stok}\x1B[0m${str}\x1B[36;1m${etok}\x1B[0m`; + }; + + let lf_sep = false; + + write('Whoops! Looks like something isn\'t working!\n'); + let any_help = false; + + if ( err.more.notes ) { + write('\n'); + lf_sep = true; + any_help = true; + for ( const note of err.more.notes ) { + write(`\x1B[33;1m * ${note}\x1B[0m\n`); + } + } + + if ( err.more.solutions?.length > 0 ) { + if ( lf_sep ) write('\n'); + lf_sep = true; + any_help = true; + write('The suggestions below may help resolve this issue.\n'); + write('\n'); + write(`${wrap_list_title('Possible Solutions') }\n`); + for ( const sol of err.more.solutions ) { + write(` - ${sol.title}\n`); + } + } + + if ( err.more.references?.length > 0 ) { + if ( lf_sep ) write('\n'); + lf_sep = true; + any_help = true; + write('The references below may be related to this issue.\n'); + write('\n'); + write(`${wrap_list_title('References') }\n`); + for ( const ref of err.more.references ) { + write(` - ${vis('[', ']', ref.subject)} ` + + `${vis('(', ')', ref.location)};\n`); + write(` ${ref.use}\n`); + if ( ref.url ) { + write(` ${ref.url}\n`); + } + } + } + + if ( ! any_help ) { + write('No help is available for this error.\n'); + write('Help can be added in src/errors/error_help_details.\n'); + } + + out.write('\x1B[31;1m┗━━ [ END HELP ]\x1B[0m\n'); + out.write('\n'); +}; + +module.exports = { + error_help_details, + print_error_help, +}; diff --git a/src/backend/src/extension/RuntimeModule.js b/src/backend/src/extension/RuntimeModule.js new file mode 100644 index 0000000000000000000000000000000000000000..4bfe8bd7faf8e47046fedf090c0c33ddf03162fa --- /dev/null +++ b/src/backend/src/extension/RuntimeModule.js @@ -0,0 +1,30 @@ +const { AdvancedBase } = require('@heyputer/putility'); + +class RuntimeModule extends AdvancedBase { + constructor (options = {}) { + super(); + this.exports_ = undefined; + this.exports_is_set_ = false; + this.remappings = options.remappings ?? {}; + + this.name = options.name ?? undefined; + } + set exports (value) { + this.exports_is_set_ = true; + this.exports_ = value; + } + get exports () { + if ( this.exports_is_set_ === false && this.defer ) { + this.exports = this.defer(); + } + return this.exports_; + } + import (name) { + if ( this.remappings.hasOwnProperty(name) ) { + name = this.remappings[name]; + } + return this.runtimeModuleRegistry.exportsOf(name); + } +} + +module.exports = { RuntimeModule }; diff --git a/src/backend/src/extension/RuntimeModuleRegistry.js b/src/backend/src/extension/RuntimeModuleRegistry.js new file mode 100644 index 0000000000000000000000000000000000000000..3a9a6dad3065d61b35eea20fcc7b8cd5bf8fb522 --- /dev/null +++ b/src/backend/src/extension/RuntimeModuleRegistry.js @@ -0,0 +1,33 @@ +const { AdvancedBase } = require('@heyputer/putility'); +const { RuntimeModule } = require('./RuntimeModule'); + +class RuntimeModuleRegistry extends AdvancedBase { + constructor () { + super(); + this.modules_ = {}; + } + + register (extensionModule, options = {}) { + if ( ! (extensionModule instanceof RuntimeModule) ) { + throw new Error(`expected a RuntimeModule, but got: ${ + extensionModule?.constructor?.name ?? typeof extensionModule})`); + } + const uniqueName = options.as ?? extensionModule.name ?? require('uuid').v4(); + if ( this.modules_.hasOwnProperty(uniqueName) ) { + throw new Error(`duplicate runtime module: ${uniqueName}`); + } + this.modules_[uniqueName] = extensionModule; + extensionModule.runtimeModuleRegistry = this; + } + + exportsOf (name) { + if ( ! this.modules_[name] ) { + throw new Error(`could not find runtime module: ${name}`); + } + return this.modules_[name].exports; + } +} + +module.exports = { + RuntimeModuleRegistry, +}; diff --git a/src/backend/src/filesystem/ECMAP.js b/src/backend/src/filesystem/ECMAP.js new file mode 100644 index 0000000000000000000000000000000000000000..83adaf58fa08acecfd09bcc477f239ce07982eb8 --- /dev/null +++ b/src/backend/src/filesystem/ECMAP.js @@ -0,0 +1,125 @@ +const { Context } = require('../util/context'); +const { NodeUIDSelector, NodePathSelector, NodeInternalIDSelector } = require('./node/selectors'); + +const LOG_PREFIX = '\x1B[31;1m[[\x1B[33;1mEC\x1B[32;1mMAP\x1B[31;1m]]\x1B[0m'; + +/** + * The ECMAP class is a memoization structure used by FSNodeContext + * whenever it is present in the execution context (AsyncLocalStorage). + * It is assumed that this object is transient and invalidation of stale + * entries is not necessary. + * + * The name ECMAP simple means Execution Context Map, because the map + * exists in memory at a particular frame of the execution context. + */ +class ECMAP { + static SYMBOL = Symbol('ECMAP'); + + constructor () { + this.identifier = require('uuid').v4(); + + // entry caches + this.uuid_to_fsNodeContext = {}; + this.path_to_fsNodeContext = {}; + this.id_to_fsNodeContext = {}; + + // identifier association caches + this.path_to_uuid = {}; + this.uuid_to_path = {}; + + this.unlinked = false; + } + + /** + * unlink() clears all references from this ECMAP to ensure that it will be + * GC'd. This is called by ECMAP.arun() after the callback has resolved. + */ + unlink () { + this.unlink = true; + this.uuid_to_fsNodeContext = null; + this.path_to_fsNodeContext = null; + this.id_to_fsNodeContext = null; + this.path_to_uuid = null; + this.uuid_to_path = null; + } + + get logPrefix () { + return `${LOG_PREFIX} \x1B[36[1m${this.identifier}\x1B[0m`; + } + + log (...a) { + if ( ! process.env.LOG_ECMAP ) return; + console.log(this.logPrefix, ...a); + } + + get_fsNodeContext_from_selector (selector) { + if ( this.unlinked ) return null; + + this.log('GET', selector.describe()); + const retvalue = (() => { + let value; + if ( selector instanceof NodeUIDSelector ) { + value = this.uuid_to_fsNodeContext[selector.value]; + if ( value ) return value; + + let maybe_path = this.uuid_to_path[value]; + if ( ! maybe_path ) return; + value = this.path_to_fsNodeContext[maybe_path]; + if ( value ) return value; + } + else + if ( selector instanceof NodePathSelector ) { + value = this.path_to_fsNodeContext[selector.value]; + if ( value ) return value; + + let maybe_uid = this.path_to_uuid[value]; + value = this.uuid_to_fsNodeContext[maybe_uid]; + if ( value ) return value; + } + })(); + if ( retvalue ) { + this.log('\x1B[32;1m <<<<< ECMAP HIT >>>>> \x1B[0m'); + } else { + this.log('\x1B[31;1m <<<<< ECMAP MISS >>>>> \x1B[0m'); + } + return retvalue; + } + + store_fsNodeContext_to_selector (selector, node) { + if ( this.unlinked ) return null; + + this.log('STORE', selector.describe()); + if ( selector instanceof NodeUIDSelector ) { + this.uuid_to_fsNodeContext[selector.value] = node; + } + if ( selector instanceof NodePathSelector ) { + this.path_to_fsNodeContext[selector.value] = node; + } + if ( selector instanceof NodeInternalIDSelector ) { + this.id_to_fsNodeContext[`${selector.service}:${selector.id}`] = node; + } + } + + store_fsNodeContext (node) { + if ( this.unlinked ) return; + + this.store_fsNodeContext_to_selector(node.selector, node); + } + + static async arun (cb) { + let context = Context.get(); + if ( ! context.get(this.SYMBOL) ) { + const ins = new this(); + context = context.sub({ + [this.SYMBOL]: ins, + }); + const result = await context.arun(cb); + ins.unlink(); + context.unlink(); + return result; + } + return await cb(); + } +} + +module.exports = { ECMAP }; diff --git a/src/backend/src/filesystem/FSNodeContext.js b/src/backend/src/filesystem/FSNodeContext.js new file mode 100644 index 0000000000000000000000000000000000000000..be6a5364a5ca20f173a1a6f148eb219fb8c6bd14 --- /dev/null +++ b/src/backend/src/filesystem/FSNodeContext.js @@ -0,0 +1,961 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { get_user, id2path, id2uuid, is_empty, suggest_app_for_fsentry, get_app } = require('../helpers'); + +const putility = require('@heyputer/putility'); +const config = require('../config'); +const _path = require('path'); +const { NodeInternalIDSelector, NodeChildSelector, NodeUIDSelector, RootNodeSelector, NodePathSelector } = require('./node/selectors'); +const { Context } = require('../util/context'); +const { NodeRawEntrySelector } = require('./node/selectors'); +const { DB_READ } = require('../services/database/consts'); +const { UserActorType, AppUnderUserActorType, Actor } = require('../services/auth/Actor'); +const { PermissionUtil } = require('../services/auth/permissionUtils.mjs'); +const { ECMAP } = require('./ECMAP'); +const { MANAGE_PERM_PREFIX } = require('../services/auth/permissionConts.mjs'); + +/** + * Container for information collected about a node + * on the filesystem. + * + * Examples of such information include: + * - data collected by querying an fsentry + * - the location of a file's contents + * + * This is an implementation of the Facade design pattern, + * so information about a filesystem node should be collected + * via the methods on this class and not mutated directly. + * + * @class FSNodeContext + * @property {object} entry the filesystem entry + * @property {string} path the path to the filesystem entry + * @property {string} uid the UUID of the filesystem entry + */ + +const TYPE_FILE = { label: 'File' }; +const TYPE_DIRECTORY = { label: 'Directory' }; +module.exports = class FSNodeContext { + static CONCERN = 'filesystem'; + + static TYPE_FILE = TYPE_FILE; + static TYPE_DIRECTORY = TYPE_DIRECTORY; + static TYPE_SYMLINK = {}; + static TYPE_SHORTCUT = {}; + static TYPE_UNDETERMINED = {}; + + static SELECTOR_PRIORITY_ORDER = [ + NodeRawEntrySelector, + RootNodeSelector, + NodeInternalIDSelector, + NodeUIDSelector, + NodeChildSelector, + NodePathSelector, + ]; + + /** + * Creates an instance of FSNodeContext. + * @param {*} opt_identifier + * @param {*} opt_identifier.path a path to the filesystem entry + * @param {*} opt_identifier.uid a UUID of the filesystem entry + * @param {*} opt_identifier.id please pass mysql_id instead + * @param {*} opt_identifier.mysql_id a MySQL ID of the filesystem entry + */ + constructor ({ + services, + selector, + provider, + fs, + }) { + const ecmap = Context.get(ECMAP.SYMBOL); + + if ( ecmap ) { + // We might return an existing FSNodeContext + const maybe_node = ecmap + ?.get_fsNodeContext_from_selector?.(selector); + if ( maybe_node ) return maybe_node; + } else { + if ( process.env.LOG_ECMAP ) { + console.log('\x1B[31;1m !!! NO ECMAP !!! \x1B[0m'); + } + } + + // This will be used to avoid concurrent fetches. Whenever an entry is being fetched, + // a subsequent call to fetchEntry must await this promise. Usually this means the + // subsequent call will not perform any expensive operations. + this.fetching = null; + + this.log = services.get('log-service').create('fsnode-context', { + concern: this.constructor.CONCERN, + }); + this.selector_ = null; + this.selectors_ = []; + this.selector = selector; + this.provider = provider; + this.entry = {}; + this.found = undefined; + this.found_thumbnail = undefined; + + selector.setPropertiesKnownBySelector(this); + + this.services = services; + + this.fileContentsFetcher = null; + + this.fs = fs; + + // Decorate all fetch methods with otel span + // TODO: Apply method decorators using a putility class feature + const fetch_methods = [ + 'fetchEntry', + 'fetchPath', + 'fetchSubdomains', + 'fetchOwner', + 'fetchShares', + 'fetchVersions', + 'fetchSize', + 'fetchSuggestedApps', + 'fetchIsEmpty', + ]; + for ( const method of fetch_methods ) { + const original_method = this[method]; + this[method] = async (...args) => { + const tracer = this.services.get('traceService').tracer; + let result; + const opts = { attributes: { + selector: selector.describe(), + trace: (new Error()).stack, + } }; + await tracer.startActiveSpan(`fs:nodectx:fetch:${method}`, opts, async span => { + result = await original_method.call(this, ...args); + span.end(); + }); + return result; + }; + } + } + + set selector (new_selector) { + // Only add the selector if we don't already have it + for ( const selector of this.selectors_ ) { + if ( selector instanceof new_selector.constructor ) return; + } + + const ecmap = Context.get(ECMAP.SYMBOL); + if ( ecmap ) { + ecmap.store_fsNodeContext_to_selector(new_selector, this); + } + + this.selectors_.push(new_selector); + this.selector_ = new_selector; + } + + get selector () { + return this.get_optimal_selector(); + } + + get_selector_of_type (cls) { + // Reverse iterate over selectors + for ( let i = this.selectors_.length - 1; i >= 0; i-- ) { + const selector = this.selectors_[i]; + if ( selector instanceof cls ) { + return selector; + } + } + + if ( cls.implyFromFetchedData ) { + return cls.implyFromFetchedData(this); + } + + return null; + } + + get_optimal_selector () { + for ( const cls of FSNodeContext.SELECTOR_PRIORITY_ORDER ) { + const selector = this.get_selector_of_type(cls); + if ( selector ) return selector; + } + this.log.warn('Failed to get optimal selector'); + return this.selector_; + } + + get isRoot () { + return this.path === '/'; + } + + async isUserDirectory () { + if ( this.isRoot ) return false; + if ( this.found === undefined ) { + await this.fetchEntry(); + } + if ( this.isRoot ) return false; + if ( this.found === false ) return undefined; + return !this.entry.parent_uid; + } + + async isAppDataDirectory () { + if ( this.isRoot ) return false; + if ( this.found === undefined ) { + await this.fetchEntry(); + } + if ( this.isRoot ) return false; + + const components = await this.getPathComponents(); + if ( components.length < 2 ) return false; + return components[1] === 'AppData'; + } + + async isPublic () { + if ( this.isRoot ) return false; + const components = await this.getPathComponents(); + if ( await this.isUserDirectory() ) return false; + if ( components[1] === 'Public' ) return true; + return false; + } + + async getPathComponents () { + if ( this.isRoot ) return []; + + // We can get path components for non-existing nodes if they + // have a path selector + if ( ! await this.exists() ) { + if ( this.selector instanceof NodePathSelector ) { + let path = this.selector.value; + if ( path.startsWith('/') ) path = path.slice(1); + return path.split('/'); + } + + // TODO: add support for NodeChildSelector as well + } + + let path = await this.get('path'); + if ( path.startsWith('/') ) path = path.slice(1); + return path.split('/'); + } + + async getUserPart () { + if ( this.isRoot ) return; + const components = await this.getPathComponents(); + return components[0]; + } + + async getPathSize () { + if ( this.isRoot ) return; + const components = await this.getPathComponents(); + return components.length; + } + + async exists ({ fetch_options } = {}) { + if ( this.found !== undefined ) { + return this.found; + } + await this.fetchEntry(fetch_options); + if ( ! this.found ) { + this.log.debug(`here's why it doesn't exist: ${ + this.selector.describe() } -> ${ + this.uid } ${ + JSON.stringify(this.entry, null, ' ')}`); + } + return this.found; + } + + async fetchPath () { + if ( this.path ) return; + + this.path = await this.services.get('information') + .with('fs.fsentry') + .obtain('fs.fsentry:path') + .exec(this.entry); + } + + /** + * Fetches the filesystem entry associated with a + * filesystem node identified by a path or UID. + * + * If a UID exists, the path is ignored. + * If neither a UID nor a path is set, an error is thrown. + * + * @param {*} fsEntryFetcher fetches the filesystem entry + * @void + */ + async fetchEntry (fetch_entry_options = {}) { + if ( this.fetching !== null ) { + await Context.get('services').get('traceService').spanify('fetching', async () => { + // ???: does this need to be double-checked? I'm not actually sure... + if ( this.fetching === null ) return; + await this.fetching; + }); + } + this.fetching = new putility.libs.promise.TeePromise(); + + if ( + this.found === true && + !fetch_entry_options.force && + ( + // thumbnail already fetched, or not asked for + !fetch_entry_options.thumbnail || this.entry?.thumbnail || + this.found_thumbnail !== undefined + ) + ) { + const promise = this.fetching; + this.fetching = null; + promise.resolve(); + return; + } + + const controls = { + log: this.log, + provide_selector: selector => { + this.selector = selector; + }, + }; + + this.log.debug(`fetching entry: ${ this.selector.describe()}`); + + const entry = await this.provider.stat({ + selector: this.selector, + options: fetch_entry_options, + node: this, + controls, + }); + + if ( ! entry ) { + this.found = false; + this.entry = false; + } else { + this.found = true; + + if ( !this.uid && entry.uuid ) { + this.uid = entry.uuid; + } + + if ( !this.mysql_id && entry.id ) { + this.mysql_id = entry.id; + } + + if ( !this.path && entry.path ) { + this.path = entry.path; + } + + if ( !this.name && entry.name ) { + this.name = entry.name; + } + + Object.assign(this.entry, entry); + } + + const promise = this.fetching; + this.fetching = null; + + promise.resolve(); + } + + /** + * Wait for an fsentry which might be enqueued for insertion + * into the database. + * + * This just calls ResourceService under the hood. + */ + async awaitStableEntry () { + const resourceService = Context.get('services').get('resourceService'); + await resourceService.waitForResource(this.selector); + } + + /** + * Fetches the subdomains associated with a directory or file + * and stores them on the `subdomains` property of the fsentry. + * @param {object} user the user is needed to query subdomains + * @param {bool} force fetch subdomains if they were already fetched + * + * @param fs:decouple-subdomains + */ + async fetchSubdomains (user, _force) { + if ( ! this.entry.is_dir ) return; + + const db = this.services.get('database').get(DB_READ, 'filesystem'); + + this.entry.subdomains = []; + let subdomains = await db.read('SELECT * FROM subdomains WHERE root_dir_id = ? AND user_id = ?', + [this.entry.id, user.id]); + if ( subdomains.length > 0 ) { + subdomains.forEach((sd) => { + this.entry.subdomains.push({ + subdomain: sd.subdomain, + address: `${config.protocol }://${ sd.subdomain }.` + 'puter.site', + uuid: sd.uuid, + }); + }); + this.entry.has_website = true; + } + } + + /** + * Fetches the owner of a directory or file and stores it on the + * `owner` property of the fsentry. + * @param {bool} force fetch owner if it was already fetched + */ + async fetchOwner (_force) { + if ( this.isRoot ) return; + const owner = await get_user({ id: this.entry.user_id }); + this.entry.owner = { + username: owner.username, + email: owner.email, + }; + } + + /** + * Fetches shares, AKA "permissions", for a directory or file; + * then, stores them on the `permissions` property + * of the fsentry. + * @param {bool} force fetch shares if they were already fetched + */ + async fetchShares (force) { + if ( this.entry.shares && !force ) return; + + const actor = Context.get('actor'); + if ( ! actor ) { + this.entry.shares = { users: [], apps: [] }; + return; + } + + if ( ! (actor.type instanceof UserActorType) ) { + this.entry.shares = { users: [], apps: [] }; + return; + } + + const svc_permission = this.services.get('permission'); + + const fsPermPrefix = `fs:${await this.get('uid')}`; + const [readWritePerms, managePerms] = await Promise.all([ + svc_permission.query_issuer_permissions_by_prefix(actor.type.user, `${fsPermPrefix}:`), + svc_permission.query_issuer_permissions_by_prefix(actor.type.user, `${MANAGE_PERM_PREFIX}:${fsPermPrefix}`), + ]); + + this.entry.shares = { users: [], apps: [] }; + + for ( const readWriteUserPerms of readWritePerms.users ) { + const access = + PermissionUtil.split(readWriteUserPerms.permission).slice(-1)[0]; + this.entry.shares.users.push({ + user: { + uid: readWriteUserPerms.user.uuid, + username: readWriteUserPerms.user.username, + }, + access, + permission: readWriteUserPerms.permission, + }); + } + for ( const manageUserPerms of managePerms.users ) { + const access = MANAGE_PERM_PREFIX; + this.entry.shares.users.push({ + user: { + uid: manageUserPerms.user.uuid, + username: manageUserPerms.user.username, + }, + access, + permission: manageUserPerms.permission, + }); + } + + for ( const readWriteAppPerms of readWritePerms.apps ) { + const access = + PermissionUtil.split(readWriteAppPerms.permission).slice(-1)[0]; + this.entry.shares.apps.push({ + app: { + icon: readWriteAppPerms.app.icon, + uid: readWriteAppPerms.app.uid, + name: readWriteAppPerms.app.name, + }, + access, + permission: readWriteAppPerms.permission, + }); + } + + for ( const manageAppPerms of readWritePerms.apps ) { + const access = + MANAGE_PERM_PREFIX; + this.entry.shares.apps.push({ + app: { + icon: manageAppPerms.app.icon, + uid: manageAppPerms.app.uid, + name: manageAppPerms.app.name, + }, + access, + permission: manageAppPerms.permission, + }); + } + } + + /** + * Fetches versions associated with a filesystem entry, + * then stores them on the `versions` property of + * the fsentry. + * @param {bool} force fetch versions if they were already fetched + * + * @todo fs:decouple-versions + */ + async fetchVersions (force) { + if ( this.entry.versions && !force ) return; + + const db = this.services.get('database').get(DB_READ, 'filesystem'); + + let versions = await db.read('SELECT * FROM fsentry_versions WHERE fsentry_id = ?', + [this.entry.id]); + const versions_tidy = []; + for ( const version of versions ) { + let username = version.user_id ? (await get_user({ id: version.user_id })).username : null; + versions_tidy.push({ + id: version.version_id, + message: version.message, + timestamp: version.ts_epoch, + user: { + username: username, + }, + }); + } + + this.entry.versions = versions_tidy; + } + + /** + * Fetches the size of a file or directory if it was not + * already fetched. + */ + async fetchSize () { + // we already have the size for files + if ( ! this.entry.is_dir ) { + await this.fetchEntry(); + return this.entry.size; + } + + this.entry.size = await this.provider.get_recursive_size({ node: this }); + + return this.entry.size; + } + + async fetchSuggestedApps (user, force) { + if ( this.entry.suggested_apps && !force ) return; + + await this.fetchEntry(); + if ( ! this.entry ) return; + + this.entry.suggested_apps = + await suggest_app_for_fsentry(this.entry, { user }); + } + + async fetchIsEmpty () { + if ( !this.uid && !this.path ) return; + this.entry.is_empty = await is_empty({ + uid: this.uid, + path: this.path, + }); + } + + async fetchAll (_fsEntryFetcher, user, _force) { + await this.fetchEntry({ thumbnail: true }); + await this.fetchSubdomains(user); + await this.fetchOwner(); + await this.fetchShares(); + await this.fetchVersions(); + await this.fetchSize(user); + await this.fetchSuggestedApps(user); + await this.fetchIsEmpty(); + } + + async get (key) { + /* + This isn't supposed to stay like this! + + """ if ( key === something ) return this """ + + ^ we should use a map of getters instead + + Ideally I'd like to make a class trait for classes like + FSNodeContext that provide a key-value facade to access + information about some entity. + */ + + if ( this.found === false ) { + throw new Error(`Tried to get ${key} of non-existent fsentry: ${ + this.selector.describe(true)}`); + } + + if ( key === 'entry' ) { + await this.fetchEntry(); + if ( this.found === false ) { + throw new Error(`Tried to get entry of non-existent fsentry: ${ + this.selector.describe(true)}`); + } + return this.entry; + } + + if ( key === 'path' ) { + if ( ! this.path ) await this.fetchEntry(); + if ( this.found === false ) { + throw new Error(`Tried to get path of non-existent fsentry: ${ + this.selector.describe(true)}`); + } + if ( ! this.path ) { + await this.fetchPath(); + } + if ( ! this.path ) { + throw new Error('failed to get path'); + } + return this.path; + } + + if ( key === 'uid' ) { + const uidSelector = this.get_selector_of_type(NodeUIDSelector); + if ( uidSelector ) { + return uidSelector.value; + } + await this.fetchEntry(); + return this.uid; + } + + if ( key === 'mysql-id' ) { + await this.fetchEntry(); + return this.mysql_id ?? this.entry.id; + } + + if ( key === 'owner' ) { + const user_id = await this.get('user_id'); + const actor = new Actor({ + type: new UserActorType({ + user: await get_user({ id: user_id }), + }), + }); + return actor; + } + + const values_from_entry = ['immutable', 'user_id', 'name', 'size', 'parent_uid', 'metadata']; + for ( const k of values_from_entry ) { + if ( key === k ) { + await this.fetchEntry(); + if ( this.found === false ) { + throw new Error(`Tried to get ${key} of non-existent fsentry: ${ + this.selector.describe(true)}`); + } + return this.entry[k]; + } + } + + if ( key === 'type' ) { + await this.fetchEntry(); + + // Longest ternary operator chain I've ever written? + return this.entry.is_shortcut + ? FSNodeContext.TYPE_SHORTCUT + : this.entry.is_symlink + ? FSNodeContext.TYPE_SYMLINK + : this.entry.is_dir + ? FSNodeContext.TYPE_DIRECTORY + : FSNodeContext.TYPE_FILE; + } + + if ( key === 'has-s3' ) { + await this.fetchEntry(); + if ( this.entry.is_dir ) return false; + if ( this.entry.is_shortcut ) return false; + return true; + } + + if ( key === 's3:location' ) { + await this.fetchEntry(); + if ( ! await this.exists() ) { + throw new Error('file does not exist'); + } + // return null for local filesystem + if ( ! this.entry.bucket ) { + return null; + } + return { + bucket: this.entry.bucket, + bucket_region: this.entry.bucket_region, + key: this.entry.uuid, + }; + } + + if ( key === 'is-root' ) { + await this.fetchEntry(); + return this.isRoot; + } + + if ( key === 'writable' ) { + const actor = Context.get('actor'); + if ( !actor || !actor.type.user ) return undefined; + const svc_acl = this.services.get('acl'); + return await svc_acl.check(actor, this, 'write'); + } + + throw new Error(`unrecognize key for FSNodeContext.get: ${key}`); + } + + async getParent () { + if ( this.isRoot ) { + throw new Error('tried to get parent of root'); + } + + if ( this.path ) { + const parent_fsNode = await this.fs.node({ + path: _path.dirname(this.path), + }); + return parent_fsNode; + } + + if ( this.selector instanceof NodeChildSelector ) { + return this.fs.node(this.selector.parent); + } + + if ( ! await this.exists() ) { + throw new Error('unable to get parent'); + } + + const parent_uid = this.entry.parent_uid; + + if ( ! parent_uid ) { + return this.fs.node(new RootNodeSelector()); + } + + return this.fs.node(new NodeUIDSelector(parent_uid)); + } + + async getChild (name) { + // If we have a path, we can get an FSNodeContext for the child + // without fetching anything. + if ( this.path ) { + const child_fsNode = await this.fs.node({ + path: _path.join(this.path, name), + }); + return child_fsNode; + } + + return await this.fs.node(new NodeChildSelector(this.selector, name)); + } + + async hasChild (name) { + return await this.provider.directory_has_name({ parent: this, name }); + } + + async getTarget () { + await this.fetchEntry(); + const type = await this.get('type'); + + if ( type === FSNodeContext.TYPE_SYMLINK ) { + const path = await this.entry.symlink_path; + return await this.fs.node({ path }); + } + + if ( type === FSNodeContext.TYPE_SHORTCUT ) { + const target_id = await this.entry.shortcut_to; + return await this.fs.node({ mysql_id: target_id }); + } + + return this; + } + + async is_above (child_fsNode) { + if ( this.isRoot ) return true; + + const path_this = await this.get('path'); + const path_child = await child_fsNode.get('path'); + + return path_child.startsWith(`${path_this }/`); + } + + async is (fsNode) { + if ( this.mysql_id && fsNode.mysql_id ) { + return this.mysql_id === fsNode.mysql_id; + } + + if ( this.uid && fsNode.uid ) { + return this.uid === fsNode.uid; + } + + if ( this.path && fsNode.path ) { + return await this.get('path') === await fsNode.get('path'); + } + + await this.fetchEntry(); + await fsNode.fetchEntry(); + return this.uid === fsNode.uid; + } + + async getSafeEntry (fetch_options = {}) { + const svc_event = this.services.get('event'); + + if ( this.found === false ) { + throw new Error(`Tried to get entry of non-existent fsentry: ${ + this.selector.describe(true)}`); + } + await this.fetchEntry(fetch_options); + + const res = this.entry; + const fsentry = {}; + if ( res.thumbnail ) { + await svc_event.emit('thumbnail.read', this.entry); + } + + // This property will not be serialized, but it can be checked + // by other code to verify that API calls do not send + // unsanitized filsystem entries. + Object.defineProperty(fsentry, '__is_safe__', { + enumerable: false, + value: true, + }); + + for ( const k in res ) { + fsentry[k] = res[k]; + } + + let actor; try { + actor = Context.get('actor'); + } catch ( _e ) { + // fail silently + } + if ( !actor?.type?.user || actor.type.user.id !== res.user_id ) { + if ( ! fsentry.owner ) await this.fetchOwner(); + fsentry.owner = { + username: res.owner?.username, + }; + } + if ( ! ( actor.type === AppUnderUserActorType ) ) { + if ( fsentry.owner ) delete fsentry.owner.email; + } + + const info = this.services.get('information'); + + if ( !this.uid && !this.entry.uuid ) { + console.warn(`Potential Error in getSafeEntry with no uid or entry.uuid ${ + this.selector.describe() } ${ + JSON.stringify(this.entry, null, ' ')}`); + } + + // If fsentry was found by a path but the entry doesn't + // have a path, use the path that was used to find it. + fsentry.path = res.path ?? this.path ?? await info + .with('fs.fsentry:uuid') + .obtain('fs.fsentry:path') + .exec(this.uid ?? this.entry.uuid); + + if ( fsentry.path && fsentry.path.startsWith('/-void/') ) { + fsentry.broken = true; + } + + fsentry.dirname = _path.dirname(fsentry.path); + fsentry.dirpath = fsentry.dirname; + fsentry.writable = await this.get('writable'); + + // Do not send internal IDs to clients + fsentry.id = res.uuid; + fsentry.parent_id = res.parent_uid; + // The client calls it uid, not uuid. + fsentry.uid = res.uuid; + delete fsentry.uuid; + delete fsentry.user_id; + if ( fsentry.suggested_apps ) { + for ( const app of fsentry.suggested_apps ) { + if ( app === null ) { + this.log.warn('null app'); + continue; + } + delete app.owner_user_id; + } + } + + // Do not send S3 bucket information to clients + delete fsentry.bucket; + delete fsentry.bucket_region; + + // Use client-friendly IDs for shortcut_to + fsentry.shortcut_to = (res.shortcut_to + ? await id2uuid(res.shortcut_to) : undefined); + try { + fsentry.shortcut_to_path = (res.shortcut_to + ? await id2path(res.shortcut_to) : undefined); + } catch ( _e ) { + fsentry.shortcut_invalid = true; + fsentry.shortcut_uid = res.shortcut_to; + } + + // Add file_request_url + if ( res.file_request_token && res.file_request_token !== '' ) { + fsentry.file_request_url = `${config.origin + }/upload?token=${ res.file_request_token}`; + } + + if ( fsentry.associated_app_id ) { + const app = await get_app({ id: fsentry.associated_app_id }); + fsentry.associated_app = app; + } + + // If this file is in an appdata directory, add `appdata_app` + const components = await this.getPathComponents(); + if ( components[1] === 'AppData' ) { + fsentry.appdata_app = components[2]; + } + + fsentry.is_dir = !!fsentry.is_dir; + + // Ensure `size` is numeric + if ( fsentry.size ) { + fsentry.size = parseInt(fsentry.size); + } + + return fsentry; + } + + static sanitize_pending_entry_info (res) { + const fsentry = {}; + + // This property will not be serialized, but it can be checked + // by other code to verify that API calls do not send + // unsanitized filsystem entries. + Object.defineProperty(fsentry, '__is_safe__', { + enumerable: false, + value: true, + }); + + for ( const k in res ) { + fsentry[k] = res[k]; + } + + fsentry.dirname = _path.dirname(fsentry.path); + + // Do not send internal IDs to clients + fsentry.id = res.uuid; + fsentry.parent_id = res.parent_uid; + // The client calls it uid, not uuid. + fsentry.uid = res.uuid; + + delete fsentry.uuid; + delete fsentry.user_id; + + // Do not send S3 bucket information to clients + delete fsentry.bucket; + delete fsentry.bucket_region; + + delete fsentry.shortcut_to; + delete fsentry.shortcut_to_path; + + return fsentry; + } +}; + +module.exports.TYPE_FILE = TYPE_FILE; +module.exports.TYPE_DIRECTORY = TYPE_DIRECTORY; diff --git a/src/backend/src/filesystem/FilesystemService.js b/src/backend/src/filesystem/FilesystemService.js new file mode 100644 index 0000000000000000000000000000000000000000..9016fac6a828d86c2f29ee44f5796c56a3abb355 --- /dev/null +++ b/src/backend/src/filesystem/FilesystemService.js @@ -0,0 +1,360 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +// TODO: database access can be a service +const { RESOURCE_STATUS_PENDING_CREATE } = require('../modules/puterfs/ResourceService.js'); +const { NodePathSelector, NodeUIDSelector, NodeInternalIDSelector, NodeSelector } = require('./node/selectors.js'); +const FSNodeContext = require('./FSNodeContext.js'); +const { Context } = require('../util/context.js'); +const APIError = require('../api/APIError.js'); +const { PermissionUtil, PermissionRewriter, PermissionImplicator, PermissionExploder } = require('../services/auth/permissionUtils.mjs'); +const { DB_WRITE } = require('../services/database/consts'); +const { UserActorType } = require('../services/auth/Actor'); +const { get_user } = require('../helpers'); +const BaseService = require('../services/BaseService'); +const { MANAGE_PERM_PREFIX } = require('../services/auth/permissionConts.mjs'); +const { quot } = require('@heyputer/putility/src/libs/string.js'); +const fsCapabilities = require('./definitions/capabilities.js'); + +class FilesystemService extends BaseService { + static MODULES = { + _path: require('path'), + uuidv4: require('uuid').v4, + config: require('../config.js'), + }; + + old_constructor (args) { + const { services } = args; + + // The new fs entry service + this.log = services.get('log-service').create('filesystem-service'); + + // used by update_child_paths + this.db = services.get('database').get(DB_WRITE, 'filesystem'); + + const info = services.get('information'); + info.given('fs.fsentry').provide('fs.fsentry:path') + .addStrategy('entry-or-delegate', async entry => { + if ( entry.path ) return entry.path; + return await info + .with('fs.fsentry:uuid') + .obtain('fs.fsentry:path') + .exec(entry.uuid); + }); + } + + async _init () { + this.old_constructor({ services: this.services }); + const svc_permission = this.services.get('permission'); + svc_permission.register_rewriter(PermissionRewriter.create({ + matcher: permission => { + if ( !permission.startsWith('fs:') && !permission.startsWith('manage:fs:') ) return false; + const [_, specifier] = permission.split('fs:'); + if ( ! specifier.startsWith('/') ) return false; + return true; + }, + rewriter: async permission => { + const [manageOpt, pathPerm] = permission.split('fs:'); + const [path, ...rest] = PermissionUtil.split(pathPerm); + const node = await this.node(new NodePathSelector(path)); + if ( ! await node.exists() ) { + // TOOD: we need a general-purpose error that can have + // a user-safe message, instead of using APIError + // which is for API errors. + throw APIError.create('subject_does_not_exist'); + } + const uid = await node.get('uid'); + if ( uid === undefined || uid === 'undefined' ) { + throw new Error(`uid is undefined for path ${path}`); + } + return [manageOpt.replace(':', ''), 'fs', uid, ...rest].filter(Boolean).join(':'); + }, + })); + svc_permission.register_implicator(PermissionImplicator.create({ + id: 'is-owner', + shortcut: true, + matcher: permission => { + // TODO DS: for now users will only have manage access on files, that might change, and then this has to change too + return permission.startsWith('fs:') + || permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`) + || permission.startsWith(`${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`); // owner has implicit rule to give others manage access; + }, + checker: async ({ actor, permission }) => { + if ( ! (actor.type instanceof UserActorType) ) { + return undefined; + } + + const [_, uid] = PermissionUtil.split(permission.replaceAll(`${MANAGE_PERM_PREFIX}:`, '')); + const node = await this.node(new NodeUIDSelector(uid)); + + if ( ! await node.exists() ) { + return undefined; + } + + const owner_id = await node.get('user_id'); + + // These conditions should never happen + if ( !owner_id || !actor.type.user.id ) { + throw new Error('something unexpected happened'); + } + + if ( owner_id === actor.type.user.id ) { + return {}; + } + + return undefined; + }, + })); + svc_permission.register_exploder(PermissionExploder.create({ + id: 'fs-access-levels', + matcher: permission => { + return permission.startsWith('fs:') && + PermissionUtil.split(permission).length >= 3; + }, + exploder: async ({ permission }) => { + const permissions = [permission]; + const [fsPrefix, fileId, specifiedMode, ...rest] = PermissionUtil.split(permission); + + const rules = { + see: ['list', 'read', 'write'], + list: ['read', 'write'], + read: ['write'], + }; + + if ( rules[specifiedMode] ) { + permissions.push(...rules[specifiedMode].map(mode => PermissionUtil.join(fsPrefix, fileId, mode, ...rest.slice(1)))); + // push manage permission as well + permissions.push(PermissionUtil.join(MANAGE_PERM_PREFIX, fsPrefix, fileId)); + } + + return permissions; + }, + })); + } + + async mkshortcut ({ parent, name, user, target }) { + + // Access Control + { + const svc_acl = this.services.get('acl'); + + if ( ! await svc_acl.check(user, target, 'read') ) { + throw await svc_acl.get_safe_acl_error(user, target, 'read'); + } + + if ( ! await svc_acl.check(user, parent, 'write') ) { + throw await svc_acl.get_safe_acl_error(user, parent, 'write'); + } + } + + if ( ! await target.exists() ) { + throw APIError.create('shortcut_to_does_not_exist'); + } + + if ( ! parent.provider.get_capabilities().has(fsCapabilities.PUTER_SHORTCUT) ) { + throw APIError.create('missing_filesystem_capability', null, { + action: 'make shortcut', + subjectName: parent.path ?? parent.uid, + providerName: parent.provider.name, + capability: 'PUTER_SHORTCUT', + }); + } + + return await parent.provider.puter_shortcut({ + parent, name, user, target, + }); + } + + async mklink ({ parent, name, user, target }) { + + // Access Control + { + const svc_acl = this.services.get('acl'); + + if ( ! await svc_acl.check(user, parent, 'write') ) { + throw await svc_acl.get_safe_acl_error(user, parent, 'write'); + } + } + + // We don't check if the target exists because broken links + // are allowed. + + const { _path, uuidv4 } = this.modules; + const resourceService = this.services.get('resourceService'); + const svc_fsEntry = this.services.get('fsEntryService'); + + const ts = Math.round(Date.now() / 1000); + const uid = uuidv4(); + + resourceService.register({ + uid, + status: RESOURCE_STATUS_PENDING_CREATE, + }); + + const raw_fsentry = { + is_symlink: 1, + symlink_path: target, + is_dir: 0, + uuid: uid, + parent_uid: await parent.get('uid'), + path: _path.join(await parent.get('path'), name), + user_id: user.id, + name, + created: ts, + updated: ts, + modified: ts, + immutable: false, + }; + + this.log.debug('creating symlink', { fsentry: raw_fsentry }); + + const entryOp = await svc_fsEntry.insert(raw_fsentry); + + (async () => { + await entryOp.awaitDone(); + this.log.debug('finished creating symlink', { uid }); + resourceService.free(uid); + })(); + + const node = await this.node(new NodeUIDSelector(uid)); + + const svc_event = this.services.get('event'); + svc_event.emit('fs.create.symlink', { + node, + context: Context.get(), + }); + + return node; + } + + async update_child_paths (old_path, new_path, user_id) { + + if ( ! old_path.endsWith('/') ) old_path += '/'; + if ( ! new_path.endsWith('/') ) new_path += '/'; + // TODO: fs:decouple-tree-storage + await this.db.write('UPDATE fsentries SET path = CONCAT(?, SUBSTRING(path, ?)) WHERE path LIKE ? AND user_id = ?', + [new_path, old_path.length + 1, `${old_path}%`, user_id]); + + const log = this.services.get('log-service').create('update_child_paths'); + log.debug(`updated ${old_path} -> ${new_path}`); + + } + + /** + * node() returns a filesystem node using path, uid, + * or id associated with a filesystem node. Use this + * method when you need to get a filesystem node and + * need to collect information about the entry. + * + * @param {*} location - path, uid, or id associated with a filesystem node + * @returns + */ + async node (selector) { + if ( typeof selector === 'string' ) { + if ( selector.startsWith('/') ) { + selector = new NodePathSelector(selector); + } + } + + // COERCE: legacy selection objects to Node*Selector objects + if ( + typeof selector === 'object' && + selector.constructor.name === 'Object' + ) { + if ( selector.path ) { + selector = new NodePathSelector(selector.path); + } else if ( selector.uid ) { + selector = new NodeUIDSelector(selector.uid); + } else { + selector = new NodeInternalIDSelector('mysql', selector.mysql_id); + } + } + + if ( ! (selector instanceof NodeSelector) ) { + throw new Error(`FileSystemService could not resolve the specified node value ${ + quot(`${ selector}`) } (type: ${typeof selector}) ` + + 'to a filesystem node selector'); + } + + system_dir_check: { + if ( ! (selector instanceof NodePathSelector) ) break system_dir_check; + if ( ! selector.value.startsWith('/') ) break system_dir_check; + + // OPTIMIZATION: Check if the path matches a system directory pattern. + const systemDirRegex = /^\/([a-zA-Z0-9_]+)\/(Trash|AppData|Desktop|Documents|Pictures|Videos|Public)$/; + const match = selector.value.match(systemDirRegex); + if ( ! match ) break system_dir_check; + + const username = match[1]; + const dirName = match[2]; + + // Get the user object (this is likely cached). + const user = await get_user({ username }); + if ( ! user ) break system_dir_check; + + let uuidKey = ( selector.value === `/${user.username}` ) + ? 'home_uuid' + : `${dirName.toLowerCase()}_uuid`; // e.g., 'desktop_uuid' + + const cachedUUID = user[uuidKey]; + if ( ! cachedUUID ) break system_dir_check; + + // If we have a cached ID, use it for more direct lookup. + selector = new NodeUIDSelector(cachedUUID); + } + + const svc_mountpoint = this.services.get('mountpoint'); + const provider = await svc_mountpoint.get_provider(selector); + + let fsNode = new FSNodeContext({ + provider, + services: this.services, + selector, + fs: this, + }); + + return fsNode; + } + + /** + * get_entry() returns a filesystem entry using + * path, uid, or id associated with a filesystem + * node. Use this method when you need to get a + * filesystem entry but don't need to collect any + * other information about the entry. + * + * @warning The entry returned by this method is not + * client-safe. Use FSNodeContext to get a client-safe + * entry by calling it's fetchEntry() method. + * + * @param {*} param0 options for getting the entry + * @param {*} param0.path + * @param {*} param0.uid + * @param {*} param0.id please use mysql_id instead + * @param {*} param0.mysql_id + */ + async get_entry ({ path, uid, id, mysql_id, ...options }) { + let fsNode = await this.node({ path, uid, id, mysql_id }); + await fsNode.fetchEntry(options); + return fsNode.entry; + } +} + +module.exports = { + FilesystemService, +}; diff --git a/src/backend/src/filesystem/batch/BatchExecutor.js b/src/backend/src/filesystem/batch/BatchExecutor.js new file mode 100644 index 0000000000000000000000000000000000000000..3f4771a236821405bf22e1f69248dc6a0c4fd613 --- /dev/null +++ b/src/backend/src/filesystem/batch/BatchExecutor.js @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const PathResolver = require('../../routers/filesystem_api/batch/PathResolver'); +const commands = require('./commands').commands; +const APIError = require('../../api/APIError'); +const { Context } = require('../../util/context'); +const config = require('../../config'); +const { TeePromise } = require('@heyputer/putility').libs.promise; +const { WorkUnit } = require('../../modules/core/lib/expect'); + +class BatchExecutor extends AdvancedBase { + static LOG_LEVEL = true; + + constructor (x, { actor, log, errors }) { + super(); + this.x = x; + this.actor = actor; + this.pathResolver = new PathResolver({ actor }); + this.expectations = x.get('services').get('expectations'); + this.log = log; + this.errors = errors; + this.responsePromises = []; + this.hasError = false; + + this.total_tbd = true; + this.total = 0; + this.counter = 0; + + this.concurrent_ops = 0; + this.max_concurrent_ops = 20; + this.ops_promise = null; + + this.log_batchCommands = (config.logging ?? []).includes('batch-commands'); + } + + async ready_for_more () { + if ( this.ops_promise === null ) { + this.ops_promise = new TeePromise(); + } + await this.ops_promise; + } + + async exec_op (req, op, file) { + while ( this.concurrent_ops >= this.max_concurrent_ops ) { + await this.ready_for_more(); + } + + this.concurrent_ops++; + + const { expectations } = this; + const command_cls = commands[op.op]; + if ( this.log_batchCommands ) { + console.log(command_cls, JSON.stringify(op, null, 2)); + } + delete op.op; + + const workUnit = WorkUnit.create(); + expectations.expect_eventually({ + workUnit, + checkpoint: 'operation responded', + }); + + // TEMP: event service will handle this + op.original_client_socket_id = req.body.original_client_socket_id; + op.socket_id = req.body.socket_id; + + // run the operation + let p = this.x.arun(async () => { + const x = Context.get(); + if ( ! x ) throw new Error('no context'); + + try { + if ( ! command_cls ) { + throw APIError.create('invalid_operation', null, { + operation: op.op, + }); + } + + if ( file ) { + workUnit.checkpoint(`about to run << ${ + file.originalname ?? file.name + } >> ${ + JSON.stringify(op)}`); + } + const command_ins = await command_cls.run({ + getFile: () => file, + pathResolver: this.pathResolver, + actor: this.actor, + }, op); + workUnit.checkpoint('operation invoked'); + + const res = await command_ins.awaitValue('result'); + // const res = await opctx.awaitValue('response'); + workUnit.checkpoint('operation responded'); + return res; + } catch (e) { + this.hasError = true; + if ( ! ( e instanceof APIError ) ) { + // TODO: alarm condition + this.errors.report('batch-operation', { + source: e, + trace: true, + alarm: true, + }); + + e = APIError.adapt(e); // eslint-disable-line no-ex-assign + } + + // Consume stream if there's a file + if ( file ) { + try { + // read entire stream + await new Promise((resolve, reject) => { + file.stream.on('end', resolve); + file.stream.on('error', reject); + file.stream.resume(); + }); + } catch (e) { + this.errors.report('batch-operation-2', { + source: e, + trace: true, + alarm: true, + }); + } + } + + if ( config.env == 'dev' ) { + console.error(e); + // process.exit(1); + } + + const serialized_error = e.serialize(); + return serialized_error; + } finally { + this.concurrent_ops--; + if ( this.ops_promise && this.concurrent_ops < this.max_concurrent_ops ) { + this.ops_promise.resolve(); + this.ops_promise = null; + } + } + }); + + // decorate with logging + p = p.then(result => { + this.counter++; + const { log, total, total_tbd, counter } = this; + const total_str = total_tbd ? `TBD(>${total})` : `${total}`; + log.debug(`Batch Progress: ${counter} / ${total_str} operations`); + return result; + }); + + // this.responsePromises.push(p); + + // It doesn't really matter whether or not `await` is here + // (that's a design flaw in the Promise API; what if you + // want a promise that returns a promise?) + const result = await p; + return result; + + } +} + +module.exports = { + BatchExecutor, +}; diff --git a/src/backend/src/filesystem/batch/commands.js b/src/backend/src/filesystem/batch/commands.js new file mode 100644 index 0000000000000000000000000000000000000000..15342b1e2f9ddfff98a690e91657d1029b5ef5a6 --- /dev/null +++ b/src/backend/src/filesystem/batch/commands.js @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { AsyncProviderFeature } = require('../../traits/AsyncProviderFeature'); +const { HLMkdir, QuickMkdir } = require('../hl_operations/hl_mkdir'); +const { Context } = require('../../util/context'); +const { HLWrite } = require('../hl_operations/hl_write'); +const { get_app } = require('../../helpers'); +const { OperationFrame } = require('../../services/OperationTraceService'); +const { HLMkShortcut } = require('../hl_operations/hl_mkshortcut'); +const { HLMkLink } = require('../hl_operations/hl_mklink'); +const { HLRemove } = require('../hl_operations/hl_remove'); + +class BatchCommand extends AdvancedBase { + static FEATURES = [ + new AsyncProviderFeature(), + ]; + static async run (executor, parameters) { + const instance = new this(); + let x = Context.get(); + const operationTraceSvc = x.get('services').get('operationTrace'); + const frame = await operationTraceSvc.add_frame(`batch:${ this.name}`); + if ( parameters.hasOwnProperty('item_upload_id') ) { + frame.attr('gui_metadata', { + ...(frame.get_attr('gui_metadata') || {}), + item_upload_id: parameters.item_upload_id, + }); + } + x = x.sub({ [operationTraceSvc.ckey('frame')]: frame }); + await x.arun(async () => { + await instance.run(executor, parameters); + }); + frame.status = OperationFrame.FRAME_STATUS_DONE; + return instance; + } +} + +class MkdirCommand extends BatchCommand { + async run (executor, parameters) { + const context = Context.get(); + const fs = context.get('services').get('filesystem'); + + const parent = parameters.parent + ? await fs.node(await executor.pathResolver.awaitSelector(parameters.parent)) + : undefined ; + + const meta = parameters.parent + ? executor.pathResolver.getMeta(parameters.parent) + : undefined ; + + if ( meta?.conflict_free ) { + // No potential conflict; just create the directory + const q_mkdir = new QuickMkdir(); + await q_mkdir.run({ + parent, + path: parameters.path, + }); + if ( parameters.as ) { + executor.pathResolver.putSelector(parameters.as, + q_mkdir.created.selector, + { conflict_free: true }); + } + this.setFactory('result', async () => { + await q_mkdir.created.awaitStableEntry(); + const response = await q_mkdir.created.getSafeEntry(); + return response; + }); + return; + } + + const hl_mkdir = new HLMkdir(); + const response = await hl_mkdir.run({ + parent, + path: parameters.path, + overwrite: parameters.overwrite, + dedupe_name: parameters.dedupe_name, + create_missing_parents: + parameters.create_missing_ancestors ?? + parameters.create_missing_parents ?? + false, + shortcut_to: parameters.shortcut_to, + actor: executor.actor, + }); + if ( parameters.as ) { + executor.pathResolver.putSelector(parameters.as, + hl_mkdir.created.selector, + hl_mkdir.used_existing + ? undefined + : { conflict_free: true }); + } + this.provideValue('result', response); + } +} + +class WriteCommand extends BatchCommand { + async run (executor, parameters) { + const context = Context.get(); + const fs = context.get('services').get('filesystem'); + + const uploaded_file = executor.getFile(); + + const destinationOrParent = + await fs.node(await executor.pathResolver.awaitSelector(parameters.path)); + + let app; + if ( parameters.app_uid ) { + app = await get_app({ uid: parameters.app_uid }); + } + + const hl_write = new HLWrite(); + if ( ! executor.actor ) { + throw new Error('Actor is missing here'); + } + const response = await hl_write.run({ + destination_or_parent: destinationOrParent, + specified_name: parameters.name, + fallback_name: uploaded_file.originalname, + + overwrite: parameters.overwrite, + dedupe_name: parameters.dedupe_name, + + create_missing_parents: + parameters.create_missing_ancestors ?? + parameters.create_missing_parents ?? + false, + actor: executor.actor, + + file: uploaded_file, + offset: parameters.offset, + + // TODO: handle these with event service instead + socket_id: parameters.socket_id, + operation_id: parameters.operation_id, + item_upload_id: parameters.item_upload_id, + app_id: app ? app.id : null, + }); + + this.provideValue('result', response); + + // const opctx = await fs.write(fs, { + // // --- per file --- + // name: parameters.name, + // fallbackName: uploaded_file.originalname, + // destinationOrParent, + // // app_id: app ? app.id : null, + // overwrite: parameters.overwrite, + // dedupe_name: parameters.dedupe_name, + // file: uploaded_file, + // thumbnail: parameters.thumbnail, + // target: parameters.target ? await req.fs.node(parameters.shortcut_to) : null, + // symlink_path: parameters.symlink_path, + // operation_id: parameters.operation_id, + // item_upload_id: parameters.item_upload_id, + // user: executor.user, + + // // --- per batch --- + // socket_id: parameters.socket_id, + // original_client_socket_id: parameters.original_client_socket_id, + // }); + + // opctx.onValue('response', v => this.provideValue('result', v)); + } +} + +class ShortcutCommand extends BatchCommand { + async run (executor, parameters) { + const context = Context.get(); + const fs = context.get('services').get('filesystem'); + + const destinationOrParent = + await fs.node(await executor.pathResolver.awaitSelector(parameters.path)); + + const shortcut_to = + await fs.node(await executor.pathResolver.awaitSelector(parameters.shortcut_to)); + + let app; + if ( parameters.app_uid ) { + app = await get_app({ uid: parameters.app_uid }); + } + + await destinationOrParent.fetchEntry({ thumbnail: true }); + await shortcut_to.fetchEntry({ thumbnail: true }); + + const hl_mkShortcut = new HLMkShortcut(); + const response = await hl_mkShortcut.run({ + parent: destinationOrParent, + name: parameters.name, + actor: executor.actor, + target: shortcut_to, + dedupe_name: parameters.dedupe_name, + + // TODO: handle these with event service instead + socket_id: parameters.socket_id, + operation_id: parameters.operation_id, + item_upload_id: parameters.item_upload_id, + app_id: app ? app.id : null, + }); + + this.provideValue('result', response); + } +} + +class SymlinkCommand extends BatchCommand { + async run (executor, parameters) { + const context = Context.get(); + const fs = context.get('services').get('filesystem'); + + const destinationOrParent = + await fs.node(await executor.pathResolver.awaitSelector(parameters.path)); + + let app; + if ( parameters.app_uid ) { + app = await get_app({ uid: parameters.app_uid }); + } + + await destinationOrParent.fetchEntry({ thumbnail: true }); + + const hl_mkLink = new HLMkLink(); + const response = await hl_mkLink.run({ + parent: destinationOrParent, + name: parameters.name, + actor: executor.actor, + target: parameters.target, + + // TODO: handle these with event service instead + socket_id: parameters.socket_id, + operation_id: parameters.operation_id, + item_upload_id: parameters.item_upload_id, + app_id: app ? app.id : null, + }); + + this.provideValue('result', response); + } +} + +class DeleteCommand extends BatchCommand { + async run (executor, parameters) { + const context = Context.get(); + const fs = context.get('services').get('filesystem'); + + const target = + await fs.node(await executor.pathResolver.awaitSelector(parameters.path)); + + const hl_remove = new HLRemove(); + const response = await hl_remove.run({ + target, + actor: executor.actor, + recursive: parameters.recursive ?? false, + descendants_only: parameters.descendants_only ?? false, + }); + this.provideValue('result', response); + } +} + +module.exports = { + commands: { + mkdir: MkdirCommand, + write: WriteCommand, + shortcut: ShortcutCommand, + symlink: SymlinkCommand, + delete: DeleteCommand, + }, +}; diff --git a/src/backend/src/filesystem/definitions/capabilities.js b/src/backend/src/filesystem/definitions/capabilities.js new file mode 100644 index 0000000000000000000000000000000000000000..aa74fb7589586fb2691a484229cc82c69a437fa7 --- /dev/null +++ b/src/backend/src/filesystem/definitions/capabilities.js @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const capabilityNames = [ + // PuterFS Capabilities + 'thumbnail', + 'uuid', + 'operation-trace', + 'readdir-uuid-mode', + 'update-thumbnail', + 'puter-shortcut', + + // Standard Capabilities + 'read', + 'write', + 'symlink', + 'trash', + + // Macro Capabilities + 'copy-tree', + 'move-tree', + 'remove-tree', + 'get-recursive-size', + + // Behavior Capabilities + 'case-sensitive', + + // POSIX Capabilities + 'readdir-inode-numbers', + 'unix-perms', +]; + +const fsCapabilities = {}; +for ( const capabilityName of capabilityNames ) { + const key = capabilityName.toUpperCase().replace(/-/g, '_'); + fsCapabilities[key] = Symbol(capabilityName); +} + +module.exports = fsCapabilities; diff --git a/src/backend/src/filesystem/definitions/proto/fsentry.proto b/src/backend/src/filesystem/definitions/proto/fsentry.proto new file mode 100644 index 0000000000000000000000000000000000000000..9fc852ab59221fcaa6649293532fbceb2080be12 --- /dev/null +++ b/src/backend/src/filesystem/definitions/proto/fsentry.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +// The FSEntry from client's (puter-js, http API) perspective, it's used for +// - end to end test +// - backend logic +// - communication between servers +message FSEntry { + string uuid = 1; + // Same as uuid, used for backward compatibility. + string uid = 2; + + string name = 3; + string path = 4; + + string parent_uuid = 5; + // Same as parent_uuid, used for backward compatibility. + string parent_uid = 6; + // Same as parent_uuid, used for backward compatibility. + string parent_id = 7; + + bool is_dir = 8; + int64 created = 9; + int64 modified = 10; + int64 accessed = 11; + int64 size = 12; +} \ No newline at end of file diff --git a/src/backend/src/filesystem/definitions/ts/fsentry.js b/src/backend/src/filesystem/definitions/ts/fsentry.js new file mode 100644 index 0000000000000000000000000000000000000000..2beebd0c2a151a77f2ae18ee08e9bb47b099686d --- /dev/null +++ b/src/backend/src/filesystem/definitions/ts/fsentry.js @@ -0,0 +1,247 @@ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +export const protobufPackage = ""; +function createBaseFSEntry() { + return { + uuid: "", + uid: "", + name: "", + path: "", + parent_uuid: "", + parent_uid: "", + parent_id: "", + is_dir: false, + created: 0, + modified: 0, + accessed: 0, + size: 0, + }; +} +export const FSEntry = { + encode(message, writer = new BinaryWriter()) { + if (message.uuid !== "") { + writer.uint32(10).string(message.uuid); + } + if (message.uid !== "") { + writer.uint32(18).string(message.uid); + } + if (message.name !== "") { + writer.uint32(26).string(message.name); + } + if (message.path !== "") { + writer.uint32(34).string(message.path); + } + if (message.parent_uuid !== "") { + writer.uint32(42).string(message.parent_uuid); + } + if (message.parent_uid !== "") { + writer.uint32(50).string(message.parent_uid); + } + if (message.parent_id !== "") { + writer.uint32(58).string(message.parent_id); + } + if (message.is_dir !== false) { + writer.uint32(64).bool(message.is_dir); + } + if (message.created !== 0) { + writer.uint32(72).int64(message.created); + } + if (message.modified !== 0) { + writer.uint32(80).int64(message.modified); + } + if (message.accessed !== 0) { + writer.uint32(88).int64(message.accessed); + } + if (message.size !== 0) { + writer.uint32(96).int64(message.size); + } + return writer; + }, + decode(input, length) { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFSEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + message.uuid = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + message.uid = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + message.name = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + message.path = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + message.parent_uuid = reader.string(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + message.parent_uid = reader.string(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + message.parent_id = reader.string(); + continue; + } + case 8: { + if (tag !== 64) { + break; + } + message.is_dir = reader.bool(); + continue; + } + case 9: { + if (tag !== 72) { + break; + } + message.created = longToNumber(reader.int64()); + continue; + } + case 10: { + if (tag !== 80) { + break; + } + message.modified = longToNumber(reader.int64()); + continue; + } + case 11: { + if (tag !== 88) { + break; + } + message.accessed = longToNumber(reader.int64()); + continue; + } + case 12: { + if (tag !== 96) { + break; + } + message.size = longToNumber(reader.int64()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + fromJSON(object) { + return { + uuid: isSet(object.uuid) ? globalThis.String(object.uuid) : "", + uid: isSet(object.uid) ? globalThis.String(object.uid) : "", + name: isSet(object.name) ? globalThis.String(object.name) : "", + path: isSet(object.path) ? globalThis.String(object.path) : "", + parent_uuid: isSet(object.parent_uuid) ? globalThis.String(object.parent_uuid) : "", + parent_uid: isSet(object.parent_uid) ? globalThis.String(object.parent_uid) : "", + parent_id: isSet(object.parent_id) ? globalThis.String(object.parent_id) : "", + is_dir: isSet(object.is_dir) ? globalThis.Boolean(object.is_dir) : false, + created: isSet(object.created) ? globalThis.Number(object.created) : 0, + modified: isSet(object.modified) ? globalThis.Number(object.modified) : 0, + accessed: isSet(object.accessed) ? globalThis.Number(object.accessed) : 0, + size: isSet(object.size) ? globalThis.Number(object.size) : 0, + }; + }, + toJSON(message) { + const obj = {}; + if (message.uuid !== "") { + obj.uuid = message.uuid; + } + if (message.uid !== "") { + obj.uid = message.uid; + } + if (message.name !== "") { + obj.name = message.name; + } + if (message.path !== "") { + obj.path = message.path; + } + if (message.parent_uuid !== "") { + obj.parent_uuid = message.parent_uuid; + } + if (message.parent_uid !== "") { + obj.parent_uid = message.parent_uid; + } + if (message.parent_id !== "") { + obj.parent_id = message.parent_id; + } + if (message.is_dir !== false) { + obj.is_dir = message.is_dir; + } + if (message.created !== 0) { + obj.created = Math.round(message.created); + } + if (message.modified !== 0) { + obj.modified = Math.round(message.modified); + } + if (message.accessed !== 0) { + obj.accessed = Math.round(message.accessed); + } + if (message.size !== 0) { + obj.size = Math.round(message.size); + } + return obj; + }, + create(base) { + return FSEntry.fromPartial(base ?? {}); + }, + fromPartial(object) { + const message = createBaseFSEntry(); + message.uuid = object.uuid ?? ""; + message.uid = object.uid ?? ""; + message.name = object.name ?? ""; + message.path = object.path ?? ""; + message.parent_uuid = object.parent_uuid ?? ""; + message.parent_uid = object.parent_uid ?? ""; + message.parent_id = object.parent_id ?? ""; + message.is_dir = object.is_dir ?? false; + message.created = object.created ?? 0; + message.modified = object.modified ?? 0; + message.accessed = object.accessed ?? 0; + message.size = object.size ?? 0; + return message; + }, +}; +function longToNumber(int64) { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} +function isSet(value) { + return value !== null && value !== undefined; +} +//# sourceMappingURL=fsentry.js.map \ No newline at end of file diff --git a/src/backend/src/filesystem/definitions/ts/fsentry.ts b/src/backend/src/filesystem/definitions/ts/fsentry.ts new file mode 100644 index 0000000000000000000000000000000000000000..4da6b91bc76d75ac726a5ac29b606b379cc0bff8 --- /dev/null +++ b/src/backend/src/filesystem/definitions/ts/fsentry.ts @@ -0,0 +1,315 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.8.0 +// protoc v3.21.12 +// source: fsentry.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = ""; + +/** + * The FSEntry from client's (puter-js, http API) perspective, it's used for + * - end to end test + * - backend logic + * - communication between servers + */ +export interface FSEntry { + uuid: string; + /** Same as uuid, used for backward compatibility. */ + uid: string; + name: string; + path: string; + parent_uuid: string; + /** Same as parent_uuid, used for backward compatibility. */ + parent_uid: string; + /** Same as parent_uuid, used for backward compatibility. */ + parent_id: string; + is_dir: boolean; + created: number; + modified: number; + accessed: number; + size: number; +} + +function createBaseFSEntry(): FSEntry { + return { + uuid: "", + uid: "", + name: "", + path: "", + parent_uuid: "", + parent_uid: "", + parent_id: "", + is_dir: false, + created: 0, + modified: 0, + accessed: 0, + size: 0, + }; +} + +export const FSEntry: MessageFns = { + encode(message: FSEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.uuid !== "") { + writer.uint32(10).string(message.uuid); + } + if (message.uid !== "") { + writer.uint32(18).string(message.uid); + } + if (message.name !== "") { + writer.uint32(26).string(message.name); + } + if (message.path !== "") { + writer.uint32(34).string(message.path); + } + if (message.parent_uuid !== "") { + writer.uint32(42).string(message.parent_uuid); + } + if (message.parent_uid !== "") { + writer.uint32(50).string(message.parent_uid); + } + if (message.parent_id !== "") { + writer.uint32(58).string(message.parent_id); + } + if (message.is_dir !== false) { + writer.uint32(64).bool(message.is_dir); + } + if (message.created !== 0) { + writer.uint32(72).int64(message.created); + } + if (message.modified !== 0) { + writer.uint32(80).int64(message.modified); + } + if (message.accessed !== 0) { + writer.uint32(88).int64(message.accessed); + } + if (message.size !== 0) { + writer.uint32(96).int64(message.size); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FSEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFSEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.uuid = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.uid = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.name = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.path = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.parent_uuid = reader.string(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.parent_uid = reader.string(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.parent_id = reader.string(); + continue; + } + case 8: { + if (tag !== 64) { + break; + } + + message.is_dir = reader.bool(); + continue; + } + case 9: { + if (tag !== 72) { + break; + } + + message.created = longToNumber(reader.int64()); + continue; + } + case 10: { + if (tag !== 80) { + break; + } + + message.modified = longToNumber(reader.int64()); + continue; + } + case 11: { + if (tag !== 88) { + break; + } + + message.accessed = longToNumber(reader.int64()); + continue; + } + case 12: { + if (tag !== 96) { + break; + } + + message.size = longToNumber(reader.int64()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): FSEntry { + return { + uuid: isSet(object.uuid) ? globalThis.String(object.uuid) : "", + uid: isSet(object.uid) ? globalThis.String(object.uid) : "", + name: isSet(object.name) ? globalThis.String(object.name) : "", + path: isSet(object.path) ? globalThis.String(object.path) : "", + parent_uuid: isSet(object.parent_uuid) ? globalThis.String(object.parent_uuid) : "", + parent_uid: isSet(object.parent_uid) ? globalThis.String(object.parent_uid) : "", + parent_id: isSet(object.parent_id) ? globalThis.String(object.parent_id) : "", + is_dir: isSet(object.is_dir) ? globalThis.Boolean(object.is_dir) : false, + created: isSet(object.created) ? globalThis.Number(object.created) : 0, + modified: isSet(object.modified) ? globalThis.Number(object.modified) : 0, + accessed: isSet(object.accessed) ? globalThis.Number(object.accessed) : 0, + size: isSet(object.size) ? globalThis.Number(object.size) : 0, + }; + }, + + toJSON(message: FSEntry): unknown { + const obj: any = {}; + if (message.uuid !== "") { + obj.uuid = message.uuid; + } + if (message.uid !== "") { + obj.uid = message.uid; + } + if (message.name !== "") { + obj.name = message.name; + } + if (message.path !== "") { + obj.path = message.path; + } + if (message.parent_uuid !== "") { + obj.parent_uuid = message.parent_uuid; + } + if (message.parent_uid !== "") { + obj.parent_uid = message.parent_uid; + } + if (message.parent_id !== "") { + obj.parent_id = message.parent_id; + } + if (message.is_dir !== false) { + obj.is_dir = message.is_dir; + } + if (message.created !== 0) { + obj.created = Math.round(message.created); + } + if (message.modified !== 0) { + obj.modified = Math.round(message.modified); + } + if (message.accessed !== 0) { + obj.accessed = Math.round(message.accessed); + } + if (message.size !== 0) { + obj.size = Math.round(message.size); + } + return obj; + }, + + create(base?: DeepPartial): FSEntry { + return FSEntry.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): FSEntry { + const message = createBaseFSEntry(); + message.uuid = object.uuid ?? ""; + message.uid = object.uid ?? ""; + message.name = object.name ?? ""; + message.path = object.path ?? ""; + message.parent_uuid = object.parent_uuid ?? ""; + message.parent_uid = object.parent_uid ?? ""; + message.parent_id = object.parent_id ?? ""; + message.is_dir = object.is_dir ?? false; + message.created = object.created ?? 0; + message.modified = object.modified ?? 0; + message.accessed = object.accessed ?? 0; + message.size = object.size ?? 0; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create(base?: DeepPartial): T; + fromPartial(object: DeepPartial): T; +} diff --git a/src/backend/src/filesystem/hl_operations/definitions.js b/src/backend/src/filesystem/hl_operations/definitions.js new file mode 100644 index 0000000000000000000000000000000000000000..e3cc38b7fa29dcdcffb076599404c63201a72aae --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/definitions.js @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { BaseOperation } = require('../../services/OperationTraceService'); + +class HLFilesystemOperation extends BaseOperation { +} + +module.exports = { + HLFilesystemOperation, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_copy.js b/src/backend/src/filesystem/hl_operations/hl_copy.js new file mode 100644 index 0000000000000000000000000000000000000000..97eeaa10f243fbfb6462b4cd6fc955845179b6e9 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_copy.js @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { chkperm, validate_fsentry_name, get_user, is_ancestor_of } = require('../../helpers'); +const { TYPE_DIRECTORY } = require('../FSNodeContext'); +const { NodePathSelector, RootNodeSelector } = require('../node/selectors'); +const { HLFilesystemOperation } = require('./definitions'); +const { MkTree } = require('./hl_mkdir'); +const { HLRemove } = require('./hl_remove'); +const { LLCopy } = require('../ll_operations/ll_copy'); + +class HLCopy extends HLFilesystemOperation { + static DESCRIPTION = ` + High-level copy operation. + + This operation is a wrapper around the low-level copy operation. + It provides the following features: + - create missing parent directories + - overwrite existing files or directories + - deduplicate files/directories with the same name + `; + + static MODULES = { + _path: require('path'), + }; + + static PARAMETERS = { + source: {}, + destionation_or_parent: {}, + new_name: {}, + + overwrite: {}, + dedupe_name: {}, + + create_missing_parents: {}, + + user: {}, + }; + + async _run () { + const { _path } = this.modules; + + const { values, context } = this; + const svc = context.get('services'); + const fs = svc.get('filesystem'); + + let parent = values.destination_or_parent; + let dest = null; + + const source = values.source; + + if ( values.overwrite && values.dedupe_name ) { + throw APIError.create('overwrite_and_dedupe_exclusive'); + } + + if ( ! await source.exists() ) { + throw APIError.create('source_does_not_exist'); + } + + if ( ! await chkperm(source.entry, values.user.id, 'cp') ) { + throw APIError.create('forbidden'); + } + + if ( await parent.get('is-root') ) { + throw APIError.create('cannot_copy_to_root'); + } + + // If parent exists and is a file, and a new name wasn't + // specified, the intention must be to overwrite the file. + if ( + !values.new_name && + await parent.exists() && + await parent.get('type') !== TYPE_DIRECTORY + ) { + dest = parent; + parent = await dest.getParent(); + await parent.fetchEntry(); + } + + // If parent is not found either throw an error or create + // the parent directory as specified by parameters. + if ( ! await parent.exists() ) { + if ( ! (parent.selector instanceof NodePathSelector) ) { + throw APIError.create('dest_does_not_exist', null, { + parent: parent.selector, + }); + } + const path = parent.selector.value; + const tree_op = new MkTree(); + await tree_op.run({ + parent: await fs.node(new RootNodeSelector()), + tree: [path], + }); + await parent.fetchEntry({ force: true }); + } + + if ( + await parent.get('type') !== TYPE_DIRECTORY + ) { + throw APIError.create('dest_is_not_a_directory'); + } + + if ( ! await chkperm(parent.entry, values.user.id, 'write') ) { + throw APIError.create('forbidden'); + } + + let target_name = values.new_name ?? await source.get('name'); + + try { + validate_fsentry_name(target_name); + } catch (e) { + throw APIError.create(400, e); + } + + // NEXT: implement _verify_room with profiling + const tracer = svc.get('traceService').tracer; + await tracer.startActiveSpan('fs:cp:verify-size-constraints', async span => { + const source_file = source.entry; + const dest_fsentry = parent.entry; + + let source_user = await get_user({ id: source_file.user_id }); + let dest_user = source_user.id !== dest_fsentry.user_id + ? await get_user({ id: dest_fsentry.user_id }) + : source_user ; + const sizeService = svc.get('sizeService'); + let deset_usage = await sizeService.get_usage(dest_user.id); + + const size = await source.fetchSize(); + const capacity = await sizeService.get_storage_capacity(dest_user.id); + if ( capacity - deset_usage - size < 0 ) { + throw APIError.create('storage_limit_reached'); + } + span.end(); + }); + + if ( dest === null ) { + dest = await parent.getChild(target_name); + } + + // Ensure copy operation is legal + // TODO: maybe this is better in the low-level operation + if ( await source.get('uid') == await parent.get('uid') ) { + throw APIError.create('source_and_dest_are_the_same'); + } + + if ( await is_ancestor_of(source.uid, parent.uid) ) { + throw APIError.create('cannot_copy_item_into_itself'); + } + + let overwritten; + if ( await dest.exists() ) { + // condition: no overwrite behaviour specified + if ( !values.overwrite && !values.dedupe_name ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: dest.entry.name, + }); + } + + if ( values.dedupe_name ) { + const target_ext = _path.extname(target_name); + const target_noext = _path.basename(target_name, target_ext); + for ( let i = 1 ;; i++ ) { + const try_new_name = `${target_noext} (${i})${target_ext}`; + const exists = await parent.hasChild(try_new_name); + if ( ! exists ) { + target_name = try_new_name; + break; + } + } + + dest = await parent.getChild(target_name); + } + else if ( values.overwrite ) { + if ( ! await chkperm(dest.entry, values.user.id, 'rm') ) { + throw APIError.create('forbidden'); + } + + // TODO: This will be LLRemove + // TODO: what to do with parent_operation? + overwritten = await dest.getSafeEntry(); + const hl_remove = new HLRemove(); + await hl_remove.run({ + target: dest, + user: values.user, + recursive: true, + }); + } + } + + const ll_copy = new LLCopy(); + this.copied = await ll_copy.run({ + source, + parent, + user: values.user, + target_name, + }); + + await this.copied.awaitStableEntry(); + const response = await this.copied.getSafeEntry({ thumbnail: true }); + return { + copied: response, + overwritten, + }; + } +} + +module.exports = { + HLCopy, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_data_read.js b/src/backend/src/filesystem/hl_operations/hl_data_read.js new file mode 100644 index 0000000000000000000000000000000000000000..eead3ac41cd067a30aac163f41746d8b9a8fc344 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_data_read.js @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { HLFilesystemOperation } = require('./definitions'); +const { chkperm } = require('../../helpers'); +const { LLRead } = require('../ll_operations/ll_read'); +const APIError = require('../../api/APIError'); + +/** + * HLDataRead reads a stream of objects from a file containing structured data. + * For .jsonl files, the stream will product multiple objects. + * For .json files, the stream will produce a single object. + */ +class HLDataRead extends HLFilesystemOperation { + static MODULES = { + 'stream': require('stream'), + }; + + async _run () { + const { context } = this; + + // We get the user from context so that an elevated system context + // can read files under the system user. + const user = await context.get('user'); + + const { + fsNode, + version_id, + } = this.values; + + if ( ! await fsNode.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + if ( ! await chkperm(fsNode.entry, user.id, 'read') ) { + throw APIError.create('forbidden'); + } + + const ll_read = new LLRead(); + let stream = await ll_read.run({ + fsNode, + user, + version_id, + }); + + stream = this._stream_bytes_to_lines(stream); + stream = this._stream_jsonl_lines_to_objects(stream); + + return stream; + } + + _stream_bytes_to_lines (stream) { + const readline = require('readline'); + const rl = readline.createInterface({ + input: stream, + terminal: false, + }); + + const { PassThrough } = this.modules.stream; + + const output_stream = new PassThrough(); + + rl.on('line', (line) => { + output_stream.write(line); + }); + rl.on('close', () => { + output_stream.end(); + }); + + return output_stream; + } + + _stream_jsonl_lines_to_objects (stream) { + const { PassThrough } = this.modules.stream; + const output_stream = new PassThrough(); + (async () => { + for await ( const line of stream ) { + output_stream.write(JSON.parse(line)); + } + output_stream.end(); + })(); + return output_stream; + } +} + +module.exports = { + HLDataRead, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_mkdir.js b/src/backend/src/filesystem/hl_operations/hl_mkdir.js new file mode 100644 index 0000000000000000000000000000000000000000..5c008809dc0a07024933d882b384e390c18b02aa --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_mkdir.js @@ -0,0 +1,516 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { chkperm } = require('../../helpers'); + +const { RootNodeSelector, NodeChildSelector, NodePathSelector } = require('../node/selectors'); +const APIError = require('../../api/APIError'); + +const FSNodeParam = require('../../api/filesystem/FSNodeParam'); +const StringParam = require('../../api/filesystem/StringParam'); +const FlagParam = require('../../api/filesystem/FlagParam'); +const UserParam = require('../../api/filesystem/UserParam'); +const FSNodeContext = require('../FSNodeContext'); +const { OtelFeature } = require('../../traits/OtelFeature'); +const { HLFilesystemOperation } = require('./definitions'); +const { is_valid_path } = require('../validation'); +const { HLRemove } = require('./hl_remove'); +const { LLMkdir } = require('../ll_operations/ll_mkdir'); + +class MkTree extends HLFilesystemOperation { + static DESCRIPTION = ` + High-level operation for making directory trees + + The following input for 'tree': + ['a/b/c', ['i/j/k'], ['p', ['q'], ['r/s']]]] + + Would create a directory tree like this: + a + └── b + └── c + ├── i + │ └── j + │ └── k + └── p + ├── q + └── r + └── s + `; + + static PARAMETERS = { + parent: new FSNodeParam('parent', { optional: true }), + }; + + static PROPERTIES = { + leaves: () => [], + directories_created: () => [], + }; + + async _run () { + const { values, context } = this; + const fs = context.get('services').get('filesystem'); + + await this.create_branch_({ + parent_node: values.parent || await fs.node(new RootNodeSelector()), + tree: values.tree, + parent_exists: true, + }); + } + + async create_branch_ ({ parent_node, tree, parent_exists }) { + const { context } = this; + const fs = context.get('services').get('filesystem'); + const actor = context.get('actor'); + + const trunk = tree[0]; + const branches = tree.slice(1); + + let current = parent_node.selector; + + // trunk = a/b/c + + const dirs = trunk === '.' ? [] + : trunk.split('/').filter(Boolean); + + // dirs = [a, b, c] + + let parent_did_exist = parent_exists; + + // This is just a loop that goes through each part of the path + // until it finds the first directory that doesn't exist yet. + let i = 0; + if ( parent_exists ) { + for ( ; i < dirs.length ; i++ ) { + const dir = dirs[i]; + const currentParent = current; + current = new NodeChildSelector(current, dir); + + const maybe_dir = await fs.node(current); + + if ( maybe_dir.isRoot ) continue; + if ( await maybe_dir.isUserDirectory() ) continue; + + if ( await maybe_dir.exists() ) { + + if ( await maybe_dir.get('type') !== FSNodeContext.TYPE_DIRECTORY ) { + throw APIError.create('dest_is_not_a_directory'); + } + + continue; + } + + current = currentParent; + parent_exists = false; + break; + } + } + + if ( parent_did_exist && !parent_exists ) { + const node = await fs.node(current); + const has_perm = await chkperm(await node.get('entry'), actor.type.user.id, 'write'); + if ( ! has_perm ) throw APIError.create('permission_denied'); + } + + // This next loop creates the new directories + + // We break into a second loop because we know none of these directories + // exist yet. If we continued those checks each child operation would + // wait for the previous one to complete because FSNodeContext::fetchEntry + // will notice ResourceService has a lock on the previous operation + // we started. + + // In this way it goes nyyyoooom because all the database inserts + // happen concurrently (and probably end up in the same batch). + + for ( ; i < dirs.length ; i++ ) { + const dir = dirs[i]; + const currentParent = current; + current = new NodeChildSelector(current, dir); + + const ll_mkdir = new LLMkdir(); + const node = await ll_mkdir.run({ + parent: await fs.node(currentParent), + name: current.name, + actor, + }); + + current = node.selector; + + this.directories_created.push(node); + } + + const bottom_parent = await fs.node(current); + + if ( branches.length === 0 ) { + this.leaves.push(bottom_parent); + } + + for ( const branch of branches ) { + await this.create_branch_({ + parent_node: bottom_parent, + tree: branch, + parent_exists, + }); + } + } +} + +class QuickMkdir extends HLFilesystemOperation { + async _run () { + const { context, values } = this; + let { parent, path } = values; + const { _path } = this.modules; + const fs = context.get('services').get('filesystem'); + const actor = context.get('actor'); + + parent = parent || await fs.node(new RootNodeSelector()); + + let current = parent.selector; + + const dirs = path === '.' ? [] + : path.split('/').filter(Boolean); + + const api = require('@opentelemetry/api'); + const currentSpan = api.trace.getSpan(api.context.active()); + if ( currentSpan ) { + currentSpan.setAttribute('path', path); + currentSpan.setAttribute('dirs', dirs.join('/')); + currentSpan.setAttribute('parent', parent.selector.describe()); + } + + for ( let i = 0 ; i < dirs.length ; i++ ) { + const dir = dirs[i]; + const currentParent = current; + current = new NodeChildSelector(current, dir); + + const ll_mkdir = new LLMkdir(); + const node = await ll_mkdir.run({ + parent: await fs.node(currentParent), + name: current.name, + actor, + }); + + current = node.selector; + + // this.directories_created.push(node); + } + + this.created = await fs.node(current); + } +} + +class HLMkdir extends HLFilesystemOperation { + static DESCRIPTION = ` + High-level mkdir operation. + + This operation is a wrapper around the low-level mkdir operation. + It provides the following features: + - create missing parent directories + - overwrite existing files + - dedupe names + - create shortcuts + `; + + static PARAMETERS = { + parent: new FSNodeParam('parent', { optional: true }), + path: new StringParam('path'), + overwrite: new FlagParam('overwrite', { optional: true }), + create_missing_parents: new FlagParam('create_missing_parents', { optional: true }), + user: new UserParam(), + + shortcut_to: new FSNodeParam('shortcut_to', { optional: true }), + }; + + static MODULES = { + _path: require('path'), + }; + + static PROPERTIES = { + parent_directories_created: () => [], + }; + + static FEATURES = [ + new OtelFeature([ + '_get_existing_parent', + '_create_parents', + ]), + ]; + + async _run () { + const { context, values } = this; + const { _path } = this.modules; + const fs = context.get('services').get('filesystem'); + + if ( ! is_valid_path(values.path, { + no_relative_components: true, + allow_path_fragment: true, + }) ) { + throw APIError.create('field_invalid', null, { + key: 'path', + expected: 'valid path', + got: 'invalid path', + }); + } + + // Unify the following formats: + // - full path: {"path":"/foo/bar", args...}, used by apitest (./tools/api-tester/apitest.js) + // - parent + path: {"parent": "/foo", "path":"bar", args...}, used by puter-js (puter.fs.mkdir("/foo/bar")) + if ( !values.parent && values.path ) { + values.parent = await fs.node(new NodePathSelector(_path.dirname(values.path))); + values.path = _path.basename(values.path); + } + + let parent_node = values.parent || await fs.node(new RootNodeSelector()); + + let target_basename = _path.basename(values.path); + + // "top_parent" is the immediate parent of the target directory + // (e.g: /home/foo/bar -> /home/foo) + const top_parent = values.create_missing_parents + ? await this._create_dir(parent_node) + : await this._get_existing_top_parent({ top_parent: parent_node }) + ; + + // TODO: this can be removed upon completion of: https://github.com/HeyPuter/puter/issues/1352 + if ( top_parent.isRoot ) { + // root directory is read-only + throw APIError.create('forbidden', null, { + message: 'Cannot create directories in the root directory.', + }); + } + + // `parent_node` becomes the parent of the last directory name + // specified under `path`. + parent_node = await this._create_parents({ + parent_node: top_parent, + actor: values.actor, + }); + + const user_id = values.actor.type.user.id; + + const has_perm = await chkperm(await parent_node.get('entry'), user_id, 'write'); + if ( ! has_perm ) throw APIError.create('permission_denied'); + + const existing = await fs.node(new NodeChildSelector(parent_node.selector, target_basename)); + + await existing.fetchEntry(); + + if ( existing.found ) { + const { overwrite, dedupe_name, create_missing_parents } = values; + if ( overwrite ) { + // TODO: tag rm operation somehow + const has_perm = await chkperm(await existing.get('entry'), user_id, 'write'); + if ( ! has_perm ) throw APIError.create('permission_denied'); + const hl_remove = new HLRemove(); + await hl_remove.run({ + target: existing, + actor: values.actor, + recursive: true, + }); + } + else if ( dedupe_name ) { + const fs = context.get('services').get('filesystem'); + const parent_selector = parent_node.selector; + for ( let i = 1 ;; i++ ) { + let try_new_name = `${target_basename} (${i})`; + const selector = new NodeChildSelector(parent_selector, try_new_name); + const exists = await parent_node.provider.quick_check({ + selector, + }); + if ( ! exists ) { + target_basename = try_new_name; + break; + } + } + } + else if ( create_missing_parents ) { + if ( ! existing.entry.is_dir ) { + throw APIError.create('dest_is_not_a_directory'); + } + this.created = existing; + this.used_existing = true; + return await this.created.getSafeEntry(); + } else { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: target_basename, + }); + } + } + + if ( values.shortcut_to ) { + const shortcut_to = values.shortcut_to; + if ( ! await shortcut_to.exists() ) { + throw APIError.create('shortcut_to_does_not_exist'); + } + if ( ! shortcut_to.entry.is_dir ) { + throw APIError.create('shortcut_target_is_a_directory'); + } + const has_perm = await chkperm(shortcut_to.entry, user_id, 'read'); + if ( ! has_perm ) throw APIError.create('forbidden'); + + this.created = await fs.mkshortcut({ + parent: parent_node, + name: target_basename, + actor: values.actor, + target: shortcut_to, + }); + + await this.created.awaitStableEntry(); + return await this.created.getSafeEntry(); + } + + const ll_mkdir = new LLMkdir(); + this.created = await ll_mkdir.run({ + parent: parent_node, + name: target_basename, + actor: values.actor, + }); + + const all_nodes = [ + ...this.parent_directories_created, + this.created, + ]; + + await Promise.all(all_nodes.map(node => node.awaitStableEntry())); + + const response = await this.created.getSafeEntry(); + response.parent_dirs_created = []; + for ( const node of this.parent_directories_created ) { + response.parent_dirs_created.push(await node.getSafeEntry()); + } + response.requested_path = values.path; + + return response; + } + + async _create_parents ({ parent_node }) { + const { context, values } = this; + const { _path } = this.modules; + + const fs = context.get('services').get('filesystem'); + + // Determine the deepest existing node + let deepest_existing = parent_node; + let remaining_path = _path.dirname(values.path).split('/').filter(Boolean); + { + const parts = remaining_path.slice(); + for ( ;; ) { + if ( remaining_path.length === 0 ) { + return deepest_existing; + } + const component = remaining_path[0]; + const next_selector = new NodeChildSelector(deepest_existing.selector, component); + const next_node = await fs.node(next_selector); + if ( ! await next_node.exists() ) { + break; + } + deepest_existing = next_node; + remaining_path.shift(); + } + } + + const tree_op = new MkTree(); + await tree_op.run({ + parent: deepest_existing, + tree: [remaining_path.join('/')], + }); + + this.parent_directories_created = tree_op.directories_created; + + return tree_op.leaves[0]; + } + + async _get_existing_parent ({ parent_node }) { + const { context, values } = this; + const { _path } = this.modules; + const fs = context.get('services').get('filesystem'); + + const target_dirname = _path.dirname(values.path); + const dirs = target_dirname === '.' ? [] + : target_dirname.split('/').filter(Boolean); + + let current = parent_node.selector; + for ( let i = 0 ; i < dirs.length ; i++ ) { + current = new NodeChildSelector(current, dirs[i]); + } + + const node = await fs.node(current); + + if ( ! await node.exists() ) { + throw APIError.create('dest_does_not_exist'); + } + + if ( ! node.entry.is_dir ) { + throw APIError.create('dest_is_not_a_directory'); + } + + return node; + } + + /** + * Creates a directory and all its ancestors. + * + * @param {FSNodeContext} dir - The directory to create. + * @returns {Promise} The created directory. + */ + async _create_dir (dir) { + if ( await dir.exists() ) { + if ( ! dir.entry.is_dir ) { + throw APIError.create('dest_is_not_a_directory'); + } + return dir; + } + + const maybe_path_selector = + dir.get_selector_of_type(NodePathSelector); + + if ( ! maybe_path_selector ) { + throw APIError.create('dest_does_not_exist'); + } + + const path = maybe_path_selector.value; + + const fs = this.context.get('services').get('filesystem'); + + const tree_op = new MkTree(); + await tree_op.run({ + parent: await fs.node(new RootNodeSelector()), + tree: [path], + }); + + return tree_op.leaves[0]; + } + + async _get_existing_top_parent ({ top_parent }) { + if ( ! await top_parent.exists() ) { + throw APIError.create('dest_does_not_exist'); + } + + if ( ! top_parent.entry.is_dir ) { + throw APIError.create('dest_is_not_a_directory'); + } + + return top_parent; + } +} + +module.exports = { + QuickMkdir, + HLMkdir, + MkTree, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_mklink.js b/src/backend/src/filesystem/hl_operations/hl_mklink.js new file mode 100644 index 0000000000000000000000000000000000000000..d8573ece80ecca772e966b055bd6a9f424e52605 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_mklink.js @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const FSNodeParam = require('../../api/filesystem/FSNodeParam'); +const StringParam = require('../../api/filesystem/StringParam'); +const { HLFilesystemOperation } = require('./definitions'); +const APIError = require('../../api/APIError'); +const { TYPE_DIRECTORY } = require('../FSNodeContext'); + +class HLMkLink extends HLFilesystemOperation { + static PARAMETERS = { + parent: new FSNodeParam('symlink'), + name: new StringParam('name'), + target: new StringParam('target'), + }; + + static MODULES = { + path: require('node:path'), + }; + + async _run () { + const { context, values } = this; + const fs = context.get('services').get('filesystem'); + + const { target, parent, user } = values; + let { name } = values; + + if ( ! name ) { + throw APIError.create('field_empty', null, { key: 'name' }); + } + + if ( ! await parent.exists() ) { + throw APIError.create('dest_does_not_exist'); + } + + if ( await parent.get('type') !== TYPE_DIRECTORY ) { + throw APIError.create('dest_is_not_a_directory'); + } + + { + const dest = await parent.getChild(name); + if ( await dest.exists() ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: name, + }); + } + } + + const created = await fs.mklink({ + target, + parent, + name, + user, + }); + + await created.awaitStableEntry(); + return await created.getSafeEntry(); + } +} + +module.exports = { + HLMkLink, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_mkshortcut.js b/src/backend/src/filesystem/hl_operations/hl_mkshortcut.js new file mode 100644 index 0000000000000000000000000000000000000000..13393087174aa9a52b1ebaf4b5af6ee24247018d --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_mkshortcut.js @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const FSNodeParam = require('../../api/filesystem/FSNodeParam'); +const FlagParam = require('../../api/filesystem/FlagParam'); +const StringParam = require('../../api/filesystem/StringParam'); +const { TYPE_DIRECTORY } = require('../FSNodeContext'); +const { HLFilesystemOperation } = require('./definitions'); + +class HLMkShortcut extends HLFilesystemOperation { + static PARAMETERS = { + parent: new FSNodeParam('shortcut'), + name: new StringParam('name'), + target: new FSNodeParam('target'), + + dedupe_name: new FlagParam('dedupe_name', { optional: true }), + }; + + static MODULES = { + path: require('node:path'), + }; + + async _run () { + const { context, values } = this; + const fs = context.get('services').get('filesystem'); + + const { target, parent, user, actor } = values; + let { name, dedupe_name } = values; + + if ( ! await target.exists() ) { + throw APIError.create('shortcut_to_does_not_exist'); + } + + if ( ! name ) { + dedupe_name = true; + name = `Shortcut to ${ await target.get('name')}`; + } + + { + const svc_acl = context.get('services').get('acl'); + if ( ! await svc_acl.check(actor, target, 'read') ) { + throw await svc_acl.get_safe_acl_error(actor, target, 'read'); + } + } + + if ( ! await parent.exists() ) { + throw APIError.create('dest_does_not_exist'); + } + + if ( await parent.get('type') !== TYPE_DIRECTORY ) { + throw APIError.create('dest_is_not_a_directory'); + } + + { + const dest = await parent.getChild(name); + if ( await dest.exists() ) { + if ( ! dedupe_name ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: name, + }); + } + + const name_ext = this.modules.path.extname(name); + const name_noext = this.modules.path.basename(name, name_ext); + for ( let i = 1 ;; i++ ) { + const try_new_name = `${name_noext} (${i})${name_ext}`; + const try_dest = await parent.getChild(try_new_name); + if ( ! await try_dest.exists() ) { + name = try_new_name; + break; + } + } + } + } + + const created = await fs.mkshortcut({ + target, + parent, + name, + user, + }); + + await created.awaitStableEntry(); + return await created.getSafeEntry(); + } +} + +module.exports = { + HLMkShortcut, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_move.js b/src/backend/src/filesystem/hl_operations/hl_move.js new file mode 100644 index 0000000000000000000000000000000000000000..5ea948915cebe34553d360a936996170c4ea6051 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_move.js @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { chkperm, validate_fsentry_name, is_ancestor_of, df, get_user } = require('../../helpers'); +const { LLMove } = require('../ll_operations/ll_move'); +const { RootNodeSelector } = require('../node/selectors'); +const { HLFilesystemOperation } = require('./definitions'); +const { MkTree } = require('./hl_mkdir'); +const { HLRemove } = require('./hl_remove'); +const { TYPE_DIRECTORY } = require('../FSNodeContext'); + +class HLMove extends HLFilesystemOperation { + static MODULES = { + _path: require('path'), + }; + + static PROPERTIES = { + parent_directories_created: () => [], + }; + + async _run () { + const { _path } = this.modules; + + const { context, values } = this; + const svc = context.get('services'); + const fs = svc.get('filesystem'); + + const new_metadata = typeof values.new_metadata === 'string' + ? values.new_metadata : JSON.stringify(values.new_metadata); + + // !! new_name, create_missing_parents, overwrite, dedupe_name + + let parent = values.destination_or_parent; + let dest = null; + const source = values.source; + + if ( await source.get('is-root') ) { + throw APIError.create('immutable'); + } + if ( await parent.get('is-root') ) { + throw APIError.create('cannot_copy_to_root'); + } + + if ( ! await source.exists() ) { + throw APIError.create('source_does_not_exist'); + } + + if ( ! await chkperm(source.entry, values.user.id, 'cp') ) { + throw APIError.create('forbidden'); + } + + if ( source.entry.immutable ) { + throw APIError.create('immutable'); + } + + // If the "parent" is a file, then it's actually our destination; not the parent. + if ( !values.new_name && await parent.exists() && await parent.get('type') !== TYPE_DIRECTORY ) { + dest = parent; + parent = await dest.getParent(); + } + + if ( ! await parent.exists() ) { + if ( !parent.path || !values.create_missing_parents ) { + throw APIError.create('dest_does_not_exist'); + } + + const tree_op = new MkTree(); + await tree_op.run({ + parent: await fs.node(new RootNodeSelector()), + tree: [parent.path], + }); + + this.parent_directories_created = tree_op.directories_created; + + parent = tree_op.leaves[0]; + } + + await parent.fetchEntry(); + if ( ! await chkperm(parent.entry, values.user.id, 'write') ) { + throw APIError.create('forbidden'); + } + if ( await parent.get('type') !== TYPE_DIRECTORY ) { + throw APIError.create('dest_is_not_a_directory'); + } + + let source_user, dest_user; + + // 3. Verify cross-user size constraints + const src_user_id = await source.get('user_id'); + const parent_user_id = await parent.get('user_id'); + if ( src_user_id !== parent_user_id ) { + source_user = await get_user({ id: src_user_id }); + if ( source_user.id !== parent_user_id ) + { + dest_user = await get_user({ id: parent_user_id }); + } + else + { + dest_user = source_user; + } + await source.fetchSize(); + const item_size = source.entry.size; + const sizeService = svc.get('sizeService'); + const capacity = await sizeService.get_storage_capacity(dest_user.id); + if ( capacity - await df(dest_user.id) - item_size < 0 ) { + throw APIError.create('storage_limit_reached'); + } + } + + let target_name = values.new_name ?? await source.get('name'); + const metadata = new_metadata ?? await source.get('metadata'); + + try { + validate_fsentry_name(target_name); + } catch (e) { + throw APIError.create(400, e); + } + + if ( dest === null ) { + dest = await parent.getChild(target_name); + } + + const src_uid = await source.get('uid'); + // const dst_uid = await dest.get('uid'); + const par_uid = await parent.get('uid'); + + if ( src_uid === par_uid ) { + throw APIError.create('source_and_dest_are_the_same'); + } + if ( await is_ancestor_of(src_uid, par_uid) ) { + throw APIError('cannot_move_item_into_itself'); + } + + let overwritten; + if ( await dest.exists() ) { + if ( !values.overwrite && !values.dedupe_name ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: await dest.get('name'), + }); + } + + if ( values.dedupe_name ) { + const target_ext = _path.extname(target_name); + const target_noext = _path.basename(target_name, target_ext); + for ( let i = 1 ;; i++ ) { + const try_new_name = `${target_noext} (${i})${target_ext}`; + const exists = await parent.hasChild(try_new_name); + if ( ! exists ) { + target_name = try_new_name; + break; + } + } + + dest = await parent.getChild(target_name); + } + else if ( values.overwrite ) { + overwritten = await dest.getSafeEntry(); + const hl_remove = new HLRemove(); + await hl_remove.run({ + target: dest, + user: values.user, + }); + } + else { + throw new Error('unreachable'); + } + } + + const old_path = await source.get('path'); + + const ll_move = new LLMove(); + const source_new = await ll_move.run({ + source, + parent, + target_name, + user: values.user, + metadata: metadata, + }); + + await source_new.awaitStableEntry(); + await source_new.fetchSuggestedApps(); + await source_new.fetchOwner(); + + const response = { + moved: await source_new.getSafeEntry({ thumbnail: true }), + overwritten, + old_path, + }; + + response.parent_dirs_created = []; + for ( const node of this.parent_directories_created ) { + response.parent_dirs_created.push(await node.getSafeEntry()); + } + + return response; + } +} + +module.exports = { + HLMove, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_name_search.js b/src/backend/src/filesystem/hl_operations/hl_name_search.js new file mode 100644 index 0000000000000000000000000000000000000000..ded90923154160d44ed4ebdd38c15b5ab25d45f1 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_name_search.js @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { DB_READ } = require('../../services/database/consts'); +const { Context } = require('../../util/context'); +const { NodeUIDSelector } = require('../node/selectors'); +const { HLFilesystemOperation } = require('./definitions'); + +class HLNameSearch extends HLFilesystemOperation { + async _run () { + let { actor, term } = this.values; + const services = Context.get('services'); + const svc_fs = services.get('filesystem'); + const db = services.get('database') + .get(DB_READ, 'fs.namesearch'); + + term = term.replace(/%/g, ''); + term = `%${ term }%`; + + // Only user actors can do this, because the permission + // system would otherwise slow things down + if ( ! actor.type.user ) return []; + + const results = await db.read('SELECT uuid FROM fsentries WHERE name LIKE ? AND ' + + 'user_id = ? LIMIT 50', + [term, actor.type.user.id]); + + const uuids = results.map(v => v.uuid); + + const fsnodes = await Promise.all(uuids.map(async uuid => { + return await svc_fs.node(new NodeUIDSelector(uuid)); + })); + + return Promise.all(fsnodes.map(async fsnode => { + return await fsnode.getSafeEntry(); + })); + } +} + +module.exports = { + HLNameSearch, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_read.js b/src/backend/src/filesystem/hl_operations/hl_read.js new file mode 100644 index 0000000000000000000000000000000000000000..2185bfe0302717c7dc21abc8d61de2cfd580c833 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_read.js @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { LLRead } = require('../ll_operations/ll_read'); +const { HLFilesystemOperation } = require('./definitions'); + +class HLRead extends HLFilesystemOperation { + static CONCERN = 'filesystem'; + static MODULES = { + 'stream': require('stream'), + }; + + async _run () { + const { + fsNode, actor, + line_count, byte_count, + offset, + version_id, range, + } = this.values; + + if ( ! await fsNode.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + const ll_read = new LLRead(); + let stream = await ll_read.run({ + fsNode, + actor, + version_id, + range, + ...(byte_count !== undefined ? { + offset: offset ?? 0, + length: byte_count, + } : {}), + }); + + if ( line_count !== undefined ) { + stream = this._wrap_stream_line_count(stream, line_count); + } + + return stream; + } + + /** + * returns a new stream that will only produce the first `line_count` lines + * @param {*} stream - input stream + * @param {*} line_count - number of lines to produce + */ + _wrap_stream_line_count (stream, line_count) { + const readline = require('readline'); + const rl = readline.createInterface({ + input: stream, + terminal: false, + }); + + const { PassThrough } = this.modules.stream; + + const output_stream = new PassThrough(); + + let lines_read = 0; + new Promise((resolve, reject) => { + rl.on('line', (line) => { + if ( lines_read++ >= line_count ) { + return rl.close(); + } + + output_stream.write(lines_read > 1 ? `\r\n${ line}` : line); + }); + rl.on('error', () => { + console.log('error'); + }); + rl.on('close', function () { + resolve(); + }); + }); + + return output_stream; + } +} + +module.exports = { + HLRead, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_readdir.js b/src/backend/src/filesystem/hl_operations/hl_readdir.js new file mode 100644 index 0000000000000000000000000000000000000000..5a2fd9bed6721f46827b4f8e5e89e19ed7dd4ac5 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_readdir.js @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { Context } = require('../../util/context'); +const { stream_to_buffer } = require('../../util/streamutil'); +const { ECMAP } = require('../ECMAP'); +const { TYPE_DIRECTORY, TYPE_SYMLINK } = require('../FSNodeContext'); +const { LLListUsers } = require('../ll_operations/ll_listusers'); +const { LLReadDir } = require('../ll_operations/ll_readdir'); +const { LLReadShares } = require('../ll_operations/ll_readshares'); +const { HLFilesystemOperation } = require('./definitions'); + +class HLReadDir extends HLFilesystemOperation { + static CONCERN = 'filesystem'; + async _run () { + return ECMAP.arun(async () => { + const ecmap = Context.get(ECMAP.SYMBOL); + ecmap.store_fsNodeContext(this.values.subject); + return await this.__run(); + }); + } + async __run () { + const { subject: subject_let, user, no_thumbs, no_assocs, actor } = this.values; + let subject = subject_let; + + if ( ! await subject.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + if ( await subject.get('type') === TYPE_SYMLINK ) { + const { context } = this; + const svc_acl = context.get('services').get('acl'); + if ( ! await svc_acl.check(actor, subject, 'read') ) { + throw await svc_acl.get_safe_acl_error(actor, subject, 'read'); + } + const target = await subject.getTarget(); + subject = target; + } + + if ( await subject.get('type') !== TYPE_DIRECTORY ) { + const { context } = this; + const svc_acl = context.get('services').get('acl'); + if ( ! await svc_acl.check(actor, subject, 'see') ) { + throw await svc_acl.get_safe_acl_error(actor, subject, 'see'); + } + throw APIError.create('readdir_of_non_directory'); + } + + let children; + + this.log.debug('READDIR', + { + userdir: await subject.isUserDirectory(), + namediff: await subject.get('name') !== user.username, + }); + if ( subject.isRoot ) { + const ll_listusers = new LLListUsers(); + children = await ll_listusers.run(this.values); + } else if ( + await subject.getUserPart() !== user.username && + await subject.isUserDirectory() + ) { + const ll_readshares = new LLReadShares(); + children = await ll_readshares.run(this.values); + } else { + const ll_readdir = new LLReadDir(); + children = await ll_readdir.run(this.values); + } + + return Promise.all(children.map(async child => { + // When thumbnails are requested, fetching before the call to + // .getSafeEntry prevents .fetchEntry (possibly called by + // .fetchSuggestedApps or .fetchSubdomains) + if ( ! no_thumbs ) { + await child.fetchEntry({ thumbnail: true }); + } + + if ( ! no_assocs ) { + await Promise.all([ + child.fetchSuggestedApps(user), + child.fetchSubdomains(user), + ]); + } + const entry = await child.getSafeEntry(); + if ( !no_thumbs && entry.associated_app ) { + const svc_appIcon = this.context.get('services').get('app-icon'); + const icon_result = await svc_appIcon.get_icon_stream({ + app_icon: entry.associated_app.icon, + app_uid: entry.associated_app.uid ?? entry.associated_app.uuid, + size: 64, + }); + + if ( icon_result.data_url ) { + entry.associated_app.icon = icon_result.data_url; + } else { + try { + const buffer = await stream_to_buffer(icon_result.stream); + const resp_data_url = `data:${icon_result.mime};base64,${buffer.toString('base64')}`; + entry.associated_app.icon = resp_data_url; + } catch (e) { + const svc_error = this.context.get('services').get('error-service'); + svc_error.report('hl_readdir:icon-stream', { + source: e, + }); + } + } + } + return entry; + })); + } +} + +module.exports = { + HLReadDir, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_remove.js b/src/backend/src/filesystem/hl_operations/hl_remove.js new file mode 100644 index 0000000000000000000000000000000000000000..d915ca7c09a0ea7e7323a3a6b21fda3927308ffc --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_remove.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { chkperm } = require('../../helpers'); +const { TYPE_DIRECTORY } = require('../FSNodeContext'); +const { LLRmDir } = require('../ll_operations/ll_rmdir'); +const { LLRmNode } = require('../ll_operations/ll_rmnode'); +const { HLFilesystemOperation } = require('./definitions'); + +class HLRemove extends HLFilesystemOperation { + static PARAMETERS = { + target: {}, + user: {}, + recursive: {}, + descendants_only: {}, + }; + + async _run () { + const { target, user } = this.values; + + if ( ! await target.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + if ( ! chkperm(target.entry, user.id, 'rm') ) { + throw APIError.create('forbidden'); + } + + if ( await target.get('type') === TYPE_DIRECTORY ) { + const ll_rmdir = new LLRmDir(); + return await ll_rmdir.run(this.values); + } + + const ll_rmnode = new LLRmNode(); + return await ll_rmnode.run(this.values); + } +} + +module.exports = { + HLRemove, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_stat.js b/src/backend/src/filesystem/hl_operations/hl_stat.js new file mode 100644 index 0000000000000000000000000000000000000000..f9ac0f466b847203a52ff9622fb5538135588503 --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_stat.js @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { Context } = require('../../util/context'); +const { HLFilesystemOperation } = require('./definitions'); +const APIError = require('../../api/APIError'); +const { ECMAP } = require('../ECMAP'); +const { NodeUIDSelector } = require('../node/selectors'); + +class HLStat extends HLFilesystemOperation { + static MODULES = { + ['mime-types']: require('mime-types'), + }; + + async _run () { + return await ECMAP.arun(async () => { + const ecmap = Context.get(ECMAP.SYMBOL); + ecmap.store_fsNodeContext(this.values.subject); + return await this.__run(); + }); + } + // async _run () { + // return await this.__run(); + // } + async __run () { + const { + subject, user, + return_subdomains, + return_permissions, // Deprecated: kept for backwards compatiable with `return_shares` + return_shares, + return_versions, + return_size, + } = this.values; + + const maybe_uid_selector = subject.get_selector_of_type(NodeUIDSelector); + + // users created before 2025-07-30 might have fsentries with NULL paths. + // we can remove this check once that is fixed. + const user_unix_ts = Number((`${Date.parse(Context.get('actor')?.type?.user?.timestamp)}`).slice(0, -3)); + const paths_are_fine = user_unix_ts >= 1722385593; + + if ( maybe_uid_selector || paths_are_fine ) { + // We are able to fetch the entry and is_empty simultaneously + await Promise.all([ + subject.fetchEntry(), + subject.fetchIsEmpty(), + ]); + } else { + // We need the entry first in order for is_empty to work correctly + await subject.fetchEntry(); + await subject.fetchIsEmpty(); + } + + // file not found + if ( ! subject.found ) throw APIError.create('subject_does_not_exist'); + + await subject.fetchOwner(); + + const context = Context.get(); + const svc_acl = context.get('services').get('acl'); + const actor = context.get('actor'); + if ( ! await svc_acl.check(actor, subject, 'read') ) { + throw await svc_acl.get_safe_acl_error(actor, subject, 'read'); + } + + // TODO: why is this specific to stat? + const mime = this.require('mime-types'); + const contentType = mime.contentType(subject.entry.name); + subject.entry.type = contentType ? contentType : null; + + if ( return_size ) await subject.fetchSize(user); + if ( return_subdomains ) await subject.fetchSubdomains(user); + if ( return_shares || return_permissions ) { + await subject.fetchShares(); + } + if ( return_versions ) await subject.fetchVersions(); + + return await subject.getSafeEntry(); + } +} + +module.exports = { + HLStat, +}; diff --git a/src/backend/src/filesystem/hl_operations/hl_write.js b/src/backend/src/filesystem/hl_operations/hl_write.js new file mode 100644 index 0000000000000000000000000000000000000000..2e5609a7d1b1aff81f023d8e02c5093ed4ed1a4a --- /dev/null +++ b/src/backend/src/filesystem/hl_operations/hl_write.js @@ -0,0 +1,437 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const FSNodeParam = require('../../api/filesystem/FSNodeParam'); +const FlagParam = require('../../api/filesystem/FlagParam'); +const StringParam = require('../../api/filesystem/StringParam'); +const UserParam = require('../../api/filesystem/UserParam'); +const config = require('../../config'); +const { chkperm, validate_fsentry_name } = require('../../helpers'); +const { TeePromise } = require('@heyputer/putility').libs.promise; +const { pausing_tee, offset_write_stream, stream_to_the_void } = require('../../util/streamutil'); +const { TYPE_DIRECTORY } = require('../FSNodeContext'); +const { LLRead } = require('../ll_operations/ll_read'); +const { RootNodeSelector, NodePathSelector } = require('../node/selectors'); +const { is_valid_node_name } = require('../validation'); +const { HLFilesystemOperation } = require('./definitions'); +const { MkTree } = require('./hl_mkdir'); +const { Actor } = require('../../services/auth/Actor'); +const { LLCWrite, LLOWrite } = require('../ll_operations/ll_write'); + +class WriteCommonFeature { + install_in_instance (instance) { + instance._verify_size = async function () { + if ( + this.values.file && + this.values.file.size > config.max_file_size + ) { + throw APIError.create('file_too_large', null, { + max_size: config.max_file_size, + }); + } + + if ( + this.values.thumbnail && + this.values.thumbnail.size > config.max_thumbnail_size + ) { + throw APIError.create('thumbnail_too_large', null, { + max_size: config.max_thumbnail_size, + }); + } + }; + + instance._verify_room = async function () { + if ( ! this.values.file ) return; + + const sizeService = this.context.get('services').get('sizeService'); + const { file, user: user_let } = this.values; + let user = user_let; + + if ( ! user ) user = this.values.actor.type.user; + + const usage = await sizeService.get_usage(user.id); + const capacity = await sizeService.get_storage_capacity(user.id); + if ( capacity - usage - file.size < 0 ) { + throw APIError.create('storage_limit_reached'); + } + }; + } +} + +class HLWrite extends HLFilesystemOperation { + static DESCRIPTION = ` + High-level write operation. + + This operation is a wrapper around the low-level write operation. + It provides the following features: + - create missing parent directories + - overwrite existing files + - deduplicate files with the same name + // - create thumbnails; this will happen in low-level operation for now + - create shortcuts + `; + + static FEATURES = [ + new WriteCommonFeature(), + ]; + + static PARAMETERS = { + // the parent directory, or a filepath that doesn't exist yet + destination_or_parent: new FSNodeParam('path'), + + // if specified, destination_or_parent must be a directory + specified_name: new StringParam('specified_name', { optional: true }), + + // used if specified_name is undefined and destination_or_parent is a directory + // NB: if destination_or_parent does not exist and create_missing_parents + // is true then destination_or_parent will be a directory + fallback_name: new StringParam('fallback_name', { optional: true }), + + overwrite: new FlagParam('overwrite', { optional: true }), + dedupe_name: new FlagParam('dedupe_name', { optional: true }), + + // other options + shortcut_to: new FSNodeParam('shortcut_to', { optional: true }), + create_missing_parents: new FlagParam('create_missing_parents', { optional: true }), + user: new UserParam(), + + // file: multer.File + }; + + static MODULES = { + _path: require('path'), + mime: require('mime-types'), + }; + + async _run () { + const { context, values } = this; + const { _path } = this.modules; + + const fs = context.get('services').get('filesystem'); + const svc_event = context.get('services').get('event'); + + let parent = values.destination_or_parent; + let destination = null; + + await this._verify_size(); + await this._verify_room(); + + this.checkpoint('before parent exists check'); + + if ( !await parent.exists() && values.create_missing_parents ) { + if ( ! (parent.selector instanceof NodePathSelector) ) { + throw APIError.create('dest_does_not_exist', null, { + parent: parent.selector, + }); + } + const path = parent.selector.value; + const tree_op = new MkTree(); + await tree_op.run({ + parent: await fs.node(new RootNodeSelector()), + tree: [path], + }); + + parent = await fs.node(new NodePathSelector(path)); + const parent_exists_now = await parent.exists(); + if ( ! parent_exists_now ) { + this.log.error('FAILED TO CREATE DESTINATION'); + throw APIError.create('dest_does_not_exist', null, { + parent: parent.selector, + }); + } + } + + if ( parent.isRoot ) { + throw APIError.create('cannot_write_to_root'); + } + + let target_name = values.specified_name || values.fallback_name; + + // If a name is specified then the destination must be a directory + if ( values.specified_name ) { + this.checkpoint('specified name condition'); + if ( ! await parent.exists() ) { + throw APIError.create('dest_does_not_exist'); + } + if ( await parent.get('type') !== TYPE_DIRECTORY ) { + throw APIError.create('dest_is_not_a_directory'); + } + target_name = values.specified_name; + } + + this.checkpoint('check parent DNE or is not a directory'); + if ( + !await parent.exists() || + await parent.get('type') !== TYPE_DIRECTORY + ) { + destination = parent; + parent = await destination.getParent(); + target_name = destination.name; + } + + if ( parent.isRoot ) { + throw APIError.create('cannot_write_to_root'); + } + + try { + // old validator is kept here to avoid changing the + // error messages; eventually is_valid_node_name + // will support more detailed error reporting + validate_fsentry_name(target_name); + if ( ! is_valid_node_name(target_name) ) { + throw { message: 'invalid node name' }; + } + } catch (e) { + throw APIError.create('invalid_file_name', null, { + name: target_name, + reason: e.message, + }); + } + + if ( ! destination ) { + destination = await parent.getChild(target_name); + } + + let is_overwrite = false; + + // TODO: Gotta come up with a reasonable guideline for if/when we put + // object members in the scope; it feels too arbitrary right now. + const { overwrite, dedupe_name } = values; + + this.checkpoint('before overwrite behaviours'); + + const dest_exists = await destination.exists(); + + if ( values.offset !== undefined && !dest_exists ) { + throw APIError.create('offset_without_existing_file'); + } + + // The correct ACL check here depends on context. + // ll_write checks ACL, but we need to shortcut it here + // or else we might send the user too much information. + { + const node_to_check = + ( dest_exists && overwrite && !dedupe_name ) + ? destination : parent; + + const actor = values.actor ?? Actor.adapt(values.user); + const svc_acl = context.get('services').get('acl'); + if ( ! await svc_acl.check(actor, node_to_check, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, node_to_check, 'write'); + } + } + + if ( dest_exists ) { + if ( !overwrite && !dedupe_name ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: target_name, + }); + } + + if ( dedupe_name ) { + const target_ext = _path.extname(target_name); + const target_noext = _path.basename(target_name, target_ext); + for ( let i = 1 ;; i++ ) { + const try_new_name = `${target_noext} (${i})${target_ext}`; + const exists = await parent.hasChild(try_new_name); + if ( ! exists ) { + target_name = try_new_name; + break; + } + } + + destination = await parent.getChild(target_name); + } + + else if ( overwrite ) { + if ( await destination.get('immutable') ) { + throw APIError.create('immutable'); + } + if ( await destination.get('type') === TYPE_DIRECTORY ) { + throw APIError.create('cannot_overwrite_a_directory'); + } + is_overwrite = true; + } + } + + if ( values.shortcut_to ) { + this.checkpoint('shortcut condition'); + const shortcut_to = values.shortcut_to; + if ( ! await shortcut_to.exists() ) { + throw APIError.create('shortcut_to_does_not_exist'); + } + if ( await shortcut_to.get('type') === TYPE_DIRECTORY ) { + throw APIError.create('shortcut_target_is_a_directory'); + } + // TODO: legacy check - likely not needed + const has_perm = await chkperm(shortcut_to.entry, values.actor.type.user.id, 'read'); + if ( ! has_perm ) throw APIError.create('permission_denied'); + + this.created = await fs.mkshortcut({ + parent, + name: target_name, + actor: values.actor, + target: shortcut_to, + }); + + await this.created.awaitStableEntry(); + await this.created.fetchEntry({ thumbnail: true }); + return await this.created.getSafeEntry(); + } + + this.checkpoint('before thumbnail'); + + let thumbnail_promise = new TeePromise(); + if ( await parent.isAppDataDirectory() || values.no_thumbnail ) { + thumbnail_promise.resolve(undefined); + } else { + (async () => { + const reason = await (async () => { + const { mime } = this.modules; + const thumbnails = context.get('services').get('thumbnails'); + if ( values.thumbnail ) return 'already thumbnail'; + + const content_type = mime.contentType(target_name); + this.log.debug('CONTENT TYPE', content_type); + if ( ! content_type ) return 'no content type'; + if ( ! thumbnails.is_supported_mimetype(content_type) ) return 'unsupported content type'; + if ( ! thumbnails.is_supported_size(values.file.size) ) return 'too large'; + + // Create file object for thumbnail by either using an existing + // buffer (ex: /download endpoint) or by forking a stream + // (ex: /write and /batch endpoints). + const thumb_file = (() => { + if ( values.file.buffer ) return values.file; + + const [replace_stream, thumbnail_stream] = + pausing_tee(values.file.stream, 2); + + values.file.stream = replace_stream; + return { ...values.file, stream: thumbnail_stream }; + })(); + + let thumbnail; + try { + thumbnail = await thumbnails.thumbify(thumb_file); + } catch (e) { + stream_to_the_void(thumb_file.stream); + return `thumbnail error: ${ e.message}`; + } + + const thumbnailData = { url: thumbnail }; + if ( thumbnailData.url ) { + await svc_event.emit('thumbnail.created', thumbnailData); // An extension can modify where this thumbnail is stored + } + + thumbnail_promise.resolve(thumbnailData.url); + })(); + if ( reason ) { + this.log.debug('REASON', reason); + thumbnail_promise.resolve(undefined); + + // values.file.stream = logging_stream(values.file.stream); + } + })(); + } + + this.checkpoint('before delegate'); + + if ( values.offset !== undefined ) { + if ( ! is_overwrite ) { + throw APIError.create('offset_requires_overwrite'); + } + + if ( ! values.file.stream ) { + throw APIError.create('offset_requires_stream'); + } + + const replace_length = values.file.size; + let dst_size = await destination.get('size'); + if ( values.offset > dst_size ) { + values.offset = dst_size; + } + + if ( values.offset + values.file.size > dst_size ) { + dst_size = values.offset + values.file.size; + } + + const ll_read = new LLRead(); + const read_stream = await ll_read.run({ + fsNode: destination, + }); + + values.file.stream = offset_write_stream({ + originalDataStream: read_stream, + newDataStream: values.file.stream, + offset: values.offset, + replace_length, + }); + values.file.size = dst_size; + } + + if ( is_overwrite ) { + const ll_owrite = new LLOWrite(); + this.written = await ll_owrite.run({ + node: destination, + actor: values.actor, + file: values.file, + tmp: { + socket_id: values.socket_id, + operation_id: values.operation_id, + item_upload_id: values.item_upload_id, + }, + fsentry_tmp: { + thumbnail_promise, + }, + message: values.message, + }); + } else { + const ll_cwrite = new LLCWrite(); + this.written = await ll_cwrite.run({ + parent, + name: target_name, + actor: values.actor, + file: values.file, + tmp: { + socket_id: values.socket_id, + operation_id: values.operation_id, + item_upload_id: values.item_upload_id, + }, + fsentry_tmp: { + thumbnail_promise, + }, + message: values.message, + app_id: values.app_id, + }); + } + + this.checkpoint('after delegate'); + + await this.written.awaitStableEntry(); + this.checkpoint('after await stable entry'); + const response = await this.written.getSafeEntry({ thumbnail: true }); + this.checkpoint('after get safe entry'); + + return response; + } +} + +module.exports = { + HLWrite, +}; diff --git a/src/backend/src/filesystem/lib/PuterPath.js b/src/backend/src/filesystem/lib/PuterPath.js new file mode 100644 index 0000000000000000000000000000000000000000..b569ec755a78d5915f4ddea8d2c166ef7a135684 --- /dev/null +++ b/src/backend/src/filesystem/lib/PuterPath.js @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const _path = require('path'); + +/** + * Puter paths look like any of the following: + * + * Absolute path: /user/dir1/dir2/file + * From UID: AAAA-BBBB-CCCC-DDDD/../a/b/c + * + * The difference between an absolute path and a UID-relative path + * is the leading forward-slash character. + */ +class PuterPath { + static NULL_UUID = '00000000-0000-0000-0000-000000000000'; + + static adapt (value) { + if ( value instanceof PuterPath ) return value; + return new PuterPath(value); + } + + constructor (text) { + this.text = text; + } + + set text (text) { + this.text_ = text.trim(); + this.normUnix = _path.normalize(text); + this.normFlat = + (this.normUnix.endsWith('/') && this.normUnix.length > 1) + ? this.normUnix.slice(0, -1) : this.normUnix; + } + get text () { + return this.text_; + } + + isRoot () { + if ( this.normFlat === '/' ) return true; + if ( this.normFlat === this.constructor.NULL_UUID ) { + return true; + } + return false; + } + + isAbsolute () { + return this.text.startsWith('/'); + } + + isFromUID () { + return !this.isAbsolute(); + } + + get reference () { + if ( this.isAbsolute ) return this.constructor.NULL_UUID; + + return this.text.slice(0, this.text.indexOf('/')); + } + + get relativePortion () { + if ( this.isAbsolute() ) { + return this.text.slice(1); + } + + if ( ! this.text.includes('/') ) return ''; + return this.text.slice(this.text.indexOf('/') + 1); + } +} + +module.exports = { PuterPath }; diff --git a/src/backend/src/filesystem/ll_operations/definitions.js b/src/backend/src/filesystem/ll_operations/definitions.js new file mode 100644 index 0000000000000000000000000000000000000000..f247993fa21ac62af3c83fb712b3eee9f8e7f1a3 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/definitions.js @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { BaseOperation } = require('../../services/OperationTraceService'); + +class LLFilesystemOperation extends BaseOperation { +} + +module.exports = { + LLFilesystemOperation, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_copy.js b/src/backend/src/filesystem/ll_operations/ll_copy.js new file mode 100644 index 0000000000000000000000000000000000000000..e3a0d815fbd5cc3e2c8bda585664ac0609225792 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_copy.js @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { LLFilesystemOperation } = require('./definitions'); +const fsCapabilities = require('../definitions/capabilities'); + +class LLCopy extends LLFilesystemOperation { + static MODULES = { + _path: require('path'), + uuidv4: require('uuid').v4, + }; + + async _run () { + const { _path, uuidv4 } = this.modules; + const { context } = this; + const { source, parent, user, actor, target_name } = this.values; + const svc = context.get('services'); + + const tracer = svc.get('traceService').tracer; + const fs = svc.get('filesystem'); + const svc_event = svc.get('event'); + + const uuid = uuidv4(); + const ts = Math.round(Date.now() / 1000); + + this.field('target-uid', uuid); + this.field('source', source.selector.describe()); + + this.checkpoint('before fetch parent entry'); + await parent.fetchEntry(); + this.checkpoint('before fetch source entry'); + await source.fetchEntry({ thumbnail: true }); + this.checkpoint('fetched source and parent entries'); + + // Access Control + { + const svc_acl = context.get('services').get('acl'); + this.checkpoint('copy :: access control'); + + // Check read access to source + if ( ! await svc_acl.check(actor, source, 'read') ) { + throw await svc_acl.get_safe_acl_error(actor, source, 'read'); + } + + // Check write access to destination + if ( ! await svc_acl.check(actor, parent, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, source, 'write'); + } + } + + const capabilities = source.provider.get_capabilities(); + if ( capabilities.has(fsCapabilities.COPY_TREE) ) { + const result_node = await source.provider.copy_tree({ + context, + source, + parent, + target_name, + }); + return result_node; + } else { + throw new Error('only copy_tree is current supported by ll_copy'); + } + } +} + +module.exports = { + LLCopy, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_copy_idea.js b/src/backend/src/filesystem/ll_operations/ll_copy_idea.js new file mode 100644 index 0000000000000000000000000000000000000000..96f2c859ece0c151af0e53d7ce8998eba75e36e4 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_copy_idea.js @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/* + + This file describes an idea to make fine-grained + steps of a filesystem operation more declarative. + + This could have advantages like: + - easier tracking of side-effects + - steps automatically mark checkpoints + - steps automatically have tracing + - implications of re-ordering steps would + always be known + - easier to diagnose stuck operations + +*/ +/* eslint-disable */ + +const STEPS_COPY_CONTENTS = [ + { + id: 'add storage info to fsentry', + behaviour: 'none', + fn: async ({ util, values }) => { + const { source } = values; + // "util.assign" makes it possible to + // track changes caused by this step + util.assign('raw_fsentry', { + size: source.entry.size, + // ... + }) + } + }, + { + id: 'create progress tracker', + behaviour: 'values', + fn: async () => { + const progress_tracker = + new UploadProgressTracker(); + return { + progress_tracker + }; + } + }, + { + id: 'emit copy progress event', + behaviour: 'side-effect', + fn: async ({ services }) => { + services.event.emit( + /// ... + ) + } + }, + { + id: 'get storage backend', + behaviour: 'values', + fn: async ({ services }) => { + const storage = new + PuterS3StorageStrategy({ + services + }) + return { storage }; + } + }, + // ... +] + +const STEPS = [ + { + id: 'generate uuid and ts', + behaviour: 'values', + fn: async ({ modules }) => { + return { + uuid: modules.uuidv4(), + ts: Math.round(Date.now()/1000) + }; + } + }, + { + id: 'redundancy fetch', + behaviour: 'side-effect', + fn: async ({ values }) => { + await values.source.fetchEntry({ + thumbnail: true, + }); + await values.parent.fetchEntry(); + } + }, + { + id: 'generate raw fsentry', + behaviour: 'values', + fn: async ({ values }) => { + const { + source, + parent, target_name, + uuid, ts, + user, + } = values; + const raw_fsentry = { + uuid, + is_dir: source.entry.is_dir, + // ... + }; + return { raw_fsentry }; + } + }, + { + id: 'emit fs.pending.file', + fn: () => { + // ... + } + }, + { + id: 'copy contents', + cond: async ({ values }) => { + return await values.source.get('has-s3'); + }, + steps: STEPS_COPY_CONTENTS, + }, + // ... +] + +class LLCopy extends LLFilesystemOperation { + static STEPS = STEPS +} diff --git a/src/backend/src/filesystem/ll_operations/ll_listusers.js b/src/backend/src/filesystem/ll_operations/ll_listusers.js new file mode 100644 index 0000000000000000000000000000000000000000..2a6073bc8375a2e41ef0f12d77b81770c4736a2b --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_listusers.js @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { RootNodeSelector, NodeChildSelector } = require('../node/selectors'); +const { LLFilesystemOperation } = require('./definitions'); + +class LLListUsers extends LLFilesystemOperation { + static description = ` + List user directories which are relevant to the + current actor. + `; + + async _run () { + const { context } = this; + const svc = context.get('services'); + const svc_permission = svc.get('permission'); + const svc_fs = svc.get('filesystem'); + + const user = this.values.user; + const issuers = await svc_permission.list_user_permission_issuers(user); + + const nodes = []; + + nodes.push(await svc_fs.node(new NodeChildSelector(new RootNodeSelector(), + user.username))); + + for ( const issuer of issuers ) { + const node = await svc_fs.node(new NodeChildSelector(new RootNodeSelector(), + issuer.username)); + nodes.push(node); + } + + return nodes; + } +} + +module.exports = { + LLListUsers, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_mkdir.js b/src/backend/src/filesystem/ll_operations/ll_mkdir.js new file mode 100644 index 0000000000000000000000000000000000000000..a4eeb4ec7c80b42e8b847c42f936851e31b81e0c --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_mkdir.js @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { MODE_WRITE } = require('../../services/fs/FSLockService'); +const { NodeUIDSelector, NodeChildSelector } = require('../node/selectors'); +const { RESOURCE_STATUS_PENDING_CREATE } = require('../../modules/puterfs/ResourceService'); +const { LLFilesystemOperation } = require('./definitions'); + +class LLMkdir extends LLFilesystemOperation { + static CONCERN = 'filesystem'; + static MODULES = { + _path: require('path'), + uuidv4: require('uuid').v4, + }; + + async _run () { + const { parent, name, immutable } = this.values; + + const actor = this.values.actor ?? this.context.get('actor'); + + const services = this.context.get('services'); + + const svc_fsLock = services.get('fslock'); + const svc_acl = services.get('acl'); + + /* eslint-disable */ // -- Please fix this linter rule + const lock_handle = await svc_fsLock.lock_child( + await parent.get('path'), + name, + MODE_WRITE, + ); + /* eslint-enable */ + + try { + if ( ! await svc_acl.check(actor, parent, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, parent, 'write'); + } + + return await parent.provider.mkdir({ + actor, + context: this.context, + parent, + name, + immutable, + }); + } finally { + lock_handle.unlock(); + } + } +} + +module.exports = { + LLMkdir, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_move.js b/src/backend/src/filesystem/ll_operations/ll_move.js new file mode 100644 index 0000000000000000000000000000000000000000..687503a03e6d35adf511dc2a21a8b5eb343a0fc2 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_move.js @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { LLFilesystemOperation } = require('./definitions'); + +class LLMove extends LLFilesystemOperation { + static MODULES = { + _path: require('path'), + }; + + async _run () { + const { context } = this; + const { source, parent, actor, target_name, metadata } = this.values; + + // Access Control + { + const svc_acl = context.get('services').get('acl'); + this.checkpoint('move :: access control'); + + // Check write access to source + if ( ! await svc_acl.check(actor, source, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, source, 'write'); + } + + // Check write access to destination + if ( ! await svc_acl.check(actor, parent, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, parent, 'write'); + } + } + + await source.provider.move({ + context: this.context, + node: source, + new_parent: parent, + new_name: target_name, + metadata, + }); + return source; + } +} + +module.exports = { + LLMove, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_read.js b/src/backend/src/filesystem/ll_operations/ll_read.js new file mode 100644 index 0000000000000000000000000000000000000000..f602e0723acf9098a0696b80db5803db49568101 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_read.js @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { get_user } = require('../../helpers'); +const { MemoryFSProvider } = require('../../modules/puterfs/customfs/MemoryFSProvider'); +const { UserActorType } = require('../../services/auth/Actor'); +const { Actor } = require('../../services/auth/Actor'); +const { DB_WRITE } = require('../../services/database/consts'); +const { Context } = require('../../util/context'); +const { buffer_to_stream } = require('../../util/streamutil'); +const { TYPE_SYMLINK, TYPE_DIRECTORY } = require('../FSNodeContext'); +const { LLFilesystemOperation } = require('./definitions'); + +const checkACLForRead = async (aclService, actor, fsNode, skip = false) => { + if ( skip ) { + return; + } + if ( ! await aclService.check(actor, fsNode, 'read') ) { + throw await aclService.get_safe_acl_error(actor, fsNode, 'read'); + } +}; +const typeCheckForRead = async (fsNode) => { + if ( await fsNode.get('type') === TYPE_DIRECTORY ) { + throw APIError.create('cannot_read_a_directory'); + } +}; + +class LLRead extends LLFilesystemOperation { + static CONCERN = 'filesystem'; + async _run ({ fsNode, no_acl, actor, offset, length, range, version_id } = {}) { + // extract services from context + const aclService = Context.get('services').get('acl'); + const db = Context.get('services') + .get('database').get(DB_WRITE, 'filesystem'); + const fileCacheService = Context.get('services').get('file-cache'); + + // validate input + if ( ! await fsNode.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + // validate initial node + await checkACLForRead(aclService, actor, fsNode, no_acl); + await typeCheckForRead(fsNode); + + let type = await fsNode.get('type'); + let traversedCount = 0; + while ( type === TYPE_SYMLINK ) { + fsNode = await fsNode.getTarget(); + type = await fsNode.get('type'); + traversedCount++; + } + + // validate symlink leaf node + if ( traversedCount > 0 ) { + await checkACLForRead(aclService, actor, fsNode, no_acl); + await typeCheckForRead(fsNode); + } + + // calculate range inputs + const has_range = ( + offset !== undefined && + offset !== 0 + ) || ( + length !== undefined && + length != await fsNode.get('size') + ) || range !== undefined; + + // timestamp access + db.write('UPDATE `fsentries` SET `accessed` = ? WHERE `id` = ?', + [Date.now() / 1000, await fsNode.get('mysql-id')]); + + const ownerId = await fsNode.get('user_id'); + const chargedActor = actor ? actor : new Actor({ + type: new UserActorType({ + user: await get_user({ id: ownerId }), + }), + }); + + //define metering service + + /** @type {import("../../services/MeteringService/MeteringService").MeteringService} */ + const meteringService = Context.get('services').get('meteringService').meteringService; + // check file cache + const maybe_buffer = await fileCacheService.try_get(fsNode); // TODO DS: do we need those cache hit logs? + if ( maybe_buffer ) { + // Meter cached egress + // return cached stream + if ( has_range && (length || offset) ) { + meteringService.incrementUsage(chargedActor, 'filesystem:cached-egress:bytes', length); + return buffer_to_stream(maybe_buffer.slice(offset, offset + length)); + } + meteringService.incrementUsage(chargedActor, 'filesystem:cached-egress:bytes', await fsNode.get('size')); + return buffer_to_stream(maybe_buffer); + } + + // if no cache attempt reading from storageProvider (s3) + const svc_mountpoint = Context.get('services').get('mountpoint'); + const provider = await svc_mountpoint.get_provider(fsNode.selector); + // const storage = svc_mountpoint.get_storage(provider.constructor.name); + + // Empty object here is in the case of local fiesystem, + // where s3:location will return null. + // TODO: storage interface shouldn't have S3-specific properties. + // const location = await fsNode.get('s3:location') ?? {}; + // const stream = (await storage.create_read_stream(await fsNode.get('uid'), { + // // TODO: fs:decouple-s3 + // bucket: location.bucket, + // bucket_region: location.bucket_region, + // version_id, + // key: location.key, + // memory_file: fsNode.entry, + // ...(range ? { range } : (has_range ? { + // range: `bytes=${offset}-${offset + length - 1}`, + // } : {})), + // })); + + const stream = await provider.read({ + context: this.context, + node: fsNode, + version_id: version_id, + ...(range ? { range } : (has_range ? { + range: `bytes=${offset}-${offset + length - 1}`, + } : {})), + }); + + // Meter ingress + const size = await (async () => { + if ( range ) { + const match = range.match(/bytes=(\d+)-(\d+)/); + if ( match ) { + const start = parseInt(match[1], 10); + const end = parseInt(match[2], 10); + return end - start + 1; + } + } + if ( has_range ) { + return length; + } + return await fsNode.get('size'); + })(); + meteringService.incrementUsage(chargedActor, 'filesystem:egress:bytes', size); + + // cache if whole file read + if ( ! has_range ) { + // only cache for non-memoryfs providers + if ( ! (fsNode.provider instanceof MemoryFSProvider) ) { + const res = await fileCacheService.maybe_store(fsNode, stream); + if ( res.stream ) { + // return with split cached stream + return res.stream; + } + } + } + return stream; + } +} + +module.exports = { + LLRead, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_readdir.js b/src/backend/src/filesystem/ll_operations/ll_readdir.js new file mode 100644 index 0000000000000000000000000000000000000000..cb5d73e4ce8abf59454f9924ae3255c927c45afd --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_readdir.js @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const fsCapabilities = require('../definitions/capabilities'); +const { ECMAP } = require('../ECMAP'); +const { TYPE_SYMLINK } = require('../FSNodeContext'); +const { RootNodeSelector } = require('../node/selectors'); +const { NodeUIDSelector, NodeChildSelector } = require('../node/selectors'); +const { LLFilesystemOperation } = require('./definitions'); + +class LLReadDir extends LLFilesystemOperation { + static CONCERN = 'filesystem'; + async _run () { + return ECMAP.arun(async () => { + return await this.__run(); + }); + } + async __run () { + const { context } = this; + const { subject: subject_let, actor, no_acl } = this.values; + let subject = subject_let; + + if ( ! await subject.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + const svc_acl = context.get('services').get('acl'); + if ( ! no_acl ) { + if ( ! await svc_acl.check(actor, subject, 'list') ) { + throw await svc_acl.get_safe_acl_error(actor, subject, 'list'); + } + } + + // TODO: DRY ACL check here + const subject_type = await subject.get('type'); + if ( subject_type === TYPE_SYMLINK ) { + const target = await subject.getTarget(); + if ( ! no_acl ) { + if ( ! await svc_acl.check(actor, target, 'list') ) { + throw await svc_acl.get_safe_acl_error(actor, target, 'list'); + } + } + subject = target; + } + + const svc = context.get('services'); + const svc_fs = svc.get('filesystem'); + + if ( subject.isRoot ) { + if ( ! actor.type.user ) return []; + return [ + await svc_fs.node(new NodeChildSelector(new RootNodeSelector(), + actor.type.user.username)), + ]; + } + + const capabilities = subject.provider.get_capabilities(); + + // UUID Mode + if ( capabilities.has(fsCapabilities.READDIR_UUID_MODE) ) { + this.checkpoint('readdir uuid mode'); + const child_uuids = await subject.provider.readdir({ + context, + node: subject, + }); + this.checkpoint('after get direct descendants'); + const children = await Promise.all(child_uuids.map(async uuid => { + return await svc_fs.node(new NodeUIDSelector(uuid)); + })); + this.checkpoint('after get children'); + return children; + } + + // Conventional Mode + const child_entries = subject.provider.readdir({ + context, + node: subject, + }); + + return await Promise.all(child_entries.map(async entry => { + return await svc_fs.node(new NodeChildSelector(subject, entry.name)); + })); + } +} + +module.exports = { + LLReadDir, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_readshares.js b/src/backend/src/filesystem/ll_operations/ll_readshares.js new file mode 100644 index 0000000000000000000000000000000000000000..1622bd990bc5f38867854ac9114e513f2b9a3109 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_readshares.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { get_user } = require('../../helpers'); +const { MANAGE_PERM_PREFIX } = require('../../services/auth/permissionConts.mjs'); +const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs'); +const { DB_WRITE } = require('../../services/database/consts'); +const { NodeUIDSelector } = require('../node/selectors'); +const { LLFilesystemOperation } = require('./definitions'); +const { LLReadDir } = require('./ll_readdir'); + +class LLReadShares extends LLFilesystemOperation { + static description = ` + Obtain the highest-level entries under this directory + for which the current actor has at least "see" permission. + + This is a breadth-first search. When any node is + found with "see" permission is found, children of that node + will not be traversed. + `; + + async _run () { + const { subject, user, actor } = this.values; + + const svc = this.context.get('services'); + + const svc_fs = svc.get('filesystem'); + const svc_acl = svc.get('acl'); + const db = svc.get('database').get(DB_WRITE, 'll_readshares'); + + const issuer_username = await subject.getUserPart(); + const issuer_user = await get_user({ username: issuer_username }); + const rows = await db.read('SELECT DISTINCT permission FROM `user_to_user_permissions` ' + + 'WHERE `holder_user_id` = ? AND `issuer_user_id` = ? ' + + 'AND (`permission` LIKE ? OR `permission` LIKE ?)', + [user.id, issuer_user.id, 'fs:%', 'manage:fs:%']); + + const fsentry_uuids = []; + for ( const row of rows ) { + const parts = PermissionUtil.split(row.permission.replace(`${MANAGE_PERM_PREFIX}:`, '')); + fsentry_uuids.push(parts[1]); + } + + const results = []; + + const ll_readdir = new LLReadDir(); + let interm_results = await ll_readdir.run({ + subject, + actor, + user, + no_thumbs: true, + no_assocs: true, + no_acl: true, + }); + + // Clone interm_results in case ll_readdir ever implements caching + interm_results = interm_results.slice(); + + for ( const fsentry_uuid of fsentry_uuids ) { + const node = await svc_fs.node(new NodeUIDSelector(fsentry_uuid)); + if ( ! node ) continue; + interm_results.push(node); + } + + for ( const node of interm_results ) { + if ( ! await node.exists() ) continue; + if ( ! await svc_acl.check(actor, node, 'see') ) continue; + results.push(node); + } + + return results; + } +} + +module.exports = { + LLReadShares, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_rmdir.js b/src/backend/src/filesystem/ll_operations/ll_rmdir.js new file mode 100644 index 0000000000000000000000000000000000000000..015af846ac803c1f636f88ee6bd81329993063ae --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_rmdir.js @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { MemoryFSProvider } = require('../../modules/puterfs/customfs/MemoryFSProvider'); +const { ParallelTasks } = require('../../util/otelutil'); +const FSNodeContext = require('../FSNodeContext'); +const { NodeUIDSelector } = require('../node/selectors'); +const { LLFilesystemOperation } = require('./definitions'); +const { LLRmNode } = require('./ll_rmnode'); + +class LLRmDir extends LLFilesystemOperation { + async _run () { + const { + target, + user, + actor, + descendants_only, + recursive, + + // internal use only - not for clients + ignore_not_empty, + + max_tasks = 8, + } = this.values; + + const { context } = this; + + const svc = context.get('services'); + + // Access Control + { + const svc_acl = context.get('services').get('acl'); + this.checkpoint('remove :: access control'); + + // Check write access to target + if ( ! await svc_acl.check(actor, target, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, target, 'write'); + } + } + + if ( await target.get('immutable') && !descendants_only ) { + throw APIError.create('immutable'); + } + + const fs = svc.get('filesystem'); + + const children = await target.provider.readdir({ + node: target, + }); + + if ( children.length > 0 && !recursive && !ignore_not_empty ) { + throw APIError.create('not_empty'); + } + + const tracer = svc.get('traceService').tracer; + const tasks = new ParallelTasks({ tracer, max: max_tasks }); + + for ( const child_uuid of children ) { + tasks.add('fs:rm:rm-child', async () => { + const child_node = await fs.node(new NodeUIDSelector(child_uuid)); + const type = await child_node.get('type'); + if ( type === FSNodeContext.TYPE_DIRECTORY ) { + const ll_rm = new LLRmDir(); + await ll_rm.run({ + target: await fs.node(new NodeUIDSelector(child_uuid)), + user, + recursive: true, + descendants_only: false, + + max_tasks: (v => v > 1 ? v : 1)(Math.floor(max_tasks / 2)), + }); + } else { + const ll_rm = new LLRmNode(); + await ll_rm.run({ + target: await fs.node(new NodeUIDSelector(child_uuid)), + user, + }); + } + }); + } + + await tasks.awaitAll(); + + // TODO (xiaochen): consolidate these two branches + if ( target.provider instanceof MemoryFSProvider ) { + await target.provider.rmdir({ + context, + node: target, + options: { + recursive, + descendants_only, + }, + }); + } else { + if ( ! descendants_only ) { + await target.provider.rmdir({ + context, + node: target, + options: { + ignore_not_empty: true, + }, + }); + } + } + } +} + +module.exports = { + LLRmDir, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_rmnode.js b/src/backend/src/filesystem/ll_operations/ll_rmnode.js new file mode 100644 index 0000000000000000000000000000000000000000..8ea145e20c17c80a5100b9460cc0d255aca143f0 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_rmnode.js @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { LLFilesystemOperation } = require('./definitions'); + +class LLRmNode extends LLFilesystemOperation { + async _run () { + const { target, actor } = this.values; + + const { context } = this; + + const svc_event = context.get('services').get('event'); + + // Access Control + { + const svc_acl = context.get('services').get('acl'); + this.checkpoint('remove :: access control'); + + // Check write access to target + if ( ! await svc_acl.check(actor, target, 'write') ) { + throw await svc_acl.get_safe_acl_error(actor, target, 'write'); + } + } + await svc_event.emit('fs.remove.node', this.values); + await target.provider.unlink({ context, node: target }); + } +} + +module.exports = { + LLRmNode, +}; diff --git a/src/backend/src/filesystem/ll_operations/ll_write.js b/src/backend/src/filesystem/ll_operations/ll_write.js new file mode 100644 index 0000000000000000000000000000000000000000..db2bffc1243977bd33470358b50a61002d5e0794 --- /dev/null +++ b/src/backend/src/filesystem/ll_operations/ll_write.js @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { LLFilesystemOperation } = require('./definitions'); +const APIError = require('../../api/APIError'); + +/** + * The "overwrite" write operation. + * + * This operation is used to write a file to an existing path. + * + * @extends LLFilesystemOperation + */ +class LLOWrite extends LLFilesystemOperation { + /** + * Executes the overwrite operation by writing to an existing file node. + * @returns {Promise} Result of the write operation + * @throws {APIError} When the target node does not exist + */ + async _run () { + const node = this.values.node; + + // Embed fields into this.context + this.context.set('immutable', this.values.immutable); + this.context.set('tmp', this.values.tmp); + this.context.set('fsentry_tmp', this.values.fsentry_tmp); + this.context.set('message', this.values.message); + this.context.set('actor', this.values.actor); + this.context.set('app_id', this.values.app_id); + + // TODO: Add symlink write + if ( ! await node.exists() ) { + // TODO: different class of errors for low-level operations + throw APIError.create('subject_does_not_exist'); + } + + return await node.provider.write_overwrite({ + context: this.context, + node: node, + file: this.values.file, + }); + } +} + +/** + * The "non-overwrite" write operation. + * + * This operation is used to write a file to a non-existent path. + * + * @extends LLFilesystemOperation + */ +class LLCWrite extends LLFilesystemOperation { + static MODULES = { + _path: require('path'), + uuidv4: require('uuid').v4, + config: require('../../config.js'), + }; + + /** + * Executes the create operation by writing a new file to the parent directory. + * @returns {Promise} Result of the write operation + * @throws {APIError} When the parent directory does not exist + */ + async _run () { + const parent = this.values.parent; + + // Embed fields into this.context + this.context.set('immutable', this.context.get('immutable') ?? this.values.immutable); + this.context.set('tmp', this.context.get('tmp') ?? this.values.tmp); + this.context.set('fsentry_tmp', this.context.get('fsentry_tmp') ?? this.values.fsentry_tmp); + this.context.set('message', this.context.get('message') ?? this.values.message); + this.context.set('actor', this.context.get('actor') ?? this.values.actor); + this.context.set('app_id', this.context.get('app_id') ?? this.values.app_id); + + if ( ! await parent.exists() ) { + throw APIError.create('subject_does_not_exist'); + } + + return await parent.provider.write_new({ + context: this.context, + parent, + name: this.values.name, + file: this.values.file, + }); + } +} + +module.exports = { + LLCWrite, + LLOWrite, +}; diff --git a/src/backend/src/filesystem/node/selectors.js b/src/backend/src/filesystem/node/selectors.js new file mode 100644 index 0000000000000000000000000000000000000000..4c5ba72439ed0c9b573c10b348cdd0738d9e9e45 --- /dev/null +++ b/src/backend/src/filesystem/node/selectors.js @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const _path = require('path'); +const { PuterPath } = require('../lib/PuterPath'); + +/** + * The base class doesn't add any functionality, but it's useful for + * `instanceof` checks. + */ +class NodeSelector { + constructor () { + if ( this.constructor === NodeSelector ) { + throw new Error('cannot instantiate NodeSelector directly; ' + + 'that would be like using this: https://devmeme.puter.site/plug.webp'); + } + } +} + +class NodePathSelector extends NodeSelector { + constructor (path) { + super(); + this.value = path; + } + + setPropertiesKnownBySelector (node) { + node.path = this.value; + node.name = _path.basename(this.value); + } + + describe () { + return this.value; + } +} + +class NodeUIDSelector extends NodeSelector { + constructor (uid) { + super(); + this.value = uid; + } + + setPropertiesKnownBySelector (node) { + node.uid = this.value; + } + + // Note: the selector could've been added by FSNodeContext + // during fetch, but this was more efficient because the + // object is created lazily, and it's somtimes not needed. + static implyFromFetchedData (node) { + if ( node.uid ) { + return new NodeUIDSelector(node.uid); + } + return null; + } + + describe () { + return `[uid:${this.value}]`; + } +} + +class NodeInternalIDSelector extends NodeSelector { + constructor (service, id, debugInfo) { + super(); + this.service = service; + this.id = id; + this.debugInfo = debugInfo; + } + + setPropertiesKnownBySelector (node) { + if ( this.service === 'mysql' ) { + node.mysql_id = this.id; + } + } + + describe (showDebug) { + if ( showDebug ) { + return `[db:${this.id}] (${ + JSON.stringify(this.debugInfo, null, 2) + })`; + } + return `[db:${this.id}]`; + } +} + +class NodeChildSelector extends NodeSelector { + constructor (parent, name) { + super(); + this.parent = parent; + this.name = name; + } + + setPropertiesKnownBySelector (node) { + node.name = this.name; + + try_infer_attributes(this); + if ( this.path ) { + node.path = this.path; + } + } + + describe () { + return `${this.parent.describe() }/${ this.name}`; + } +} + +class RootNodeSelector extends NodeSelector { + static entry = { + is_dir: true, + is_root: true, + uuid: PuterPath.NULL_UUID, + name: '/', + }; + setPropertiesKnownBySelector (node) { + node.path = '/'; + node.root = true; + node.uid = PuterPath.NULL_UUID; + } + constructor () { + super(); + this.entry = this.constructor.entry; + } + + describe () { + return '[root]'; + } +} + +class NodeRawEntrySelector extends NodeSelector { + constructor (entry) { + super(); + // Fix entries from get_descendants + if ( !entry.uuid && entry.uid ) { + entry.uuid = entry.uid; + if ( entry._id ) { + entry.id = entry._id; + delete entry._id; + } + } + + this.entry = entry; + } + + setPropertiesKnownBySelector (node) { + node.found = true; + node.entry = this.entry; + node.uid = this.entry.uid ?? this.entry.uuid; + node.name = this.entry.name; + if ( this.entry.path ) node.path = this.entry.path; + } + + describe () { + return '[raw entry]'; + } +} + +/** + * Try to infer following attributes for a selector: + * - path + * - uid + * + * @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} selector + */ +function try_infer_attributes (selector) { + if ( selector instanceof NodePathSelector ) { + selector.path = selector.value; + } else if ( selector instanceof NodeUIDSelector ) { + selector.uid = selector.value; + } else if ( selector instanceof NodeChildSelector ) { + try_infer_attributes(selector.parent); + if ( selector.parent.path ) { + selector.path = _path.join(selector.parent.path, selector.name); + } + } else if ( selector instanceof RootNodeSelector ) { + selector.path = '/'; + } else { + // give up + } +} + +const relativeSelector = (parent, path) => { + if ( path === '.' ) return parent; + if ( path.startsWith('..') ) { + throw new Error('currently unsupported'); + } + + let selector = parent; + + const parts = path.split('/').filter(Boolean); + for ( const part of parts ) { + selector = new NodeChildSelector(selector, part); + } + + return selector; +}; + +module.exports = { + NodeSelector, + NodePathSelector, + NodeUIDSelector, + NodeInternalIDSelector, + NodeChildSelector, + RootNodeSelector, + NodeRawEntrySelector, + relativeSelector, + try_infer_attributes, +}; diff --git a/src/backend/src/filesystem/node/states.js b/src/backend/src/filesystem/node/states.js new file mode 100644 index 0000000000000000000000000000000000000000..7194e1c9b617f6270277f1515381b52d51d38cc3 --- /dev/null +++ b/src/backend/src/filesystem/node/states.js @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +class NodeFoundState { +} + +class NodeDoesNotExistState { +} + +class NodeInitialState { +} diff --git a/src/backend/src/filesystem/storage/UploadProgressTracker.js b/src/backend/src/filesystem/storage/UploadProgressTracker.js new file mode 100644 index 0000000000000000000000000000000000000000..c98207282717d60cf917c42dfdd8a7e9ef9e42bc --- /dev/null +++ b/src/backend/src/filesystem/storage/UploadProgressTracker.js @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +class UploadProgressTracker { + constructor () { + this.progress_ = 0; + this.total_ = 0; + this.done_ = false; + + this.listeners_ = []; + } + + set_total (v) { + this.total_ = v; + } + + set (value) { + if ( value < this.progress_ ) { + // TODO: provide a logger for a warning + return; + } + const delta = value - this.progress_; + this.add(delta); + } + + add (amount) { + if ( this.done_ ) { + return; // TODO: warn + } + + this.progress_ += amount; + + for ( const lis of this.listeners_ ) { + lis(amount); + } + + this.check_if_done_(); + } + + sub (callback) { + if ( this.done_ ) { + return; + } + + const listeners = this.listeners_; + + listeners.push(callback); + + const det = { + detach: () => { + const idx = listeners.indexOf(callback); + if ( idx !== -1 ) { + listeners.splice(idx, 1); + } + }, + }; + + return det; + } + + check_if_done_ () { + if ( this.progress_ === this.total_ ) { + this.done_ = true; + // clear listeners so they get GC'd + this.listeners_ = []; + } + } +} + +module.exports = { + UploadProgressTracker, +}; \ No newline at end of file diff --git a/src/backend/src/filesystem/strategies/README.md b/src/backend/src/filesystem/strategies/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b2f24e9b0523d28339437fdd965a76e6affd3a87 --- /dev/null +++ b/src/backend/src/filesystem/strategies/README.md @@ -0,0 +1,12 @@ +## Puter Filesystem Strategies + +Each subdirectory is named in the format `_`, +where `` specifies broadly what that strategies contained within +the directory are concerned with (storage, fsentry, etc), and `` +is a letter from A-Z indicating the layer/level of concern. + +The class **A** indicates that this is the highest level of swappable +behaviour, which generally means there will be two strategies: +- one which supports legacy behaviour that is coupled with multiple concerns +- one which adapts more cohesive strategies to an interface which + supports the case above. diff --git a/src/backend/src/filesystem/strategies/storage_a/LocalDiskStorageStrategy.js b/src/backend/src/filesystem/strategies/storage_a/LocalDiskStorageStrategy.js new file mode 100644 index 0000000000000000000000000000000000000000..713db382b3fc881f59b82f30f341a4a85951faa3 --- /dev/null +++ b/src/backend/src/filesystem/strategies/storage_a/LocalDiskStorageStrategy.js @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { BaseOperation } = require('../../../services/OperationTraceService'); + +/** + * Handles file upload operations to local disk storage. + * Extends BaseOperation to provide upload functionality with progress tracking. + */ +class LocalDiskUploadStrategy extends BaseOperation { + /** + * Creates a new LocalDiskUploadStrategy instance. + * @param {Object} parent - The parent storage strategy instance + */ + constructor (parent) { + super(); + this.parent = parent; + this.uid = null; + } + + /** + * Executes the upload operation by storing file data to local disk. + * Handles both buffer and stream-based uploads with progress tracking. + * @returns {Promise} Resolves when the upload is complete + */ + async _run () { + const { uid, file, storage_api } = this.values; + + const { progress_tracker } = storage_api; + + if ( file.buffer ) { + await this.parent.svc_localDiskStorage.store_buffer({ + key: uid, + buffer: file.buffer, + }); + progress_tracker.set_total(file.buffer.length); + progress_tracker.set(file.buffer.length); + } else { + await this.parent.svc_localDiskStorage.store_stream({ + key: uid, + stream: file.stream, + size: file.size, + on_progress: evt => { + progress_tracker.set_total(file.size); + progress_tracker.set(evt.uploaded); + }, + }); + } + } + + /** + * Hook called after the operation is inserted into the trace. + */ + post_insert () { + } +} + +/** + * Handles file copy operations within local disk storage. + * Extends BaseOperation to provide copy functionality with progress tracking. + */ +class LocalDiskCopyStrategy extends BaseOperation { + /** + * Creates a new LocalDiskCopyStrategy instance. + * @param {Object} parent - The parent storage strategy instance + */ + constructor (parent) { + super(); + this.parent = parent; + } + + /** + * Executes the copy operation by duplicating a file from source to destination. + * Updates progress tracker to indicate completion. + * @returns {Promise} Resolves when the copy is complete + */ + async _run () { + const { src_node, dst_storage, storage_api } = this.values; + const { progress_tracker } = storage_api; + + await this.parent.svc_localDiskStorage.copy({ + src_key: await src_node.get('uid'), + dst_key: dst_storage.key, + }); + + // for now we just copy the file, we don't care about the progress + progress_tracker.set_total(1); + progress_tracker.set(1); + } + + /** + * Hook called after the operation is inserted into the trace. + */ + post_insert () { + } +} + +/** + * Handles file deletion operations from local disk storage. + * Extends BaseOperation to provide delete functionality. + */ +class LocalDiskDeleteStrategy extends BaseOperation { + /** + * Creates a new LocalDiskDeleteStrategy instance. + * @param {Object} parent - The parent storage strategy instance + */ + constructor (parent) { + super(); + this.parent = parent; + } + + /** + * Executes the delete operation by removing a file from local disk storage. + * @returns {Promise} Resolves when the deletion is complete + */ + async _run () { + const { node } = this.values; + + await this.parent.svc_localDiskStorage.delete({ + key: await node.get('uid'), + }); + } +} + +/** + * Main strategy class for managing local disk storage operations. + * Provides factory methods for creating upload, copy, and delete operations. + */ +class LocalDiskStorageStrategy { + /** + * Creates a new LocalDiskStorageStrategy instance. + * @param {Object} config - Configuration object + * @param {Object} config.services - Services container for dependency injection + */ + constructor ({ services }) { + this.svc_localDiskStorage = services.get('local-disk-storage'); + } + + /** + * Creates a new upload operation instance. + * @returns {LocalDiskUploadStrategy} A new upload strategy instance + */ + create_upload () { + return new LocalDiskUploadStrategy(this); + } + + /** + * Creates a new copy operation instance. + * @returns {LocalDiskCopyStrategy} A new copy strategy instance + */ + create_copy () { + return new LocalDiskCopyStrategy(this); + } + + /** + * Creates a new delete operation instance. + * @returns {LocalDiskDeleteStrategy} A new delete strategy instance + */ + create_delete () { + return new LocalDiskDeleteStrategy(this); + } + + /** + * Creates a readable stream for accessing file data from local disk storage. + * @param {string} uid - The unique identifier of the file to read + * @param {Object} [options={}] - Optional parameters for stream creation + * @returns {Promise} A readable stream for the file data + */ + async create_read_stream (uid, options = {}) { + return await this.svc_localDiskStorage.create_read_stream(uid, options); + } +} + +module.exports = { + LocalDiskStorageStrategy, +}; diff --git a/src/backend/src/filesystem/strategies/storage_a/README.md b/src/backend/src/filesystem/strategies/storage_a/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1699e05d91a98392e9fe55c4afbec008ab142bde --- /dev/null +++ b/src/backend/src/filesystem/strategies/storage_a/README.md @@ -0,0 +1,9 @@ +## Class A Storage Strategies + +This is the broadest definition of storage strategies. +This is to allow swapping between the behaviour of the original +Puter storage logic, and Class B storage strategies. + +- they know the UID of the file +- they can perform post-operations after the fsentry is inserted +- they can access the Puter database diff --git a/src/backend/src/filesystem/validation.js b/src/backend/src/filesystem/validation.js new file mode 100644 index 0000000000000000000000000000000000000000..70b9a89da7a5e946324f5ab818778c0ad3f753b2 --- /dev/null +++ b/src/backend/src/filesystem/validation.js @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/* ~~~ Filesystem validation ~~~ + +This module contains functions that validate filesystem operations. + +*/ + +/* eslint-disable no-control-regex */ + +const config = require('../config'); + +const path_excludes = () => /[\x00-\x1F]/g; +const node_excludes = () => /[/\x00-\x1F]/g; + +// this characters are not allowed in path names because +// they might be used to trick the user into thinking +// a filename is different from what it actually is. +const safety_excludes = [ + /[\u202A-\u202E]/, // RTL and LTR override + /[\u200E-\u200F]/, // RTL and LTR mark + /[\u2066-\u2069]/, // RTL and LTR isolate + /[\u2028-\u2029]/, // line and paragraph separator + /[\uFF01-\uFF5E]/, // fullwidth ASCII + /[\u2060]/, // word joiner + /[\uFEFF]/, // zero width no-break space + /[\uFFFE-\uFFFF]/, // non-characters +]; + +const is_valid_node_name = function is_valid_node_name (name) { + if ( typeof name !== 'string' ) return false; + if ( node_excludes().test(name) ) return false; + for ( const exclude of safety_excludes ) { + if ( exclude.test(name) ) return false; + } + if ( name.length > config.max_fsentry_name_length ) return false; + // Names are allowed to contain dots, but cannot + // contain only dots. (this covers '.' and '..') + const name_without_dots = name.replace(/\./g, ''); + if ( name_without_dots.length < 1 ) return false; + + return true; +}; + +const is_valid_path = function is_valid_path (path, { + no_relative_components, + allow_path_fragment, +} = {}) { + if ( typeof path !== 'string' ) return false; + if ( path.length < 1 ) false; + if ( path_excludes().test(path) ) return false; + for ( const exclude of safety_excludes ) { + if ( exclude.test(path) ) return false; + } + + if ( ! allow_path_fragment ) { + if ( path[0] !== '/' && path[0] !== '.' ) { + return false; + } + } + + if ( no_relative_components ) { + const components = path.split('/'); + for ( const component of components ) { + if ( component === '' ) continue; + const name_without_dots = component.replace(/\./g, ''); + if ( name_without_dots.length < 1 ) return false; + } + } + + return true; +}; + +module.exports = { + is_valid_node_name, + is_valid_path, +}; diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..ec714b2bfd46a7c4e78026b3e2b541d1d2773fbb --- /dev/null +++ b/src/backend/src/helpers.js @@ -0,0 +1,1730 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const _path = require('path'); +const micromatch = require('micromatch'); +const config = require('./config'); +const mime = require('mime-types'); +const { ManagedError } = require('./util/errorutil.js'); +const { spanify } = require('./util/otelutil.js'); +const APIError = require('./api/APIError.js'); +const { DB_READ, DB_WRITE } = require('./services/database/consts.js'); +const { Context } = require('./util/context'); +const { NodeUIDSelector } = require('./filesystem/node/selectors'); +const { object_returned_by_get_app } = require('./annotatedobjects.js'); +const { kv } = require('./util/kvSingleton'); + +let services = null; +const tmp_provide_services = async ss => { + services = ss; + await services.ready; +}; + +async function is_empty (dir_uuid) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + let rows; + + if ( typeof dir_uuid === 'object' ) { + if ( typeof dir_uuid.path === 'string' && dir_uuid.path !== '' ) { + rows = await db.read(`SELECT EXISTS(SELECT 1 FROM fsentries WHERE path LIKE ${db.case({ + sqlite: '? || \'%\'', + otherwise: 'CONCAT(?, \'%\')', + })} LIMIT 1) AS not_empty`, + [`${dir_uuid.path }/`]); + } else dir_uuid = dir_uuid.uid; + } + + if ( typeof dir_uuid === 'string' ) { + rows = await db.read('SELECT EXISTS(SELECT 1 FROM fsentries WHERE parent_uid = ? LIMIT 1) AS not_empty', + [dir_uuid]); + } + + return !rows[0].not_empty; +} + +/** + * @deprecated - sharing will be implemented with user-to-user ACL + */ +async function has_shared_with (user_id, recipient_user_id) { + return false; +} + +/** + * Checks to see if this file/directory is shared with the user identified by `recipient_user_id` + * + * @param {*} fsentry_id + * @param {*} recipient_user_id + * + * @deprecated - sharing will be implemented with user-to-user ACL + */ +async function is_shared_with (fsentry_id, recipient_user_id) { + return false; +} + +/** + * Checks to see if this file/directory is shared with at least one other user + * + * @param {*} fsentry_id + * @param {*} recipient_user_id + * + * @deprecated - sharing will be implemented with user-to-user ACL + */ +async function is_shared_with_anyone (fsentry_id) { + return false; +} + +/** + * Checks to see if temp_users is disabled and return a boolean + * @returns {boolean} + */ +async function is_temp_users_disabled () { + const svc_feature_flag = await services.get('feature-flag'); + return await svc_feature_flag.check('temp-users-disabled'); +} + +/** + * Checks to see if user_signup is disabled and return a boolean + * @returns {boolean} + */ +async function is_user_signup_disabled () { + const svc_feature_flag = await services.get('feature-flag'); + return await svc_feature_flag.check('user-signup-disabled'); +} + +const chkperm = spanify('chkperm', async (target_fsentry, requester_user_id, action) => { + // basic cases where false is the default response + if ( ! target_fsentry ) + { + return false; + } + + // pseudo-entry from FSNodeContext + if ( target_fsentry.is_root ) { + return action === 'read'; + } + + // requester is the owner of this entry + if ( target_fsentry.user_id === requester_user_id ) { + return true; + } + // this entry was shared with the requester + else if ( await is_shared_with(target_fsentry.id, requester_user_id) ) { + return true; + } + // special case: owner of entry has shared at least one entry with requester and requester is asking for the owner's root directory: /[owner_username] + else if ( target_fsentry.parent_uid === null && await has_shared_with(target_fsentry.user_id, requester_user_id) && action !== 'write' ) + { + return true; + } + else + { + return false; + } +}); + +/** + * Checks if the string provided is a valid FileSystem Entry name. + * + * @param {string} name + * @returns + */ +function validate_fsentry_name (name) { + if ( ! name ) + { + throw { message: 'Name can not be empty.' }; + } + else if ( ! isString(name) ) + { + throw { message: 'Name can only be a string.' }; + } + else if ( name.includes('/') ) + { + throw { message: "Name can not contain the '/' character." }; + } + else if ( name === '.' ) + { + throw { message: "Name can not be the '.' character." }; + } + else if ( name === '..' ) + { + throw { message: "Name can not be the '..' character." }; + } + else if ( name.length > config.max_fsentry_name_length ) + { + throw { message: `Name can not be longer than ${config.max_fsentry_name_length} characters` }; + } + else + { + return true; + } +} + +/** + * Convert a FSEntry ID to UUID + * + * @param {integer} id - `id` of FSEntry + * @returns {Promise} Promise object represents the UUID of the FileSystem Entry + */ +async function id2uuid (id) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + let fsentry = await db.requireRead('SELECT `uuid`, immutable FROM `fsentries` WHERE `id` = ? LIMIT 1', [id]); + + if ( ! fsentry[0] ) + { + return null; + } + else + { + return fsentry[0].uuid; + } +} + +/** + * Get total data stored by a user + * + * @param {integer} user_id - `user_id` of user + * @returns {Promise} Promise object represents the UUID of the FileSystem Entry + */ +async function df (user_id) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + const fsentry = await db.read('SELECT SUM(size) AS total FROM `fsentries` WHERE `user_id` = ? LIMIT 1', [user_id]); + if ( !fsentry[0] || !fsentry[0].total ) + { + return 0; + } + else + { + return fsentry[0].total; + } +} + +/** + * Get user by a variety of IDs + * + * Pass `cached: false` to options if a cached user entry would not be appropriate; + * for example: when performing authentication. + * + * @param {string} options - `options` + * @returns {Promise} + */ +async function get_user (options) { + return await services.get('get-user').get_user(options); +} + +/** + * Invalidate the cached entries for a user object + * + * @param {User} userID - the user entry to invalidate + */ +function invalidate_cached_user (user) { + kv.del(`users:username:${ user.username}`); + kv.del(`users:uuid:${ user.uuid}`); + kv.del(`users:email:${ user.email}`); + kv.del(`users:id:${ user.id}`); +} + +/** + * Invalidate the cached entries for the user specified by an id + * @param {number} id - the id of the user to invalidate + */ +function invalidate_cached_user_by_id (id) { + const user = kv.get(`users:id:${ id}`); + if ( ! user ) return; + invalidate_cached_user(user); +} + +/** + * Refresh apps cache + * + * @param {string} options - `options` + * @returns {Promise} + */ +async function refresh_apps_cache (options, override) { + return; +} + +async function refresh_associations_cache () { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'apps'); + + const log = services.get('log-service').create('helpers.js'); + log.tick('refresh file associations'); + const associations = await db.read('SELECT * FROM app_filetype_association'); + const lists = {}; + for ( const association of associations ) { + let ext = association.type; + if ( ext.startsWith('.') ) ext = ext.slice(1); + // Default file association entries were added with empty types; + // this prevents those from showing up. + if ( ext === '' ) continue; + if ( ! lists.hasOwnProperty(ext) ) lists[ext] = []; + lists[ext].push(association.app_id); + } + + for ( const k in lists ) { + kv.set(`assocs:${k}:apps`, lists[k]); + } +} + +/** + * Get App by a variety of IDs + * + * @param {string} options - `options` + * @returns {Promise} + */ +async function get_app (options) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'apps'); + + const log = services.get('log-service').create('get_app'); + let app = []; + + // This condition should be updated if the code below is re-ordered. + if ( options.follow_old_names && !options.uid && options.name ) { + const svc_oldAppName = services.get('old-app-name'); + const old_name = await svc_oldAppName.check_app_name(options.name); + if ( old_name ) { + options.uid = old_name.app_uid; + + // The following line is technically pointless, but may avoid a bug + // if the if...else chain below is re-ordered. + delete options.name; + } + } + + if ( options.uid ) { + // try cache first + app[0] = kv.get(`apps:uid:${options.uid}`); + // not in cache, try db + if ( ! app[0] ) { + log.cache(false, `apps:uid:${ options.uid}`); + app = await db.read('SELECT * FROM `apps` WHERE `uid` = ? LIMIT 1', [options.uid]); + } + } else if ( options.name ) { + // try cache first + app[0] = kv.get(`apps:name:${options.name}`); + // not in cache, try db + if ( ! app[0] ) { + log.cache(false, `apps:name:${ options.name}`); + app = await db.read('SELECT * FROM `apps` WHERE `name` = ? LIMIT 1', [options.name]); + } + } + else if ( options.id ) { + // try cache first + app[0] = kv.get(`apps:id:${options.id}`); + // not in cache, try db + if ( ! app[0] ) { + log.cache(false, `apps:id:${ options.id}`); + app = await db.read('SELECT * FROM `apps` WHERE `id` = ? LIMIT 1', [options.id]); + } + } + app = app && app[0] ? app[0] : null; + + if ( app === null ) return null; + + // kv.set(`apps:uid:${app.uid}`, app, { EX: 30 }); + // kv.set(`apps:name:${app.name}`, app, { EX: 30 }); + // kv.set(`apps:id:${app.id}`, app, { EX: 30 }); + + // shallow clone because we use the `delete` operator + // and it corrupts the cache otherwise + app = { ...app }; + return new object_returned_by_get_app(app); +} + +/** + * Checks to see if an app exists + * + * @param {string} options - `options` + * @returns {Promise} + */ +async function app_exists (options) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'apps'); + + let app; + if ( options.uid ) + { + app = await db.read('SELECT `id` FROM `apps` WHERE `uid` = ? LIMIT 1', [options.uid]); + } + else if ( options.name ) + { + app = await db.read('SELECT `id` FROM `apps` WHERE `name` = ? LIMIT 1', [options.name]); + } + else if ( options.id ) + { + app = await db.read('SELECT `id` FROM `apps` WHERE `id` = ? LIMIT 1', [options.id]); + } + + return app[0]; +} + +/** + * change username + * + * @param {string} options - `options` + * @returns {Promise} + */ +async function change_username (user_id, new_username) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_WRITE, 'auth'); + + const old_username = (await get_user({ id: user_id })).username; + + // update username + await db.write('UPDATE `user` SET username = ? WHERE `id` = ? LIMIT 1', [new_username, user_id]); + // update root directory name for this user + await db.write('UPDATE `fsentries` SET `name` = ?, `path` = ? ' + + 'WHERE `user_id` = ? AND parent_uid IS NULL LIMIT 1', + [new_username, `/${ new_username}`, user_id]); + + console.log(`User ${old_username} changed username to ${new_username}`); + await services.get('filesystem').update_child_paths(`/${old_username}`, `/${new_username}`, user_id); + + invalidate_cached_user_by_id(user_id); +} + +/** + * Find a FSEntry by its uuid + * + * @param {integer} id - `id` of FSEntry + * @returns {Promise} Promise object represents the UUID of the FileSystem Entry + * @deprecated Use fs middleware instead + */ +async function uuid2fsentry (uuid, return_thumbnail) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + // todo optim, check if uuid is not exactly 36 characters long, if not it's invalid + // and we can avoid one unnecessary DB lookup + let fsentry = await db.requireRead(`SELECT + id, + associated_app_id, + uuid, + public_token, + bucket, + bucket_region, + file_request_token, + user_id, + parent_uid, + is_dir, + is_public, + is_shortcut, + shortcut_to, + sort_by, + ${return_thumbnail ? 'thumbnail,' : ''} + immutable, + name, + metadata, + modified, + created, + accessed, + size + FROM fsentries WHERE uuid = ? LIMIT 1`, + [uuid]); + + if ( ! fsentry[0] ) + { + return false; + } + else + { + return fsentry[0]; + } +} + +/** + * Find a FSEntry by its id + * + * @param {integer} id - `id` of FSEntry + * @returns {Promise} Promise object represents the UUID of the FileSystem Entry + */ +async function id2fsentry (id, return_thumbnail) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + // todo optim, check if uuid is not exactly 36 characters long, if not it's invalid + // and we can avoid one unnecessary DB lookup + let fsentry = await db.requireRead(`SELECT + id, + uuid, + public_token, + file_request_token, + associated_app_id, + user_id, + parent_uid, + is_dir, + is_public, + is_shortcut, + shortcut_to, + sort_by, + ${return_thumbnail ? 'thumbnail,' : ''} + immutable, + name, + metadata, + modified, + created, + accessed, + size + FROM fsentries WHERE id = ? LIMIT 1`, + [id]); + + if ( ! fsentry[0] ) { + return false; + } else + { + return fsentry[0]; + } +} + +/** + * Takes a an absolute path and returns its corresponding FSEntry. + * + * @param {string} path - absolute path of the filesystem entry to be resolved + * @param {boolean} return_content - if FSEntry is a file, determines whether its content should be returned + * @returns {false|object} - `false` if path could not be resolved, otherwise an object representing the FSEntry + * @deprecated Use fs middleware instead + */ +async function convert_path_to_fsentry (path) { + // todo optim, check if path is valid (e.g. contaisn valid characters) + // if syntactical errors are found we can potentially avoid some expensive db lookups + + // '/' means that parent_uid is null + // TODO: facade fsentry for root (devlog:2023-06-01) + if ( path === '/' ) + { + return null; + } + //first slash is redundant + path = path.substr(path.indexOf('/') + 1); + //last slash, if existing is redundant + if ( path[path.length - 1] === '/' ) + { + path = path.slice(0, -1); + } + //split path into parts + const fsentry_names = path.split('/'); + + // if no parts, return false + if ( fsentry_names.length === 0 ) + { + return false; + } + + let parent_uid = null; + let final_res = null; + let is_public = false; + let result; + + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + // Try stored path first + result = await db.read('SELECT * FROM fsentries WHERE path=? LIMIT 1', + [`/${ path}`]); + + if ( result[0] ) { + return result[0]; + } + + for ( let i = 0; i < fsentry_names.length; i++ ) { + if ( parent_uid === null ) { + result = await db.read('SELECT * FROM fsentries WHERE parent_uid IS NULL AND name=? LIMIT 1', + [fsentry_names[i]]); + } + else { + result = await db.read('SELECT * FROM fsentries WHERE parent_uid = ? AND name=? LIMIT 1', + [parent_uid, fsentry_names[i]]); + } + + if ( result[0] ) { + parent_uid = result[0].uuid; + // is_public is either directly specified or inherited from parent dir + if ( result[0].is_public === null ) + { + result[0].is_public = is_public; + } + else + { + is_public = result[0].is_public; + } + + } else { + return false; + } + final_res = result; + } + return final_res[0]; +} + +/** + * + * @param {integer} bytes - size in bytes + * @returns {string} bytes in human-readable format + */ +function byte_format (bytes) { + // calculate and return bytes in human-readable format + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + if ( typeof bytes !== 'number' || bytes < 1 ) { + return '0 B'; + } + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + return `${Math.round(bytes / Math.pow(1024, i), 2) } ${ sizes[i]}`; +}; + +/** + * Recursively retrieve all files, directories, and subdirectories under `path`. + * Optionally the `depth` can be set. + * + * @param {string} path + * @param {object} user + * @param {integer} depth + * @returns + */ +async function getDescendantsHelper (path, user, depth, return_thumbnail = false) { + const log = services.get('log-service').create('get_descendants'); + log.called(); + + // decrement depth if it's set + depth !== undefined && depth--; + // turn path into absolute form + path = _path.resolve('/', path); + // get parent dir + const parent = await convert_path_to_fsentry(path); + // holds array that will be returned + const ret = []; + // holds immediate children of this path + let children; + + // try to extract username from path + let username; + let split_path = path.split('/'); + if ( split_path.length === 2 && split_path[0] === '' ) + { + username = split_path[1]; + } + + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + // ------------------------------------- + // parent is root ('/') + // ------------------------------------- + if ( parent === null ) { + path = ''; + // direct children under root + children = await db.read(`SELECT + id, uuid, parent_uid, name, metadata, is_dir, bucket, bucket_region, + modified, created, immutable, shortcut_to, is_shortcut, sort_by, associated_app_id, + ${return_thumbnail ? 'thumbnail, ' : ''} + accessed, size + FROM fsentries + WHERE user_id = ? AND parent_uid IS NULL`, + [user.id]); + // users that have shared files/dirs with this user + const sharing_users = await db.read(`SELECT DISTINCT(owner_user_id), user.username + FROM share + INNER JOIN user ON user.id = share.owner_user_id + WHERE share.recipient_user_id = ?`, + [user.id]); + if ( sharing_users.length > 0 ) { + for ( let i = 0; i < sharing_users.length; i++ ) { + let dir = {}; + dir.id = null; + dir.uuid = null; + dir.parent_uid = null; + dir.name = sharing_users[i].username; + dir.is_dir = true; + dir.immutable = true; + children.push(dir); + } + } + } + // ------------------------------------- + // parent doesn't exist + // ------------------------------------- + else if ( parent === false ) { + return []; + } + // ------------------------------------- + // Parent is a shared-user directory: /[some_username](/) + // but make sure `[some_username]` is not the same as the requester's username + // ------------------------------------- + else if ( username && username !== user.username ) { + children = []; + let sharing_user; + sharing_user = await get_user({ username: username }); + if ( ! sharing_user ) + { + return []; + } + + // shared files/dirs with this user + const shared_fsentries = await db.read(`SELECT + fsentries.id, fsentries.user_id, fsentries.uuid, fsentries.parent_uid, fsentries.bucket, fsentries.bucket_region, + fsentries.name, fsentries.shortcut_to, fsentries.is_shortcut, fsentries.metadata, fsentries.is_dir, fsentries.modified, + fsentries.created, fsentries.accessed, fsentries.size, fsentries.sort_by, fsentries.associated_app_id, + fsentries.is_symlink, fsentries.symlink_path, + fsentries.immutable ${return_thumbnail ? ', fsentries.thumbnail' : ''} + FROM share + INNER JOIN fsentries ON fsentries.id = share.fsentry_id + WHERE share.recipient_user_id = ? AND owner_user_id = ?`, + [user.id, sharing_user.id]); + // merge `children` and `shared_fsentries` + if ( shared_fsentries.length > 0 ) { + for ( let i = 0; i < shared_fsentries.length; i++ ) { + shared_fsentries[i].path = await id2path(shared_fsentries[i].id); + children.push(shared_fsentries[i]); + } + } + } + // ------------------------------------- + // All other cases + // ------------------------------------- + else { + children = []; + let temp_children = await db.read(`SELECT + id, user_id, uuid, parent_uid, name, metadata, is_shortcut, + shortcut_to, is_dir, modified, created, accessed, size, sort_by, associated_app_id, + is_symlink, symlink_path, + immutable ${return_thumbnail ? ', thumbnail' : ''} + FROM fsentries + WHERE parent_uid = ?`, + [parent.uuid]); + // check if user has access to each file, if yes add it + if ( temp_children.length > 0 ) { + for ( let i = 0; i < temp_children.length; i++ ) { + const tchild = temp_children[i]; + if ( await chkperm(tchild, user.id) ) + { + children.push(tchild); + } + } + } + } + + // shortcut on empty result set + if ( children.length === 0 ) return []; + + const ids = children.map(child => child.id); + const qmarks = ids.map(() => '?').join(','); + + let rows = await db.read(`SELECT root_dir_id FROM subdomains WHERE root_dir_id IN (${qmarks}) AND user_id=?`, + [...ids, user.id]); + + const websiteMap = {}; + for ( const row of rows ) websiteMap[row.root_dir_id] = true; + + for ( let i = 0; i < children.length; i++ ) { + const contentType = mime.contentType(children[i].name); + + // has_website + let has_website = false; + if ( children[i].is_dir ) { + has_website = websiteMap[children[i].id]; + } + + // object to return + // TODO: DRY creation of response fsentry from db fsentry + ret.push({ + path: children[i].path ?? (`${path }/${ children[i].name}`), + name: children[i].name, + metadata: children[i].metadata, + _id: children[i].id, + id: children[i].uuid, + uid: children[i].uuid, + is_shortcut: children[i].is_shortcut, + shortcut_to: (children[i].shortcut_to ? await id2uuid(children[i].shortcut_to) : undefined), + shortcut_to_path: (children[i].shortcut_to ? await id2path(children[i].shortcut_to) : undefined), + is_symlink: children[i].is_symlink, + symlink_path: children[i].symlink_path, + immutable: children[i].immutable, + is_dir: children[i].is_dir, + modified: children[i].modified, + created: children[i].created, + accessed: children[i].accessed, + size: children[i].size, + sort_by: children[i].sort_by, + thumbnail: children[i].thumbnail, + associated_app_id: children[i].associated_app_id, + type: contentType ? contentType : null, + has_website: has_website, + }); + if ( children[i].is_dir && + (depth === undefined || (depth !== undefined && depth > 0)) + ) { + ret.push(await get_descendants(`${path }/${ children[i].name}`, user, depth)); + } + } + return ret.flat(); +}; + +async function get_descendants (...args) { + const tracer = services.get('traceService').tracer; + let ret; + await tracer.startActiveSpan('get_descendants', async span => { + ret = await getDescendantsHelper(...args); + span.end(); + }); + return ret; +}; + +const get_dir_size = async (path, user) => { + let size = 0; + const descendants = await get_descendants(path, user); + for ( let i = 0; i < descendants.length; i++ ) { + if ( ! descendants[i].is_dir ) { + size += descendants[i].size; + } + } + + return size; +}; + +/** + * + * @param {integer} entry_id + * @returns + */ +async function id2path (entry_uid) { + if ( entry_uid == null ) { + throw new Error('got null or undefined entry id'); + } + + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + const traces = services.get('traceService'); + const log = services.get('log-service').create('helpers.id2path'); + log.traceOn(); + const errors = services.get('error-service').create(log); + log.called(); + + let result; + + return await traces.spanify('helpers:id2path', async () => { + log.debug(`entry id: ${entry_uid}`); + if ( typeof entry_uid === 'number' ) { + const old = entry_uid; + entry_uid = await id2uuid(entry_uid); + log.debug(`entry id resolved: resolved ${old} ${entry_uid}`); + } + + try { + result = await db.read(` + WITH RECURSIVE cte AS ( + SELECT uuid, parent_uid, name, name AS path + FROM fsentries + WHERE uuid = ? + + UNION ALL + + SELECT e.uuid, e.parent_uid, e.name, ${ + db.case({ + sqlite: 'e.name || \'/\' || cte.path', + otherwise: 'CONCAT(e.name, \'/\', cte.path)', + }) + } + FROM fsentries e + INNER JOIN cte ON cte.parent_uid = e.uuid + ) + SELECT * + FROM cte + WHERE parent_uid IS NULL + `, [entry_uid]); + } catch (e) { + errors.report('id2path.select', { + alarm: true, + source: e, + message: `error while resolving path for ${entry_uid}: ${e.message}`, + extra: { + entry_uid, + }, + }); + throw new ManagedError(`cannot create path for ${entry_uid}`); + } + + if ( !result || !result[0] ) { + errors.report('id2path.select', { + alarm: true, + message: `no result for ${entry_uid}`, + extra: { + entry_uid, + }, + }); + throw new ManagedError(`cannot create path for ${entry_uid}`); + } + + return `/${ result[0].path}`; + }); +}; + +/** + * + * @param {string} glob + * @param {object} user + * @returns + */ +async function resolve_glob (glob, user) { + //turn glob into abs path + glob = _path.resolve('/', glob); + //get base of glob + const base = micromatch.scan(glob).base; + //estimate needed depth + let depth = 1; + const dirs = glob.split('/'); + for ( let i = 0; i < dirs.length; i++ ) { + if ( dirs[i].includes('**') ) { + depth = undefined; + break; + } else { + depth++; + } + } + + const descendants = await get_descendants(base, user, depth); + + return descendants.filter((fsentry) => { + return fsentry.path && micromatch.isMatch(fsentry.path, glob); + }); +} + +/** + * Copies a FSEntry represented by `source_path` to `dest_path`. + * + * @param {string} source_path + * @param {string} dest_path + * @param {object} user + * @returns + */ +function cp (source_path, dest_path, user, overwrite, change_name, check_perms = true) { + throw new Error('legacy copy function called'); +} + +function isString (variable) { + return typeof variable === 'string' || variable instanceof String; +} + +// checks to see if given variable is an object +function isObject (variable) { + return variable !== null && typeof variable === 'object'; +} + +/** + * Recusrively deletes all files under `path` + * + * @param {string} source_path + * @param {object} user + * @returns + */ +function rm (source_path, user, descendants_only = false) { + throw new Error('legacy remove function called'); +} + +const body_parser_error_handler = (err, req, res, next) => { + if ( err instanceof SyntaxError && err.status === 400 && 'body' in err ) { + return res.status(400).send(err); // Bad request + } + next(); +}; + +/** + * Given a uid, returns a file node. + * + * TODO (xiaochen): It only works for MemoryFSProvider currently. + * + * @param {string} uid - The uid of the file to get. + * @returns {Promise} The file node, or null if the file does not exist. + */ +async function get_entry (uid) { + const svc_mountpoint = Context.get('services').get('mountpoint'); + const uid_selector = new NodeUIDSelector(uid); + const provider = await svc_mountpoint.get_provider(uid_selector); + + // NB: We cannot import MemoryFSProvider here because it will cause a circular dependency. + if ( provider.constructor.name !== 'MemoryFSProvider' ) { + return null; + } + + return provider.stat({ + selector: uid_selector, + }); +} + +async function is_ancestor_of (ancestor_uid, descendant_uid) { + const ancestor = await get_entry(ancestor_uid); + const descendant = await get_entry(descendant_uid); + + if ( ancestor && descendant ) { + return descendant.path.startsWith(ancestor.path); + } + + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + // root is an ancestor to all FSEntries + if ( ancestor_uid === null ) + { + return true; + } + // root is never a descendant to any FSEntries + if ( descendant_uid === null ) + { + return false; + } + + if ( typeof ancestor_uid === 'number' ) { + ancestor_uid = await id2uuid(ancestor_uid); + } + if ( typeof descendant_uid === 'number' ) { + descendant_uid = await id2uuid(descendant_uid); + } + + let parent = await db.read('SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1', [descendant_uid]); + if ( parent[0] === undefined ) + { + parent = await db.pread('SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1', [descendant_uid]); + } + if ( parent[0].uuid === ancestor_uid || parent[0].parent_uid === ancestor_uid ) { + return true; + } + // keep checking as long as parent of parent is not root + while ( parent[0].parent_uid !== null ) { + parent = await db.read('SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1', [parent[0].parent_uid]); + if ( parent[0] === undefined ) { + parent = await db.pread('SELECT `uuid`, `parent_uid` FROM `fsentries` WHERE `uuid` = ? LIMIT 1', [descendant_uid]); + } + + if ( parent[0].uuid === ancestor_uid || parent[0].parent_uid === ancestor_uid ) { + return true; + } + } + + return false; +} + +async function sign_file (fsentry, action) { + const sha256 = require('js-sha256').sha256; + + // fsentry not found + if ( fsentry === false ) { + throw { message: 'No entry found with this uid' }; + } + + const uid = fsentry.uuid ?? (fsentry.uid ?? fsentry._id); + const ttl = 9999999999999; + const secret = config.url_signature_secret; + const expires = Math.ceil(Date.now() / 1000) + ttl; + const signature = sha256(`${uid}/${action}/${secret}/${expires}`); + const contentType = mime.contentType(fsentry.name); + + // return + return { + uid: uid, + expires: expires, + signature: signature, + url: `${config.api_base_url}/file?uid=${uid}&expires=${expires}&signature=${signature}`, + read_url: `${config.api_base_url}/file?uid=${uid}&expires=${expires}&signature=${signature}`, + write_url: `${config.api_base_url}/writeFile?uid=${uid}&expires=${expires}&signature=${signature}`, + metadata_url: `${config.api_base_url}/itemMetadata?uid=${uid}&expires=${expires}&signature=${signature}`, + fsentry_type: contentType, + fsentry_is_dir: !!fsentry.is_dir, + fsentry_name: fsentry.name, + fsentry_size: fsentry.size, + fsentry_accessed: fsentry.accessed, + fsentry_modified: fsentry.modified, + fsentry_created: fsentry.created, + }; +} + +async function gen_public_token (file_uuid, ttl = 24 * 60 * 60) { + const { v4: uuidv4 } = require('uuid'); + + // get fsentry + let fsentry = await uuid2fsentry(file_uuid); + + // fsentry not found + if ( fsentry === false ) { + throw { message: 'No entry found with this uid' }; + } + + const uid = fsentry.uuid; + const token = uuidv4(); + const contentType = mime.contentType(fsentry.name); + + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_WRITE, 'filesystem'); + + // insert into DB + try { + await db.write('UPDATE fsentries SET public_token = ? WHERE id = ?', + [ + //token + token, + //fsentry_id + fsentry.id, + ]); + } catch (e) { + console.log(e); + return false; + } + + // return + return { + uid: uid, + token: token, + url: `${config.api_base_url}/pubfile?token=${token}`, + fsentry_type: contentType, + fsentry_is_dir: fsentry.is_dir, + fsentry_name: fsentry.name, + }; +} + +async function deleteUser (user_id) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + const svc_fs = services.get('filesystem'); + + // get a list of up to 5000 files owned by this user + for ( let offset = 0; true; offset += 5000 ) { + let files = await db.read(`SELECT uuid, bucket, bucket_region FROM fsentries WHERE user_id = ? AND is_dir = 0 LIMIT 5000 OFFSET ${ offset}`, + [user_id]); + + if ( !files || files.length == 0 ) break; + + // delete all files from S3 + if ( files !== null && files.length > 0 ) { + for ( let i = 0; i < files.length; i++ ) { + const node = await svc_fs.node(new NodeUIDSelector(files[i].uuid)); + + await node.provider.unlink({ + context: Context.get(), + override_immutable: true, + node, + }); + } + } + } + + // delete all fsentries from DB + await db.write('DELETE FROM fsentries WHERE user_id = ?', [user_id]); + + // delete user + await db.write('DELETE FROM user WHERE id = ?', [user_id]); +} + +function subdomain (req) { + if ( config.experimental_no_subdomain ) return 'api'; + return req.hostname.slice(0, -1 * (config.domain.length + 1)); +} + +async function jwt_auth (req) { + let token; + // HTTML Auth header + if ( req.header && req.header('Authorization') ) + { + token = req.header('Authorization'); + } + // Cookie + else if ( req.cookies && req.cookies[config.cookie_name] ) + { + token = req.cookies[config.cookie_name]; + } + // Auth token in URL + else if ( req.query && req.query.auth_token ) + { + token = req.query.auth_token; + } + // Socket + else if ( req.handshake && req.handshake.auth && req.handshake.auth.auth_token ) + { + token = req.handshake.auth.auth_token; + } + + if ( !token || token === 'null' ) + { + throw ('No auth token found'); + } + else if ( typeof token !== 'string' ) + { + throw ('token must be a string.'); + } + else + { + token = token.replace('Bearer ', ''); + } + + try { + const svc_auth = Context.get('services').get('auth'); + const actor = await svc_auth.authenticate_from_token(token); + + if ( !actor.type?.constructor?.name === 'UserActorType' ) { + throw ({ + message: APIError.create('token_unsupported') + .serialize(), + }); + } + + return { + actor, + user: actor.type.user, + token: token, + }; + } catch (e) { + if ( ! (e instanceof APIError) ) { + console.log('ERROR', e); + } + throw (e.message); + } +} + +/** + * returns all ancestors of an fsentry + * + * @param {*} fsentry_id + */ +async function ancestors (fsentry_id) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + const ancestors = []; + // first parent + let parent = await db.read('SELECT * FROM `fsentries` WHERE `id` = ? LIMIT 1', [fsentry_id]); + if ( parent.length === 0 ) { + return ancestors; + } + // get all subsequent parents + while ( parent[0].parent_uid !== null ) { + const parent_fsentry = await uuid2fsentry(parent[0].parent_uid); + parent = await db.read('SELECT * FROM `fsentries` WHERE `id` = ? LIMIT 1', [parent_fsentry.id]); + if ( parent[0].length !== 0 ) { + ancestors.push(parent[0]); + } + } + + return ancestors; +} + +function hyphenize_confirm_code (email_confirm_code) { + email_confirm_code = email_confirm_code.toString(); + email_confirm_code = + `${email_confirm_code[0] + + email_confirm_code[1] + + email_confirm_code[2] + }-${ + email_confirm_code[3] + }${email_confirm_code[4] + }${email_confirm_code[5]}`; + return email_confirm_code; +} + +async function username_exists (username) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + let rows = await db.read('SELECT EXISTS(SELECT 1 FROM user WHERE username=?) AS username_exists', [username]); + if ( rows[0].username_exists ) + { + return true; + } +} + +async function app_name_exists (name) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_READ, 'filesystem'); + + let rows = await db.read('SELECT EXISTS(SELECT 1 FROM apps WHERE apps.name=?) AS app_name_exists', [name]); + if ( rows[0].app_name_exists ) + { + return true; + } + + const svc_oldAppName = services.get('old-app-name'); + const name_info = await svc_oldAppName.check_app_name(name); + if ( name_info ) return true; +} + +function send_email_verification_code (email_confirm_code, email) { + const svc_email = Context.get('services').get('email'); + svc_email.send_email({ email }, 'email_verification_code', { + code: hyphenize_confirm_code(email_confirm_code), + }); +} + +function send_email_verification_token (email_confirm_token, email, user_uuid) { + const svc_email = Context.get('services').get('email'); + const link = `${config.origin}/confirm-email-by-token?user_uuid=${user_uuid}&token=${email_confirm_token}`; + svc_email.send_email({ email }, 'email_verification_link', { link }); +} + +function generate_random_str (length) { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const charactersLength = characters.length; + for ( let i = 0; i < length; i++ ) { + result += characters.charAt(Math.floor(Math.random() * + charactersLength)); + } + return result; +} + +/** + * Converts a given number of seconds into a human-readable string format. + * + * @param {number} seconds - The number of seconds to be converted. + * @returns {string} The time represented in the format: 'X years Y days Z hours A minutes B seconds'. + * @throws {TypeError} If the `seconds` parameter is not a number. + */ +function seconds_to_string (seconds) { + const numyears = Math.floor(seconds / 31536000); + const numdays = Math.floor((seconds % 31536000) / 86400); + const numhours = Math.floor(((seconds % 31536000) % 86400) / 3600); + const numminutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60); + const numseconds = (((seconds % 31536000) % 86400) % 3600) % 60; + return `${numyears } years ${ numdays } days ${ numhours } hours ${ numminutes } minutes ${ numseconds } seconds`; +} + +/** + * returns a list of apps that could open the fsentry, ranked by relevance + * @param {*} fsentry + * @param {*} options + */ +async function suggest_app_for_fsentry (fsentry, options) { + const suggested_apps_promises = []; + + let content_type = mime.contentType(fsentry.name); + if ( ! content_type ) content_type = ''; + + // IIFE just so fsname can stay `const` + const fsname = (() => { + if ( ! fsentry.name ) { + return 'missing-fsentry-name'; + } + let fsname = fsentry.name.toLowerCase(); + // We add `.directory` so that this works as a file association + if ( fsentry.is_dir ) fsname += '.directory'; + return fsname; + })(); + const file_extension = _path.extname(fsname).toLowerCase(); + + const any_of = (list, name) => { + return list.some(v => name.endsWith(v)); + }; + + //--------------------------------------------- + // Code + //--------------------------------------------- + const exts_code = [ + '.asm', + '.asp', + '.aspx', + '.bash', + '.c', + '.cpp', + '.css', + '.csv', + '.dhtml', + '.f', + '.go', + '.h', + '.htm', + '.html', + '.html5', + '.java', + '.jl', + '.js', + '.jsa', + '.json', + '.jsonld', + '.jsf', + '.jsp', + '.kt', + '.log', + '.lock', + '.lua', + '.md', + '.perl', + '.phar', + '.php', + '.pl', + '.py', + '.r', + '.rb', + '.rdata', + '.rda', + '.rdf', + '.rds', + '.rs', + '.rlib', + '.rpy', + '.scala', + '.sc', + '.scm', + '.sh', + '.sol', + '.sql', + '.ss', + '.svg', + '.swift', + '.toml', + '.ts', + '.wasm', + '.xhtml', + '.xml', + '.yaml', + ]; + + if ( any_of(exts_code, fsname) || !fsname.includes('.') ) { + suggested_apps_promises.push(get_app({ name: 'code' })); + suggested_apps_promises.push(get_app({ name: 'editor' })); + } + + //--------------------------------------------- + // Editor + //--------------------------------------------- + if ( + fsname.endsWith('.txt') || + // files with no extension + !fsname.includes('.') + ) { + suggested_apps_promises.push(get_app({ name: 'editor' })); + suggested_apps_promises.push(get_app({ name: 'code' })); + } + //--------------------------------------------- + // Markus + //--------------------------------------------- + if ( fsname.endsWith('.md') ) { + suggested_apps_promises.push(get_app({ name: 'markus' })); + } + //--------------------------------------------- + // Viewer + //--------------------------------------------- + if ( + fsname.endsWith('.jpg') || + fsname.endsWith('.png') || + fsname.endsWith('.webp') || + fsname.endsWith('.svg') || + fsname.endsWith('.bmp') || + fsname.endsWith('.jpeg') + ) { + suggested_apps_promises.push(get_app({ name: 'viewer' })); + } + //--------------------------------------------- + // Draw + //--------------------------------------------- + if ( + fsname.endsWith('.bmp') || + content_type.startsWith('image/') + ) { + suggested_apps_promises.push(get_app({ name: 'draw' })); + } + //--------------------------------------------- + // PDF + //--------------------------------------------- + if ( fsname.endsWith('.pdf') ) { + suggested_apps_promises.push(get_app({ name: 'pdf' })); + } + //--------------------------------------------- + // Player + //--------------------------------------------- + if ( + fsname.endsWith('.mp4') || + fsname.endsWith('.webm') || + fsname.endsWith('.mpg') || + fsname.endsWith('.mpv') || + fsname.endsWith('.mp3') || + fsname.endsWith('.m4a') || + fsname.endsWith('.ogg') + ) { + suggested_apps_promises.push(get_app({ name: 'player' })); + } + + //--------------------------------------------- + // 3rd-party apps + //--------------------------------------------- + const apps = kv.get(`assocs:${file_extension.slice(1)}:apps`) ?? []; + + for ( const app_id of apps ) { + suggested_apps_promises.push((async () => { + // retrieve app from DB + const third_party_app = await get_app({ id: app_id }); + if ( ! third_party_app ) return; + // only add if the app is approved for opening items or the app is owned by this user + if ( third_party_app.approved_for_opening_items || + (options !== undefined && options.user !== undefined && options.user.id === third_party_app.owner_user_id) ) + { + return third_party_app; + } + })()); + } + + // return list + const suggested_apps = await Promise.all(suggested_apps_promises); + return suggested_apps.filter((suggested_app, pos, self) => { + // Remove any null values caused by calling `get_app()` for apps that don't exist. + // This happens on self-host because we don't include `code`, among others. + if ( ! suggested_app ) + { + return false; + } + + // Remove any duplicate entries + return self.indexOf(suggested_app) === pos; + }); +} + +async function get_taskbar_items (user, { icon_size, no_icons } = {}) { + /** @type BaseDatabaseAccessService */ + const db = services.get('database').get(DB_WRITE, 'filesystem'); + + let taskbar_items_from_db = []; + // If taskbar items don't exist (specifically NULL) + // add default apps. + if ( ! user.taskbar_items ) { + taskbar_items_from_db = [ + { name: 'app-center', type: 'app' }, + { name: 'dev-center', type: 'app' }, + { name: 'editor', type: 'app' }, + { name: 'code', type: 'app' }, + { name: 'camera', type: 'app' }, + { name: 'recorder', type: 'app' }, + ]; + await db.write('UPDATE user SET taskbar_items = ? WHERE id = ?', + [ + JSON.stringify(taskbar_items_from_db), + user.id, + ]); + invalidate_cached_user(user); + } + // there are items from before + else { + try { + taskbar_items_from_db = JSON.parse(user.taskbar_items); + } catch (e) { + // ignore errors + } + } + + // get apps that these taskbar items represent + let taskbar_items = []; + for ( let index = 0; index < taskbar_items_from_db.length; index++ ) { + const taskbar_item_from_db = taskbar_items_from_db[index]; + if ( taskbar_item_from_db.type !== 'app' ) continue; + if ( taskbar_item_from_db.name === 'explorer' ) continue; + + let item = {}; + if ( taskbar_item_from_db.name ) + { + item = await get_app({ name: taskbar_item_from_db.name }); + } + else if ( taskbar_item_from_db.id ) + { + item = await get_app({ id: taskbar_item_from_db.id }); + } + else if ( taskbar_item_from_db.uid ) + { + item = await get_app({ uid: taskbar_item_from_db.uid }); + } + + // if item not found, skip it + if ( ! item ) continue; + + // delete sensitive attributes + delete item.id; + delete item.owner_user_id; + delete item.timestamp; + // delete item.godmode; + delete item.approved_for_listing; + delete item.approved_for_opening_items; + + if ( no_icons ) { + delete item.icon; + } else { + const svc_appIcon = services.get('app-icon'); + const icon_result = await svc_appIcon.get_icon_stream({ + app_icon: item.icon, + app_uid: item.uid, + size: icon_size, + }); + + item.icon = await icon_result.get_data_url(); + } + + // add to final object + taskbar_items.push(item); + } + + return taskbar_items; +} + +function validate_signature_auth (url, action, options = {}) { + const query = new URL(url).searchParams; + + if ( ! query.get('uid') ) + { + throw { message: '`uid` is required for signature-based authentication.' }; + } + else if ( ! action ) + { + throw { message: '`action` is required for signature-based authentication.' }; + } + else if ( ! query.get('expires') ) + { + throw { message: '`expires` is required for signature-based authentication.' }; + } + else if ( ! query.get('signature') ) + { + throw { message: '`signature` is required for signature-based authentication.' }; + } + + if ( options.uid ) { + if ( query.get('uid') !== options.uid ) { + throw { message: 'Authentication failed. `uid` does not match.' }; + } + } + + const expired = query.get('expires') && (query.get('expires') < Date.now() / 1000); + + // expired? + if ( expired ) + { + throw { message: 'Authentication failed. Signature expired.' }; + } + + const uid = query.get('uid'); + const secret = config.url_signature_secret; + const sha256 = require('js-sha256').sha256; + + // before doing anything, see if this signature is valid for 'write' action, if yes that means every action is allowed + if ( !expired && query.get('signature') === sha256(`${uid}/write/${secret}/${query.get('expires')}`) ) + { + return true; + } + // if not, check specific actions + else if ( !expired && query.get('signature') === sha256(`${uid}/${action}/${secret}/${query.get('expires')}`) ) + { + return true; + } + // auth failed + else + { + throw { message: 'Authentication failed' }; + } +} + +function get_url_from_req (req) { + return `${req.protocol }://${ req.get('host') }${req.originalUrl}`; +} + +async function mv (options) { + throw new Error('legacy mv function called'); +} + +/** + * Formats a number with grouped thousands. + * + * @param {number|string} number - The number to be formatted. If a string is provided, it must only contain numerical characters, plus and minus signs, and the letter 'E' or 'e' (for scientific notation). + * @param {number} decimals - The number of decimal points. If a non-finite number is provided, it defaults to 0. + * @param {string} [dec_point='.'] - The character used for the decimal point. Defaults to '.' if not provided. + * @param {string} [thousands_sep=','] - The character used for the thousands separator. Defaults to ',' if not provided. + * @returns {string} The formatted number with grouped thousands, using the specified decimal point and thousands separator characters. + * @throws {TypeError} If the `number` parameter cannot be converted to a finite number, or if the `decimals` parameter is non-finite and cannot be converted to an absolute number. + */ +function number_format (number, decimals, dec_point, thousands_sep) { + // Strip all characters but numerical ones. + number = (`${number }`).replace(/[^0-9+\-Ee.]/g, ''); + let n = !isFinite(+number) ? 0 : +number, + prec = !isFinite(+decimals) ? 0 : Math.abs(decimals), + sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep, + dec = (typeof dec_point === 'undefined') ? '.' : dec_point, + s = '', + toFixedFix = function (n, prec) { + const k = Math.pow(10, prec); + return `${ Math.round(n * k) / k}`; + }; + // Fix for IE parseFloat(0.55).toFixed(0) = 0; + s = (prec ? toFixedFix(n, prec) : `${ Math.round(n)}`).split('.'); + if ( s[0].length > 3 ) { + s[0] = s[0].replace(/\B(?=(?:\d{3})+(?!\d))/g, sep); + } + if ( (s[1] || '').length < prec ) { + s[1] = s[1] || ''; + s[1] += new Array(prec - s[1].length + 1).join('0'); + } + return s.join(dec); +} + +module.exports = { + ancestors, + app_name_exists, + app_exists, + body_parser_error_handler, + byte_format, + change_username, + chkperm, + convert_path_to_fsentry, + cp, + deleteUser, + get_descendants, + get_dir_size, + gen_public_token, + get_taskbar_items, + get_url_from_req, + generate_random_str, + get_app, + get_user, + invalidate_cached_user, + invalidate_cached_user_by_id, + has_shared_with, + hyphenize_confirm_code, + id2fsentry, + id2path, + id2uuid, + is_ancestor_of, + is_empty, + is_shared_with, + is_shared_with_anyone, + ...require('./validation'), + is_temp_users_disabled, + is_user_signup_disabled, + jwt_auth, + mv, + number_format, + refresh_apps_cache, + refresh_associations_cache, + resolve_glob, + rm, + seconds_to_string, + send_email_verification_code, + send_email_verification_token, + sign_file, + subdomain, + suggest_app_for_fsentry, + df, + username_exists, + uuid2fsentry, + validate_fsentry_name, + validate_signature_auth, + tmp_provide_services, +}; diff --git a/src/backend/src/index.js b/src/backend/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..724b0bb11a901a7bf7ec0729417e43e1bd7135f4 --- /dev/null +++ b/src/backend/src/index.js @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +'use strict'; + +const { Kernel } = require('./Kernel'); +const CoreModule = require('./CoreModule'); +const { CaptchaModule } = require('./modules/captcha/CaptchaModule'); // Add CaptchaModule + +const testlaunch = () => { + const k = new Kernel(); + k.add_module(new CoreModule()); + k.add_module(new CaptchaModule()); // Register the CaptchaModule + k.boot(); +}; + +module.exports = { testlaunch }; diff --git a/src/backend/src/kernel/modutil.js b/src/backend/src/kernel/modutil.js new file mode 100644 index 0000000000000000000000000000000000000000..4b3864964ecd1b3751e0d625939b7a27f1671140 --- /dev/null +++ b/src/backend/src/kernel/modutil.js @@ -0,0 +1,61 @@ +const fs = require('fs').promises; +const path = require('path'); + +async function prependToJSFiles (directory, snippet) { + const jsExtensions = new Set(['.js', '.cjs', '.mjs', '.ts']); + + async function processDirectory (dir) { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const promises = []; + + for ( const entry of entries ) { + const fullPath = path.join(dir, entry.name); + + if ( entry.isDirectory() ) { + // Skip common directories that shouldn't be modified + if ( ! shouldSkipDirectory(entry.name) ) { + promises.push(processDirectory(fullPath)); + } + } else if ( entry.isFile() && jsExtensions.has(path.extname(entry.name)) ) { + promises.push(prependToFile(fullPath, snippet)); + } + } + + await Promise.all(promises); + } catch ( error ) { + throw new Error(`error processing directory ${dir}`, { + cause: error, + }); + } + } + + function shouldSkipDirectory (dirName) { + const skipDirs = new Set([ + 'node_modules', + 'gui', + ]); + if ( skipDirs.has(dirName) ) return true; + if ( dirName.startsWith('.') ) return true; + return false; + } + + async function prependToFile (filePath, snippet) { + try { + const content = await fs.readFile(filePath, 'utf8'); + if ( content.startsWith('//!no-prepend') ) return; + const newContent = snippet + content; + await fs.writeFile(filePath, newContent, 'utf8'); + } catch ( error ) { + throw new Error(`error processing file ${filePath}`, { + cause: error, + }); + } + } + + await processDirectory(directory); +} + +module.exports = { + prependToJSFiles, +}; diff --git a/src/backend/src/middleware/abuse.js b/src/backend/src/middleware/abuse.js new file mode 100644 index 0000000000000000000000000000000000000000..12dd28b19b0fc1292d8f2f52702b3c302b010e1b --- /dev/null +++ b/src/backend/src/middleware/abuse.js @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../api/APIError'); +const config = require('../config'); +const { Context } = require('../util/context'); + +const abuse = options => (req, res, next) => { + if ( config.disable_abuse_checks ) { + next(); return; + } + + const requester = Context.get('requester'); + + if ( options.no_bots ) { + if ( requester.is_bot ) { + if ( options.shadow_ban_responder ) { + return options.shadow_ban_responder(req, res); + } + throw APIError.create('forbidden'); + } + } + + if ( options.puter_origin ) { + if ( ! requester.is_puter_origin() ) { + throw APIError.create('forbidden'); + } + } + + next(); +}; + +module.exports = abuse; diff --git a/src/backend/src/middleware/anticsrf.js b/src/backend/src/middleware/anticsrf.js new file mode 100644 index 0000000000000000000000000000000000000000..500ba228dcd0dc462ab74661fea03b036d95de4d --- /dev/null +++ b/src/backend/src/middleware/anticsrf.js @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const APIError = require('../api/APIError'); + +/** + * Creates an anti-CSRF middleware that validates CSRF tokens in incoming requests. + * This middleware protects against Cross-Site Request Forgery attacks by verifying + * that requests contain a valid anti-CSRF token in the request body. + * + * @param {Object} options - Configuration options for the middleware + * @returns {Function} Express middleware function that validates CSRF tokens + * + * @example + * // Apply anti-CSRF protection to a route + * app.post('/api/secure-endpoint', anticsrf(), (req, res) => { + * // Route handler code + * }); + */ +const anticsrf = options => async (req, res, next) => { + const svc_antiCSRF = req.services.get('anti-csrf'); + if ( ! req.body.anti_csrf ) { + const err = APIError.create('anti-csrf-incorrect'); + err.write(res); + return; + } + const has = svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf); + if ( ! has ) { + const err = APIError.create('anti-csrf-incorrect'); + err.write(res); + return; + } + + next(); +}; + +module.exports = anticsrf; diff --git a/src/backend/src/middleware/auth.js b/src/backend/src/middleware/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..d7058720a6e44900e82ef4e6fd5135feac2ad510 --- /dev/null +++ b/src/backend/src/middleware/auth.js @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +'use strict'; +const APIError = require('../api/APIError'); +const { UserActorType } = require('../services/auth/Actor'); +const auth2 = require('./auth2'); + +const auth = async (req, res, next) => { + let auth2_ok = false; + try { + // Delegate to new middleware + await auth2(req, res, () => { + auth2_ok = true; + }); + if ( ! auth2_ok ) return; + + // Everything using the old reference to the auth middleware + // should only allow session tokens + if ( ! (req.actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + next(); + } + // auth failed + catch (e) { + return res.status(401).send(e); + } +}; + +module.exports = auth; \ No newline at end of file diff --git a/src/backend/src/middleware/auth2.js b/src/backend/src/middleware/auth2.js new file mode 100644 index 0000000000000000000000000000000000000000..059e926a64097205a2d2bc00f317f9cbeb4bdff6 --- /dev/null +++ b/src/backend/src/middleware/auth2.js @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const configurable_auth = require('./configurable_auth'); + +const auth2 = configurable_auth({ optional: false }); + +module.exports = auth2; diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js new file mode 100644 index 0000000000000000000000000000000000000000..dd3cc7fa8fb05d8c11b3e2d0560afd07d5424826 --- /dev/null +++ b/src/backend/src/middleware/configurable_auth.js @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../api/APIError'); +const config = require('../config'); +const { LegacyTokenError } = require('../services/auth/AuthService'); +const { Context } = require('../util/context'); + +// The "/whoami" endpoint is a special case where we want to allow +// a legacy token to be used for authentication. The "/whoami" +// endpoint will then return a new token for further requests. +// +const is_whoami = (req) => { + if ( ! config.legacy_token_migrate ) return; + + if ( req.path !== '/whoami' ) return; + + // const subdomain = req.subdomains[res.subdomains.length - 1]; + // if ( subdomain !== 'api' ) return; + return true; +}; + +// TODO: Allow auth middleware to be used without requiring +// authentication. This will allow us to use the auth middleware +// in endpoints that do not require authentication, but can +// provide additional functionality if the user is authenticated. +const configurable_auth = options => async (req, res, next) => { + if ( options?.no_options_auth && req.method === 'OPTIONS' ) { + return next(); + } + + const optional = options?.optional; + + // Request might already have been authed (PreAuthService) + if ( req.actor ) next(); + + // === Getting the Token === + // This step came from jwt_auth in src/helpers.js + // However, since request-response handling is a concern of the + // auth middleware, it makes more sense to put it here. + + let token; + // Auth token in body + if ( req.body && req.body.auth_token ) + { + token = req.body.auth_token; + } + // HTTML Auth header + else if ( req.header && req.header('Authorization') && !req.header('Authorization').startsWith('Basic ') && req.header('Authorization') !== 'Bearer' ) { // Bearer with no space is something office does + token = req.header('Authorization'); + token = token.replace('Bearer ', '').trim(); + if ( token === 'undefined' ) { + APIError.create('unexpected_undefined', null, { + msg: 'The Authorization token cannot be the string "undefined"', + }); + } + } + // Cookie + else if ( req.cookies && req.cookies[config.cookie_name] ) + { + token = req.cookies[config.cookie_name]; + } + // Auth token in URL + else if ( req.query && req.query.auth_token ) + { + token = req.query.auth_token; + } + // Socket + else if ( req.handshake && req.handshake.query && req.handshake.query.auth_token ) + { + token = req.handshake.query.auth_token; + } + + if ( !token || token.startsWith('Basic ') ) { + if ( optional ) { + next(); + return; + } + APIError.create('token_missing').write(res); + return; + } else if ( typeof token !== 'string' ) { + APIError.create('token_auth_failed').write(res); + return; + } else { + token = token.replace('Bearer ', ''); + } + + // === Delegate to AuthService === + // AuthService will attempt to authenticate the token and return + // an Actor object, which is a high-level representation of the + // entity that is making the request; it could be a user, an app + // acting on behalf of a user, or an app acting on behalf of itself. + + const context = Context.get(); + const services = context.get('services'); + const svc_auth = services.get('auth'); + + let actor; try { + actor = await svc_auth.authenticate_from_token(token); + } catch ( e ) { + if ( e instanceof APIError ) { + e.write(res); + return; + } + if ( e instanceof LegacyTokenError && is_whoami(req) ) { + const new_info = await svc_auth.check_session(token, { + req, + from_upgrade: true, + }); + context.set('actor', new_info.actor); + context.set('user', new_info.user); + req.new_token = new_info.token; + req.token = new_info.token; + req.user = new_info.user; + req.actor = new_info.actor; + + if ( req.user?.suspended ) { + throw APIError.create('forbidden'); + } + + res.cookie(config.cookie_name, new_info.token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + next(); + return; + } + const re = APIError.create('token_auth_failed'); + re.write(res); + return; + } + + // === Populate Context === + context.set('actor', actor); + if ( actor.type.user ) { + if ( actor.type.user?.suspended ) { + throw APIError.create('forbidden'); + } + context.set('user', actor.type.user); + } + + // === Populate Request === + req.actor = actor; + req.user = actor.type.user; + req.token = token; + + next(); +}; + +module.exports = configurable_auth; \ No newline at end of file diff --git a/src/backend/src/middleware/featureflag.js b/src/backend/src/middleware/featureflag.js new file mode 100644 index 0000000000000000000000000000000000000000..9b5650438f33ab5f08a9473df00a2013cb863e48 --- /dev/null +++ b/src/backend/src/middleware/featureflag.js @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const APIError = require('../api/APIError'); +const { Context } = require('../util/context'); + +const featureflag = options => async (req, res, next) => { + const { feature } = options; + + const context = Context.get(); + const services = context.get('services'); + const svc_featureFlag = services.get('feature-flag'); + + if ( ! await svc_featureFlag.check({ + actor: req.actor, + }, feature) ) { + const e = APIError.create('forbidden'); + e.write(res); + return; + } + + next(); +}; + +module.exports = featureflag; diff --git a/src/backend/src/middleware/measure.js b/src/backend/src/middleware/measure.js new file mode 100644 index 0000000000000000000000000000000000000000..f9562a77aa678b88ffa5cd4d515f3bc6f0235eea --- /dev/null +++ b/src/backend/src/middleware/measure.js @@ -0,0 +1,94 @@ +const { pausing_tee } = require('../util/streamutil'); +const putility = require('@heyputer/putility'); + +const _intercept_req = ({ data, req, next }) => { + if ( ! req.readable ) { + return next(); + } + + try { + const [req_monitor, req_pass] = pausing_tee(req, 2); + + req_monitor.on('data', (chunk) => { + data.sz_incoming += chunk.length; + }); + + const replaces = ['readable', 'pipe', 'on', 'once', 'removeListener']; + for ( const replace of replaces ) { + const replacement = req_pass[replace]; + Object.defineProperty(req, replace, { + get () { + if ( typeof replacement === 'function' ) { + return replacement.bind(req_pass); + } + return replacement; + }, + }); + } + } catch (e) { + console.error(e); + return next(); + } +}; + +const _intercept_res = ({ data, res, next }) => { + if ( ! res.writable ) { + return next(); + } + + try { + const org_write = res.write; + const org_end = res.end; + + // Override the `write` method + res.write = function (chunk, ...args) { + if ( Buffer.isBuffer(chunk) ) { + data.sz_outgoing += chunk.length; + } else if ( typeof chunk === 'string' ) { + data.sz_outgoing += Buffer.byteLength(chunk); + } + return org_write.apply(res, [chunk, ...args]); + }; + + // Override the `end` method + res.end = function (chunk, ...args) { + if ( chunk ) { + if ( Buffer.isBuffer(chunk) ) { + data.sz_outgoing += chunk.length; + } else if ( typeof chunk === 'string' ) { + data.sz_outgoing += Buffer.byteLength(chunk); + } + } + const result = org_end.apply(res, [chunk, ...args]); + return result; + }; + } catch (e) { + console.error(e); + return next(); + } +}; + +function measure () { + return async (req, res, next) => { + const data = { + sz_incoming: 0, + sz_outgoing: 0, + }; + + _intercept_req({ data, req }); + _intercept_res({ data, res }); + + req.measurements = new putility.libs.promise.TeePromise(); + + // Wait for the request to finish processing + res.on('finish', () => { + req.measurements.resolve(data); + // console.log(`Incoming Data: ${data.sz_incoming} bytes`); + // console.log(`Outgoing Data: ${data.sz_outgoing} bytes`); // future + }); + + next(); + }; +} + +module.exports = measure; diff --git a/src/backend/src/middleware/subdomain.js b/src/backend/src/middleware/subdomain.js new file mode 100644 index 0000000000000000000000000000000000000000..28338fef98201219dfe5d0b1da1a08970dabb7b2 --- /dev/null +++ b/src/backend/src/middleware/subdomain.js @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/** + * This middleware checks the subdomain, and if the subdomain doesn't + * match it calls `next('route')` to skip the current route. + * Be sure to use this before any middleware that might erroneously + * block the request. + * + * @param {string|string[]} allowedSubdomains - The subdomain to allow; + * if an array, any of the subdomains in the array will be allowed. + * + * @returns {function} - An express middleware function + */ +const subdomain = allowedSubdomains => { + if ( ! Array.isArray(allowedSubdomains) ) { + allowedSubdomains = [allowedSubdomains]; + } + return async (req, res, next) => { + // Note: at the time of implementing this, there is a config + // option called `experimental_no_subdomain` that is designed + // to lie and tell us the subdomain is `api` when it's not. + const actual_subdomain = require('../helpers').subdomain(req); + if ( ! allowedSubdomains.includes(actual_subdomain) ) { + next('route'); + return; + } + + next(); + }; +}; + +module.exports = subdomain; diff --git a/src/backend/src/middleware/verified.js b/src/backend/src/middleware/verified.js new file mode 100644 index 0000000000000000000000000000000000000000..554d3959dc323458272326b214600638a2ea18a1 --- /dev/null +++ b/src/backend/src/middleware/verified.js @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const config = require('../config'); + +const verified = async (req, res, next) => { + if ( ! config.strict_email_verification_required ) { + next(); + return; + } + + if ( ! req.user.requires_email_confirmation ) { + next(); + return; + } + + if ( req.user.email_confirmed ) { + next(); + return; + } + + res.status(400).send({ + code: 'account_is_not_verified', + message: 'Account is not verified', + }); +}; + +module.exports = verified; diff --git a/src/backend/src/modules/ai/PuterAIChatModule.js b/src/backend/src/modules/ai/PuterAIChatModule.js new file mode 100644 index 0000000000000000000000000000000000000000..731d78e979073c79f1ab2b8e2b3504ee57ab033b --- /dev/null +++ b/src/backend/src/modules/ai/PuterAIChatModule.js @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { AdvancedBase } from '@heyputer/putility'; +import config from '../../config.js'; +import { AIInterfaceService } from '../../services/ai/AIInterfaceService.js'; +import { AIChatService } from '../../services/ai/chat/AIChatService.js'; +import { AIImageGenerationService } from '../../services/ai/image/AIImageGenerationService.js'; +import { AWSTextractService } from '../../services/ai/ocr/AWSTextractService.js'; +import { ElevenLabsVoiceChangerService } from '../../services/ai/sts/ElevenLabsVoiceChangerService.js'; +import { OpenAISpeechToTextService } from '../../services/ai/stt/OpenAISpeechToTextService.js'; +import { AWSPollyService } from '../../services/ai/tts/AWSPollyService.js'; +import { ElevenLabsTTSService } from '../../services/ai/tts/ElevenLabsTTSService.js'; +import { OpenAITTSService } from '../../services/ai/tts/OpenAITTSService.js'; +import { TogetherVideoGenerationService } from '../../services/ai/video/TogetherVideoGenerationService.js'; +import { OpenAIVideoGenerationService } from '../../services/ai/video/OpenAIVideoGenerationService.js'; +// import { AIVideoGenerationService } from '../../services/ai/video/AIVideoGenerationService.js'; + +/** +* PuterAIModule class extends AdvancedBase to manage and register various AI services. +* This module handles the initialization and registration of multiple AI-related services +* including text processing, speech synthesis, chat completion, and image generation. +* Services are conditionally registered based on configuration settings, allowing for +* flexible deployment with different AI providers like AWS, OpenAI, Claude, Together AI, +* Mistral, Groq, and XAI. +* @extends AdvancedBase +*/ +export class PuterAIModule extends AdvancedBase { + /** + * Module for managing AI-related services in the Puter platform + * Extends AdvancedBase to provide core functionality + * Handles registration and configuration of various AI services like OpenAI, Claude, AWS services etc. + */ + async install (context) { + const services = context.get('services'); + + services.registerService('__ai-interfaces', AIInterfaceService); + + // completion ai service + services.registerService('ai-chat', AIChatService); + + // image generation ai service + services.registerService('ai-image', AIImageGenerationService); + + // video generation ai service + // services.registerService('ai-video', AIVideoGenerationService); + + // TODO DS: centralize other service types too + // TODO: services should govern their own availability instead of the module deciding what to register + if ( config?.services?.['aws-textract']?.aws ) { + + services.registerService('aws-textract', AWSTextractService); + } + + if ( config?.services?.['aws-polly']?.aws ) { + + services.registerService('aws-polly', AWSPollyService); + } + + if ( config?.services?.['elevenlabs'] || config?.elevenlabs ) { + services.registerService('elevenlabs-tts', ElevenLabsTTSService); + + services.registerService('elevenlabs-voice-changer', ElevenLabsVoiceChangerService); + } + + if ( config?.services?.openai || config?.openai ) { + + services.registerService('openai-tts', OpenAITTSService); + services.registerService('openai-speech2txt', OpenAISpeechToTextService); + + // TODO DS: move to video service + services.registerService('openai-video-generation', OpenAIVideoGenerationService); + } + + if ( config?.services?.['together-ai'] ) { + // TODO DS: move to video service + services.registerService('together-video-generation', TogetherVideoGenerationService); + } + } +} diff --git a/src/backend/src/modules/apps/AppIconService.js b/src/backend/src/modules/apps/AppIconService.js new file mode 100644 index 0000000000000000000000000000000000000000..c234d5bf298648a2e40c92e28dd4e81f6dc50973 --- /dev/null +++ b/src/backend/src/modules/apps/AppIconService.js @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { HLWrite } = require('../../filesystem/hl_operations/hl_write'); +const { LLMkdir } = require('../../filesystem/ll_operations/ll_mkdir'); +const { LLRead } = require('../../filesystem/ll_operations/ll_read'); +const { NodePathSelector } = require('../../filesystem/node/selectors'); +const { get_app } = require('../../helpers'); +const { Endpoint } = require('../../util/expressutil'); +const { buffer_to_stream, stream_to_buffer } = require('../../util/streamutil'); +const BaseService = require('../../services/BaseService.js'); + +const ICON_SIZES = [16, 32, 64, 128, 256, 512]; + +const DEFAULT_APP_ICON = require('./default-app-icon.js'); +const IconResult = require('./lib/IconResult.js'); + +/** + * AppIconService handles icon generation and serving for apps. + * + * This is done by listening to the `app.new-icon` event which is + * dispatched by AppES. `sharp` is used to resize the images to + * pre-selected sizees in the `ICON_SIZES` constant defined above. + * + * Icons are stored in and served from the `/system/app_icons` + * directory. If the system user does not have this directory, + * it will be created in the consolidation boot phase after + * UserService emits the `user.system-user-ready` event on the + * service container event bus. + */ +class AppIconService extends BaseService { + static MODULES = { + sharp: require('sharp'), + bmp: require('sharp-bmp'), + ico: require('sharp-ico'), + }; + + static ICON_SIZES = ICON_SIZES; + + /** + * AppIconService listens to this event to register the + * endpoint /app-icon/:app_uid/:size which serves the + * app icon at the requested size. + */ + async ['__on_install.routes'] (_, { app }) { + Endpoint({ + route: '/app-icon/:app_uid/:size', + methods: ['GET'], + handler: async (req, res) => { + // Validate parameters + let { app_uid, size } = req.params; + if ( ! ICON_SIZES.includes(Number(size)) ) { + res.status(400).send('Invalid size'); + return; + } + if ( ! app_uid.startsWith('app-') ) { + app_uid = `app-${app_uid}`; + } + + const { + stream, + mime, + } = await this.get_icon_stream({ app_uid, size }); + + res.set('Content-Type', mime); + stream.pipe(res); + }, + }).attach(app); + } + + get_sizes () { + return this.constructor.ICON_SIZES; + } + + async iconify_apps ({ apps, size }) { + return await Promise.all(apps.map(async app => { + const icon_result = await this.get_icon_stream({ + app_icon: app.icon, + app_uid: app.uid ?? app.uuid, + size: size, + }); + + if ( icon_result.data_url ) { + app.icon = icon_result.data_url; + return app; + } + + try { + const buffer = await stream_to_buffer(icon_result.stream); + const resp_data_url = `data:${icon_result.mime};base64,${buffer.toString('base64')}`; + + app.icon = resp_data_url; + } catch (e) { + this.errors.report('get-launch-apps:icon-stream', { + source: e, + }); + } + return app; + })); + } + + async get_icon_stream (params) { + const result = await this.get_icon_stream_(params); + return new IconResult(result); + } + + async get_icon_stream_ ({ app_icon, app_uid, size, tries = 0 }) { + // If there is an icon provided, and it's an SVG, we'll just return it + if ( app_icon ) { + const [metadata, data] = app_icon.split(','); + const input_mime = metadata.split(';')[0].split(':')[1]; + + // svg icons will be sent as-is + if ( input_mime === 'image/svg+xml' ) { + return { + mime: 'image/svg+xml', + get stream () { + return buffer_to_stream(Buffer.from(data, 'base64')); + }, + data_url: app_icon, + }; + } + } + + // Get icon file node + const dir_app_icons = await this.get_app_icons(); + const node = await dir_app_icons.getChild(`${app_uid}-${size}.png`); + + const get_fallback_icon = async () => { + // Use database-stored icon as a fallback + app_icon = app_icon || await (async () => { + const app = await get_app({ uid: app_uid }); + return app.icon || DEFAULT_APP_ICON; + })(); + const [metadata, base64] = app_icon.split(','); + const mime = metadata.split(';')[0].split(':')[1]; + const img = Buffer.from(base64, 'base64'); + return { + mime, + stream: buffer_to_stream(img), + }; + }; + + if ( ! await node.exists() ) { + return await get_fallback_icon(); + } + + try { + const svc_su = this.services.get('su'); + const ll_read = new LLRead(); + return { + mime: 'image/png', + stream: await ll_read.run({ + fsNode: node, + actor: await svc_su.get_system_actor(), + }), + }; + } catch (e) { + this.errors.report('AppIconService.get_icon_stream', { + source: e, + }); + if ( tries < 1 ) { + // We can choose the fallback icon in these two ways: + + // Choose the next size up, or 256 if we're already at 512; + // this prioritizes icon quality over speed and bandwidth. + let second_size = size < 512 ? size * 2 : 256; + + // Choose the next size down, or 32 if we're already at 16; + // this prioritizes speed and bandwidth over icon quality. + // let second_size = size > 16 ? size / 2 : 32; + + return await this.get_icon_stream({ + app_uid, size: second_size, tries: tries + 1, + }); + } + return await get_fallback_icon(); + } + } + + /** + * Returns an FSNodeContext instance for the app icons + * directory. + */ + async get_app_icons () { + if ( this.dir_app_icons ) { + return this.dir_app_icons; + } + + const svc_fs = this.services.get('filesystem'); + const dir_app_icons = await svc_fs.node(new NodePathSelector('/system/app_icons')); + + return this.dir_app_icons = dir_app_icons; + } + + get_sharp ({ metadata, input }) { + const type = metadata.split(';')[0].split(':')[1]; + + if ( type === 'image/bmp' ) { + return this.modules.bmp.sharpFromBmp(input); + } + + const icotypes = ['image/x-icon', 'image/vnd.microsoft.icon']; + if ( icotypes.includes(type) ) { + const sharps = this.modules.ico.sharpsFromIco(input); + return sharps[0]; + } + + return this.modules.sharp(input); + } + + /** + * AppIconService listens to this event to create the + * `/system/app_icons` directory if it does not exist, + * and then to register the event listener for `app.new-icon`. + */ + async ['__on_user.system-user-ready'] () { + const svc_su = this.services.get('su'); + const svc_fs = this.services.get('filesystem'); + const svc_user = this.services.get('user'); + + const dir_system = await svc_user.get_system_dir(); + + // Ensure app icons directory exists + await svc_su.sudo(async () => { + const dir_app_icons = await svc_fs.node(new NodePathSelector('/system/app_icons')); + if ( ! await dir_app_icons.exists() ) { + const ll_mkdir = new LLMkdir(); + await ll_mkdir.run({ + parent: dir_system, + name: 'app_icons', + actor: await svc_su.get_system_actor(), + }); + } + this.dir_app_icons = dir_app_icons; + }); + + // Listen for new app icons + const svc_event = this.services.get('event'); + svc_event.on('app.new-icon', async (_, data) => { + await this.create_app_icons({ data }); + }); + } + + async create_app_icons ({ data }) { + const svc_su = this.services.get('su'); + const dir_app_icons = await this.get_app_icons(); + + // Writing icons as the system user + const icon_jobs = []; + for ( const size of ICON_SIZES ) { + icon_jobs.push((async () => { + await svc_su.sudo(async () => { + const filename = `${data.app_uid}-${size}.png`; + const data_url = data.data_url; + const [metadata, base64] = data_url.split(','); + const input = Buffer.from(base64, 'base64'); + + const sharp_instance = this.get_sharp({ + metadata, + input, + }); + + // NOTE: A stream would be more ideal than a buffer here + // but we have no way of knowing the output size + // before we finish processing the image. + const output = await sharp_instance + .resize(size) + .png() + .toBuffer(); + + const sys_actor = await svc_su.get_system_actor(); + const hl_write = new HLWrite(); + await hl_write.run({ + destination_or_parent: dir_app_icons, + specified_name: filename, + overwrite: true, + actor: sys_actor, + user: sys_actor.type.user, + no_thumbnail: true, + file: { + size: output.length, + name: filename, + mimetype: 'image/png', + type: 'image/png', + stream: buffer_to_stream(output), + }, + }); + }); + })()); + } + await Promise.all(icon_jobs); + } + + async _init () { + } +} + +module.exports = { + AppIconService, +}; diff --git a/src/backend/src/modules/apps/AppInformationService.js b/src/backend/src/modules/apps/AppInformationService.js new file mode 100644 index 0000000000000000000000000000000000000000..f16c83d8f0abbc1f2d4c4eb90d6f60b474ec113e --- /dev/null +++ b/src/backend/src/modules/apps/AppInformationService.js @@ -0,0 +1,799 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { origin_from_url } = require('../../util/urlutil'); +const { DB_READ } = require('../../services/database/consts'); +const BaseService = require('../../services/BaseService'); +const { kv } = require('../../util/kvSingleton'); + +// Currently leaks memory (not sure why yet, but icons are a factor) +const ENABLE_REFRESH_APP_CACHE = false; + +/** +* @class AppInformationService +* @description +* The AppInformationService class manages application-related information, +* including caching, statistical data, and tags for applications within the Puter ecosystem. +* It provides methods for refreshing application data, managing app statistics, +* and handling tags associated with apps. This service is crucial for maintaining +* up-to-date information about applications, facilitating features like app listings, +* recent apps, and tag-based app discovery. +*/ +class AppInformationService extends BaseService { + static LOG_DEBUG = true; + + _construct () { + this.collections = {}; + this.collections.recent = []; + + this.tags = {}; + + // MySQL date format mapping for different groupings + this.mysqlDateFormats = { + 'hour': '%Y-%m-%d %H:00:00', + 'day': '%Y-%m-%d', + 'week': '%Y-%U', + 'month': '%Y-%m', + 'year': '%Y', + }; + + // ClickHouse date format mapping for different groupings + this.clickhouseGroupByFormats = { + 'hour': 'toStartOfHour(fromUnixTimestamp(ts))', + 'day': 'toStartOfDay(fromUnixTimestamp(ts))', + 'week': 'toStartOfWeek(fromUnixTimestamp(ts))', + 'month': 'toStartOfMonth(fromUnixTimestamp(ts))', + 'year': 'toStartOfYear(fromUnixTimestamp(ts))', + }; + } + + '__on_boot.consolidation' () { + (async () => { + try { + ENABLE_REFRESH_APP_CACHE && await this._refresh_app_cache(); + await this._refresh_app_stats(); + await this._refresh_recent_cache(); + } catch (e) { + console.error('Some app cache portion failed to populate:', e); + } + ENABLE_REFRESH_APP_CACHE && setInterval(async () => { + try { + await this._refresh_app_cache(); + } catch (e) { + console.error('App cache failed to update:', e); + } + }, 30 * 1000); + setInterval(async () => { + try { + await this._refresh_app_stats(); + await this._refresh_recent_cache(); + } catch (e) { + console.error('App stats cache failed to update:', e); + } + }, 4 * 60 * 1000); + })(); + } + + /** + * Retrieves and returns statistical data for a specific application over different time periods. + * + * This method fetches various metrics such as the number of times the app has been opened, + * the count of unique users who have opened the app, and the number of referrals attributed to the app. + * It supports different time periods such as today, yesterday, past 7 days, past 30 days, and all time. + * + * @param {string} app_uid - The unique identifier for the application. + * @param {Object} [options] - Optional parameters to customize the query + * @param {string} [options.period='all'] - Time period for stats: 'today', 'yesterday', '7d', '30d', 'this_month', 'last_month', 'this_year', 'last_year', '12m', 'all' + * @param {string} [options.grouping=undefined] - Time grouping for stats: 'hour', 'day', 'week', 'month', 'year' + * @returns {Promise} An object containing: + * - {Object} open_count - Open counts for different time periods + * - {Object} user_count - Uniqu>e user counts for different time periods + * - {number|null} referral_count - The number of referrals (all-time only) + */ + async get_stats (app_uid, options = {}) { + let period = options.period ?? 'all'; + let stats_grouping = options.grouping; + let app_creation_ts = options.created_at; + + // Check cache first if period is 'all' and no grouping is requested + if ( period === 'all' && !stats_grouping ) { + const key_open_count = `apps:open_count:uid:${app_uid}`; + const key_user_count = `apps:user_count:uid:${app_uid}`; + const key_referral_count = `apps:referral_count:uid:${app_uid}`; + + const [cached_open_count, cached_user_count, cached_referral_count] = await Promise.all([ + kv.get(key_open_count), + kv.get(key_user_count), + kv.get(key_referral_count), + ]); + + if ( cached_open_count !== null && cached_user_count !== null ) { + return { + open_count: parseInt(cached_open_count), + user_count: parseInt(cached_user_count), + referral_count: cached_referral_count, + }; + } + } + + const db = this.services.get('database').get(DB_READ, 'apps'); + + const getTimeRange = (period) => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch ( period ) { + case 'today': + return { + start: today.getTime(), + end: now.getTime(), + }; + case 'yesterday': { + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + return { + start: yesterday.getTime(), + end: today.getTime() - 1, + }; + } + case '7d': { + const weekAgo = new Date(now); + weekAgo.setDate(weekAgo.getDate() - 7); + return { + start: weekAgo.getTime(), + end: now.getTime(), + }; + } + case '30d': { + const monthAgo = new Date(now); + monthAgo.setDate(monthAgo.getDate() - 30); + return { + start: monthAgo.getTime(), + end: now.getTime(), + }; + } + case 'this_week': { + const firstDayOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay()); + return { + start: firstDayOfWeek.getTime(), + end: now.getTime(), + }; + } + case 'last_week': { + const firstDayOfLastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay() - 7); + const firstDayOfThisWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay()); + return { + start: firstDayOfLastWeek.getTime(), + end: firstDayOfThisWeek.getTime() - 1, + }; + } + case 'this_month': { + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + return { + start: firstDayOfMonth.getTime(), + end: now.getTime(), + }; + } + case 'last_month': { + const firstDayOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const firstDayOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1); + return { + start: firstDayOfLastMonth.getTime(), + end: firstDayOfThisMonth.getTime() - 1, + }; + } + case 'this_year': { + const firstDayOfYear = new Date(now.getFullYear(), 0, 1); + return { + start: firstDayOfYear.getTime(), + end: now.getTime(), + }; + } + case 'last_year': { + const firstDayOfLastYear = new Date(now.getFullYear() - 1, 0, 1); + const firstDayOfThisYear = new Date(now.getFullYear(), 0, 1); + return { + start: firstDayOfLastYear.getTime(), + end: firstDayOfThisYear.getTime() - 1, + }; + } + case '12m': { + const twelveMonthsAgo = new Date(now); + twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12); + return { + start: twelveMonthsAgo.getTime(), + end: now.getTime(), + }; + } + case 'all':{ + const start = new Date(app_creation_ts); + return { + start: start.getTime(), + end: now.getTime(), + }; + } + default: + return null; + } + }; + + const timeRange = getTimeRange(period); + + // Handle time-based grouping if stats_grouping is specified + if ( stats_grouping ) { + const timeFormat = this.mysqlDateFormats[stats_grouping]; + if ( ! timeFormat ) { + throw new Error(`Invalid stats_grouping: ${stats_grouping}. Supported values are: hour, day, week, month, year`); + } + + // Generate all periods for the time range + const allPeriods = this.generateAllPeriods(new Date(timeRange.start), + new Date(timeRange.end), + stats_grouping); + + if ( global.clickhouseClient ) { + const groupByFormat = this.clickhouseGroupByFormats[stats_grouping]; + const timeCondition = timeRange ? + `AND ts >= ${Math.floor(timeRange.start / 1000)} AND ts < ${Math.floor(timeRange.end / 1000)}` : ''; + + const [openResult, userResult] = await Promise.all([ + global.clickhouseClient.query({ + query: ` + SELECT + ${groupByFormat} as period, + COUNT(_id) as count + FROM app_opens + WHERE app_uid = '${app_uid}' + ${timeCondition} + GROUP BY period + ORDER BY period + `, + format: 'JSONEachRow', + }), + global.clickhouseClient.query({ + query: ` + SELECT + ${groupByFormat} as period, + COUNT(DISTINCT user_id) as count + FROM app_opens + WHERE app_uid = '${app_uid}' + ${timeCondition} + GROUP BY period + ORDER BY period + `, + format: 'JSONEachRow', + }), + ]); + + const openRows = await openResult.json(); + const userRows = await userResult.json(); + + // Ensure counts are properly parsed as integers + const processedOpenRows = openRows.map(row => ({ + period: new Date(row.period), + count: parseInt(row.count), + })); + + const processedUserRows = userRows.map(row => ({ + period: new Date(row.period), + count: parseInt(row.count), + })); + + // Calculate totals from the processed rows + const totalOpenCount = processedOpenRows.reduce((sum, row) => sum + row.count, 0); + const totalUserCount = processedUserRows.reduce((sum, row) => sum + row.count, 0); + + // Generate all periods and merge with actual data + const allPeriods = this.generateAllPeriods(new Date(timeRange.start), + new Date(timeRange.end), + stats_grouping); + + const completeOpenStats = this.mergeWithGeneratedPeriods(processedOpenRows, allPeriods, stats_grouping); + const completeUserStats = this.mergeWithGeneratedPeriods(processedUserRows, allPeriods, stats_grouping); + + return { + open_count: totalOpenCount, + user_count: totalUserCount, + grouped_stats: { + open_count: completeOpenStats, + user_count: completeUserStats, + }, + referral_count: period === 'all' ? await kv.get(`apps:referral_count:uid:${app_uid}`) : null, + }; + } + + else { + // MySQL queries for grouped stats + const queryParams = timeRange ? + [app_uid, timeRange.start / 1000, timeRange.end / 1000] : + [app_uid]; + + const [openResult, userResult] = await Promise.all([ + db.read(` + SELECT ${ + db.case({ + mysql: `DATE_FORMAT(FROM_UNIXTIME(ts/1000), '${timeFormat}') as period, `, + sqlite: `STRFTIME('%Y-%m-%d %H', datetime(ts/1000, 'unixepoch'), '${timeFormat}') as period, `, + }) + } + COUNT(_id) as count + FROM app_opens + WHERE app_uid = ? + ${timeRange ? 'AND ts >= ? AND ts < ?' : ''} + GROUP BY period + ORDER BY period + `, queryParams), + db.read(` + SELECT ${ + db.case({ + mysql: `DATE_FORMAT(FROM_UNIXTIME(ts/1000), '${timeFormat}') as period, `, + sqlite: `STRFTIME('%Y-%m-%d %H', datetime(ts/1000, 'unixepoch'), '${timeFormat}') as period, `, + }) + } + COUNT(DISTINCT user_id) as count + FROM app_opens + WHERE app_uid = ? + ${timeRange ? 'AND ts >= ? AND ts < ?' : ''} + GROUP BY period + ORDER BY period + `, queryParams), + ]); + + // Calculate totals + const totalOpenCount = openResult.reduce((sum, row) => sum + parseInt(row.count), 0); + const totalUserCount = userResult.reduce((sum, row) => sum + parseInt(row.count), 0); + + // Convert MySQL results to the same format as needed + const openRows = openResult.map(row => ({ + period: row.period, + count: parseInt(row.count), + })); + const userRows = userResult.map(row => ({ + period: row.period, + count: parseInt(row.count), + })); + + // Merge with generated periods to include zero-value periods + const completeOpenStats = this.mergeWithGeneratedPeriods(openRows, allPeriods, stats_grouping); + const completeUserStats = this.mergeWithGeneratedPeriods(userRows, allPeriods, stats_grouping); + + return { + open_count: totalOpenCount, + user_count: totalUserCount, + grouped_stats: { + open_count: completeOpenStats, + user_count: completeUserStats, + }, + referral_count: period === 'all' ? await kv.get(`apps:referral_count:uid:${app_uid}`) : null, + }; + } + } + + // Handle non-grouped stats + if ( global.clickhouseClient ) { + const openCountQuery = timeRange + ? `SELECT COUNT(_id) AS open_count FROM app_opens + WHERE app_uid = '${app_uid}' + AND ts >= ${Math.floor(timeRange.start / 1000)} + AND ts < ${Math.floor(timeRange.end / 1000)}` + : `SELECT COUNT(_id) AS open_count FROM app_opens + WHERE app_uid = '${app_uid}'`; + + const userCountQuery = timeRange + ? `SELECT COUNT(DISTINCT user_id) AS uniqueUsers FROM app_opens + WHERE app_uid = '${app_uid}' + AND ts >= ${Math.floor(timeRange.start / 1000)} + AND ts < ${Math.floor(timeRange.end / 1000)}` + : `SELECT COUNT(DISTINCT user_id) AS uniqueUsers FROM app_opens + WHERE app_uid = '${app_uid}'`; + + const [openResult, userResult] = await Promise.all([ + global.clickhouseClient.query({ + query: openCountQuery, + format: 'JSONEachRow', + }), + global.clickhouseClient.query({ + query: userCountQuery, + format: 'JSONEachRow', + }), + ]); + + const openRows = await openResult.json(); + const userRows = await userResult.json(); + + const results = { + open_count: parseInt(openRows[0].open_count), + user_count: parseInt(userRows[0].uniqueUsers), + referral_count: period === 'all' ? await kv.get(`apps:referral_count:uid:${app_uid}`) : null, + }; + + // Cache the results if period is 'all' + if ( period === 'all' ) { + const key_open_count = `apps:open_count:uid:${app_uid}`; + const key_user_count = `apps:user_count:uid:${app_uid}`; + await Promise.all([ + kv.set(key_open_count, results.open_count), + kv.set(key_user_count, results.user_count), + ]); + } + + return results; + } else { + // Regular MySQL queries for non-grouped stats + const baseOpenQuery = 'SELECT COUNT(_id) AS open_count FROM app_opens WHERE app_uid = ?'; + const baseUserQuery = 'SELECT COUNT(DISTINCT user_id) AS user_count FROM app_opens WHERE app_uid = ?'; + + const generateQuery = (baseQuery, timeRange) => { + if ( ! timeRange ) return baseQuery; + return `${baseQuery} AND ts >= ? AND ts < ?`; + }; + + const openQuery = generateQuery(baseOpenQuery, timeRange); + const userQuery = generateQuery(baseUserQuery, timeRange); + const queryParams = timeRange ? [app_uid, timeRange.start, timeRange.end] : [app_uid]; + + const [openResult, userResult] = await Promise.all([ + db.read(openQuery, queryParams), + db.read(userQuery, queryParams), + ]); + + const results = { + open_count: parseInt(openResult[0].open_count), + user_count: parseInt(userResult[0].user_count), + referral_count: period === 'all' ? await kv.get(`apps:referral_count:uid:${app_uid}`) : null, + }; + + // Cache the results if period is 'all' + if ( period === 'all' ) { + const key_open_count = `apps:open_count:uid:${app_uid}`; + const key_user_count = `apps:user_count:uid:${app_uid}`; + await Promise.all([ + kv.set(key_open_count, results.open_count), + kv.set(key_user_count, results.user_count), + ]); + } + + return results; + } + } + + /** + * Refreshes the application cache by querying the database for all apps and updating the key-value store. + * + * @async + * @returns {Promise} A promise that resolves when the cache refresh operation is complete. + */ + async _refresh_app_cache () { + const db = this.services.get('database').get(DB_READ, 'apps'); + + let apps = await db.read('SELECT * FROM apps'); + for ( const app of apps ) { + kv.set(`apps:name:${ app.name}`, app); + kv.set(`apps:id:${ app.id}`, app); + kv.set(`apps:uid:${ app.uid}`, app); + } + } + + /** + * Refreshes the cache of app statistics including open and user counts. + * + * @notes + * - This method logs a tick event for performance monitoring. + * + * @async + * @returns {Promise} A promise that resolves when the cache refresh operation is complete. + */ + async _refresh_app_stats () { + this.log.tick('refresh app stats'); + + const db = this.services.get('database').get(DB_READ, 'apps'); + + // Fetch all stats in two aggregate queries instead of per-app queries + const [openCounts, userCounts] = await Promise.all([ + db.read(` + SELECT app_uid, COUNT(_id) AS open_count + FROM app_opens + GROUP BY app_uid + `), + db.read(` + SELECT app_uid, COUNT(DISTINCT user_id) AS user_count + FROM app_opens + GROUP BY app_uid + `), + ]); + + // Build maps for quick lookup + const openCountMap = new Map(openCounts.map(row => [row.app_uid, row.open_count])); + const userCountMap = new Map(userCounts.map(row => [row.app_uid, row.user_count])); + + // Get all app UIDs and update the cache + const apps = await db.read('SELECT uid FROM apps'); + + for ( const app of apps ) { + const key_open_count = `apps:open_count:uid:${app.uid}`; + const key_user_count = `apps:user_count:uid:${app.uid}`; + + kv.set(key_open_count, openCountMap.get(app.uid) ?? 0); + kv.set(key_user_count, userCountMap.get(app.uid) ?? 0); + } + } + + /** + * Refreshes the cache of app referral statistics. + * + * This method queries the database for user counts referred by each app's origin URL + * and updates the cache with the referral counts for each app. + * + * @notes + * - This method logs a tick event for performance monitoring. + * + * @async + * @returns {Promise} A promise that resolves when the cache refresh operation is complete. + */ + async _refresh_app_stat_referrals () { + this.log.tick('refresh app stat referrals'); + + const db = this.services.get('database').get(DB_READ, 'apps'); + + const apps = await db.read('SELECT uid, index_url FROM apps'); + + // First, build a map of valid app origins to UIDs + const validApps = []; + const svc_auth = this.services.get('auth'); + + for ( const app of apps ) { + const origin = origin_from_url(app.index_url); + + // only count the referral if the origin hashes to the app's uid + let expected_uid; + try { + expected_uid = await svc_auth.app_uid_from_origin(origin); + } catch (e) { + // This happens if the app origin isn't valid + continue; + } + if ( expected_uid !== app.uid ) { + continue; + } + + validApps.push({ uid: app.uid, origin }); + } + + if ( validApps.length === 0 ) { + return; + } + + // Build a single query to get all referral counts + const likeConditions = validApps.map(() => 'referrer LIKE ?').join(' OR '); + const queryParams = validApps.map(app => `${app.origin}%`); + + const referralResults = await db.read(` + SELECT + referrer, + COUNT(id) as referral_count + FROM user + WHERE ${likeConditions} + GROUP BY referrer + `, queryParams); + + // Create a map to store referral counts by origin + const referralMap = new Map(); + + for ( const result of referralResults ) { + // Find which app this referrer belongs to + for ( const app of validApps ) { + if ( result.referrer.startsWith(app.origin) ) { + const currentCount = referralMap.get(app.uid) || 0; + referralMap.set(app.uid, currentCount + parseInt(result.referral_count)); + break; + } + } + } + + // Update cache with results + for ( const app of validApps ) { + const key_referral_count = `apps:referral_count:uid:${app.uid}`; + const count = referralMap.get(app.uid) || 0; + kv.set(key_referral_count, count); + } + + this.log.info('DONE refresh app stat referrals'); + } + + /** + * Updates the cache with recently updated apps. + * + * @description This method refreshes the cache containing the most recently updated applications. + * It fetches all app UIDs, retrieves the corresponding app data, filters for approved apps, + * sorts them by timestamp in descending order, and updates the 'recent' collection with + * the UIDs of the top 50 most recent apps. + * + * @returns {Promise} Resolves when the cache has been updated. + */ + async _refresh_recent_cache () { + const app_keys = kv.keys('apps:uid:*'); + + let apps = []; + for ( const key of app_keys ) { + const app = kv.get(key); + apps.push(app); + } + + apps = apps.filter(app => app.approved_for_listing); + apps.sort((a, b) => { + return b.timestamp - a.timestamp; + }); + + this.collections.recent = apps.map(app => app.uid).slice(0, 50); + } + + /** + * Deletes an application from the system. + * + * This method performs the following actions: + * - Retrieves the app data from cache or database if not provided. + * - Deletes the app record from the database. + * - Removes the app from all relevant caches (by name, id, and uid). + * - Removes the app from the recent collection if present. + * - Removes the app from any associated tags. + * + * @param {string} app_uid - The unique identifier of the app to be deleted. + * @param {Object} [app] - The app object, if already fetched. If not provided, it will be retrieved. + * @throws {Error} If the app is not found in either cache or database. + * @returns {Promise} A promise that resolves when the app has been successfully deleted. + */ + async delete_app (app_uid, app) { + const db = this.services.get('database').get(DB_READ, 'apps'); + + app = app ?? kv.get(`apps:uid:${ app_uid}`); + if ( ! app ) { + app = (await db.read('SELECT * FROM apps WHERE uid = ?', + [app_uid]))[0]; + } + + if ( ! app ) { + throw new Error('app not found'); + } + + await db.write('DELETE FROM apps WHERE uid = ? LIMIT 1', + [app_uid]); + + // remove from caches + kv.del(`apps:name:${ app.name}`); + kv.del(`apps:id:${ app.id}`); + kv.del(`apps:uid:${ app.uid}`); + + // remove from recent + const index = this.collections.recent.indexOf(app_uid); + if ( index >= 0 ) { + this.collections.recent.splice(index, 1); + } + + // remove from tags + const app_tags = (app.tags ?? '').split(',') + .map(tag => tag.trim()) + .filter(tag => tag.length > 0); + for ( const tag of app_tags ) { + if ( ! this.tags[tag] ) continue; + const index = this.tags[tag].indexOf(app_uid); + if ( index >= 0 ) { + this.tags[tag].splice(index, 1); + } + } + + } + + // Helper function to generate array of all periods between start and end dates + generateAllPeriods (startDate, endDate, grouping) { + const periods = []; + let currentDate = new Date(startDate); + + // ???: In local debugging, `currentDate` evaluates to `Invalid Date`. + // Does this work in prod? + + while ( currentDate <= endDate ) { + let period; + switch ( grouping ) { + case 'hour': + period = `${currentDate.toISOString().slice(0, 13) }:00:00`; + currentDate.setHours(currentDate.getHours() + 1); + break; + case 'day': + period = currentDate.toISOString().slice(0, 10); + currentDate.setDate(currentDate.getDate() + 1); + break; + case 'week': { + // Get the ISO week number + const weekNum = String(this.getWeekNumber(currentDate)).padStart(2, '0'); + period = `${currentDate.getFullYear()}-${weekNum}`; + currentDate.setDate(currentDate.getDate() + 7); + break; + } + case 'month': + period = currentDate.toISOString().slice(0, 7); + currentDate.setMonth(currentDate.getMonth() + 1); + break; + case 'year': + period = currentDate.getFullYear().toString(); + currentDate.setFullYear(currentDate.getFullYear() + 1); + break; + } + periods.push({ period, count: 0 }); + } + return periods; + } + + // Helper function to get ISO week number + getWeekNumber (date) { + const target = new Date(date.valueOf()); + const dayNumber = (date.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNumber + 3); + const firstThursday = target.valueOf(); + target.setMonth(0, 1); + if ( target.getDay() !== 4 ) { + target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7); + } + return 1 + Math.ceil((firstThursday - target) / 604800000); + } + + // Helper function to merge actual data with generated periods + mergeWithGeneratedPeriods (actualData, allPeriods, stats_grouping) { + // Create a map of period to count from actual data + // First normalize the period format from both MySQL and ClickHouse + const dataMap = new Map(actualData.map(item => { + let period = item.period; + // For ClickHouse results, convert the timestamp to match the expected format + if ( item.period instanceof Date ) { + switch ( stats_grouping ) { + case 'hour': + period = `${item.period.toISOString().slice(0, 13) }:00:00`; + break; + case 'day': + period = item.period.toISOString().slice(0, 10); + break; + case 'week': { + const weekNum = String(this.getWeekNumber(item.period)).padStart(2, '0'); + period = `${item.period.getFullYear()}-${weekNum}`; + break; + } + case 'month': + period = item.period.toISOString().slice(0, 7); + break; + case 'year': + period = item.period.getFullYear().toString(); + break; + } + } + return [period, parseInt(item.count)]; + })); + + // Map the generated periods to include actual counts where they exist + return allPeriods.map(periodObj => { + const count = dataMap.get(periodObj.period); + return { + period: periodObj.period, + count: count !== undefined ? count : 0, + }; + }); + } + +} + +module.exports = { + AppInformationService, +}; diff --git a/src/backend/src/modules/apps/AppPermissionService.js b/src/backend/src/modules/apps/AppPermissionService.js new file mode 100644 index 0000000000000000000000000000000000000000..a42f05ecd795331a08edc331e1b63f08862911eb --- /dev/null +++ b/src/backend/src/modules/apps/AppPermissionService.js @@ -0,0 +1,30 @@ +const { UserActorType } = require('../../services/auth/Actor'); +const { PermissionImplicator, PermissionUtil } = require('../../services/auth/permissionUtils.mjs'); +const BaseService = require('../../services/BaseService'); + +class AppPermissionService extends BaseService { + async _init () { + const svc_permission = this.services.get('permission'); + svc_permission.register_implicator(PermissionImplicator.create({ + id: 'user-can-grant-read-own-apps', + matcher: permission => { + return permission.startsWith('apps-of-user:') || + permission.startsWith('subdomains-of-user:'); + }, + checker: async ({ actor, permission }) => { + if ( ! (actor.type instanceof UserActorType) ) { + return undefined; + } + + const parts = PermissionUtil.split(permission); + if ( parts[1] === actor.type.user.uuid ) { + return {}; + } + }, + })); + } +} + +module.exports = { + AppPermissionService, +}; diff --git a/src/backend/src/modules/apps/AppsModule.js b/src/backend/src/modules/apps/AppsModule.js new file mode 100644 index 0000000000000000000000000000000000000000..47274fa82c5eb9b42f922bed32daa4f51a4562a0 --- /dev/null +++ b/src/backend/src/modules/apps/AppsModule.js @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +class AppsModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { AppInformationService } = require('./AppInformationService'); + services.registerService('app-information', AppInformationService); + + const { AppIconService } = require('./AppIconService'); + services.registerService('app-icon', AppIconService); + + const { OldAppNameService } = require('./OldAppNameService'); + services.registerService('old-app-name', OldAppNameService); + + const { ProtectedAppService } = require('./ProtectedAppService'); + services.registerService('__protected-app', ProtectedAppService); + + const RecommendedAppsService = require('./RecommendedAppsService'); + services.registerService('recommended-apps', RecommendedAppsService); + + const { AppPermissionService } = require('./AppPermissionService'); + services.registerService('app-permission', AppPermissionService); + } +} + +module.exports = { + AppsModule, +}; diff --git a/src/backend/src/modules/apps/OldAppNameService.js b/src/backend/src/modules/apps/OldAppNameService.js new file mode 100644 index 0000000000000000000000000000000000000000..511a1a033f6b961179655983fcd0c08431a06fcc --- /dev/null +++ b/src/backend/src/modules/apps/OldAppNameService.js @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); +const { DB_READ } = require('../../services/database/consts'); + +const N_MONTHS = 4; + +class OldAppNameService extends BaseService { + static LOG_DEBUG = true; + + _init () { + this.db = this.services.get('database').get(DB_READ, 'old-app-name'); + } + + async ['__on_boot.consolidation'] () { + const svc_event = this.services.get('event'); + svc_event.on('app.rename', async (_, { app_uid, old_name }) => { + this.log.info('GOT EVENT', { app_uid, old_name }); + await this.db.write('INSERT INTO `old_app_names` (`app_uid`, `name`) VALUES (?, ?)', + [app_uid, old_name]); + }); + } + + async check_app_name (name) { + const rows = await this.db.read('SELECT * FROM `old_app_names` WHERE `name` = ?', + [name]); + + if ( rows.length === 0 ) return; + + // Check if the app has been renamed in the last N months + const [row] = rows; + const timestamp = row.timestamp instanceof Date ? row.timestamp : new Date( + // Ensure timestamp ir processed as UTC + row.timestamp.endsWith('Z') ? row.timestamp : `${row.timestamp }Z`); + + const age = Date.now() - timestamp.getTime(); + + // const n_ms = 60 * 1000; + const n_ms = N_MONTHS * 30 * 24 * 60 * 60 * 1000; + this.log.info('AGE INFO', { + input_time: row.timestamp, + age, + n_ms, + }); + if ( age > n_ms ) { + // Remove record + await this.db.write('DELETE FROM `old_app_names` WHERE `id` = ?', + [row.id]); + // Return undefined + return; + } + + return { + id: row.id, + app_uid: row.app_uid, + }; + } + + async remove_name (id) { + await this.db.write('DELETE FROM `old_app_names` WHERE `id` = ?', + [id]); + } +} + +module.exports = { + OldAppNameService, +}; diff --git a/src/backend/src/modules/apps/ProtectedAppService.js b/src/backend/src/modules/apps/ProtectedAppService.js new file mode 100644 index 0000000000000000000000000000000000000000..1523d3f73c5ba9f1302fee725303c28e711b3f18 --- /dev/null +++ b/src/backend/src/modules/apps/ProtectedAppService.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { get_app } = require('../../helpers'); +const { UserActorType } = require('../../services/auth/Actor'); +const { PermissionImplicator, PermissionUtil, PermissionRewriter } = + require('../../services/auth/permissionUtils.mjs'); +const BaseService = require('../../services/BaseService'); + +/** +* @class ProtectedAppService +* @extends BaseService +* @classdesc This class represents a service that handles protected applications. It extends the BaseService and includes +* methods for initializing permissions and registering rewriters and implicators for permission handling. The class +* ensures that the owner of a protected app has implicit permission to access it. +*/ +class ProtectedAppService extends BaseService { + /** + * Initializes the ProtectedAppService. + * Registers a permission rewriter and implicator to handle application-specific permissions. + * @async + * @method _init + * @memberof ProtectedAppService + * @returns {Promise} A promise that resolves when the initialization is complete. + */ + async _init () { + const svc_permission = this.services.get('permission'); + + svc_permission.register_rewriter(PermissionRewriter.create({ + matcher: permission => { + if ( ! permission.startsWith('app:') ) return false; + const [_, specifier] = PermissionUtil.split(permission); + if ( specifier.startsWith('uid#') ) return false; + return true; + }, + rewriter: async permission => { + const [_1, name, ...rest] = PermissionUtil.split(permission); + const app = await get_app({ name }); + return PermissionUtil.join(_1, `uid#${app.uid}`, ...rest); + }, + })); + + // track: object description in comment + // Owner of procted app has implicit permission to access it + svc_permission.register_implicator(PermissionImplicator.create({ + matcher: permission => { + return permission.startsWith('app:'); + }, + checker: async ({ actor, permission }) => { + if ( ! (actor.type instanceof UserActorType) ) { + return undefined; + } + + const parts = PermissionUtil.split(permission); + if ( parts.length !== 3 ) return undefined; + + const [_, uid_part, lvl] = parts; + if ( lvl !== 'access' ) return undefined; + + // track: slice a prefix + const uid = uid_part.slice('uid#'.length); + + const app = await get_app({ uid }); + + if ( app.owner_user_id !== actor.type.user.id ) { + return undefined; + } + + return {}; + }, + })); + } +} + +module.exports = { + ProtectedAppService, +}; diff --git a/src/backend/src/modules/apps/RecommendedAppsService.js b/src/backend/src/modules/apps/RecommendedAppsService.js new file mode 100644 index 0000000000000000000000000000000000000000..c27c988980b402424d102e31b3c1d2a192f121d6 --- /dev/null +++ b/src/backend/src/modules/apps/RecommendedAppsService.js @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { get_app } = require('../../helpers'); +const BaseService = require('../../services/BaseService'); + +const get_apps = async ({ specifiers }) => { + return await Promise.all(specifiers.map(async (specifier) => { + return await get_app(specifier); + })); +}; + +class RecommendedAppsService extends BaseService { + static APP_NAMES = [ + 'app-center', + 'dev-center', + 'editor', + 'code', + 'camera', + 'recorder', + 'shell-shockers-outpan', + 'krunker', + 'slash-frvr', + 'judge0', + 'viewer', + 'solitaire-frvr', + 'tiles-beat', + 'silex', + 'markus', + 'puterjs-playground', + 'player', + 'grist', + 'pdf', + 'photopea', + 'polotno', + 'basketball-frvr', + 'gold-digger-frvr', + 'plushie-connect', + 'hex-frvr', + 'spider-solitaire', + 'danger-cross', + 'doodle-jump-extra', + 'endless-lake', + 'sword-and-jewel', + 'reversi-2', + 'in-orbit', + 'bowling-king', + 'calc-hklocykcpts', + 'virtu-piano', + 'battleship-war', + 'turbo-racing', + 'guns-and-bottles', + 'tronix', + 'jewel-classic', + ]; + + _construct () { + this.app_names = new Set(RecommendedAppsService.APP_NAMES); + } + + ['__on_boot.consolidation'] () { + const svc_appIcon = this.services.get('app-icon'); + const svc_event = this.services.get('event'); + svc_event.on('apps.invalidate', (_, { app }) => { + const sizes = svc_appIcon.get_sizes(); + + // If it's a single-app invalidation, only invalidate if the + // app is in the list of recommended apps + if ( app ) { + const name = app.name; + if ( ! this.app_names.has(name) ) return; + } + + kv.del('global:recommended-apps'); + for ( const size of sizes ) { + const key = `global:recommended-apps:icon-size:${size}`; + kv.del(key); + } + }); + } + + async get_recommended_apps ({ icon_size }) { + const recommended_cache_key = `global:recommended-apps${ + icon_size ? `:icon-size:${icon_size}` : ''}`; + + let recommended = kv.get(recommended_cache_key); + if ( recommended ) return recommended; + + // Prepare each app for returning to user by only returning the necessary fields + // and adding them to the retobj array + recommended = (await get_apps({ + specifiers: Array.from(this.app_names).map(name => ({ name })), + })).filter(app => !!app).map(app => { + return { + uuid: app.uid, + name: app.name, + title: app.title, + icon: app.icon, + godmode: app.godmode, + maximize_on_start: app.maximize_on_start, + index_url: app.index_url, + }; + }); + + const svc_appIcon = this.services.get('app-icon'); + + // Iconify apps + if ( icon_size ) { + recommended = await svc_appIcon.iconify_apps({ + apps: recommended, + size: icon_size, + }); + } + + kv.set(recommended_cache_key, recommended); + + return recommended; + } +} + +module.exports = RecommendedAppsService; diff --git a/src/backend/src/modules/apps/default-app-icon.js b/src/backend/src/modules/apps/default-app-icon.js new file mode 100644 index 0000000000000000000000000000000000000000..8ae51e5dd3b2b79aac9d7b9fd23abc3ea75657b7 --- /dev/null +++ b/src/backend/src/modules/apps/default-app-icon.js @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +module.exports = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmVyc2lvbj0iMS4xIgogICB3aWR0aD0iNDgiCiAgIGhlaWdodD0iNDgiCiAgIGlkPSJzdmc2NjQ5IgogICB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiAgPGRlZnMKICAgICBpZD0iZGVmczY2NTEiPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICB4bGluazpocmVmPSIjbGluZWFyR3JhZGllbnQxMjEzMDMiCiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMjE3NjQiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4wMDU5MTg0LDAsMCwwLjg1NzEwOTk5LC0wLjEyNzgyMjg3LDguMTA2NDc1MSkiCiAgICAgICB4MT0iMjUuMDg2MDM5IgogICAgICAgeTE9Ii0xLjM2MjM2OTEiCiAgICAgICB4Mj0iMjUuMDg2MDM5IgogICAgICAgeTI9IjE4LjI5OTMzNCIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MTIxMzAzIj4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEyOTUiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjEiCiAgICAgICAgIG9mZnNldD0iMCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEyOTciCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMjM1Mjk0MTIiCiAgICAgICAgIG9mZnNldD0iMC4xMTQxOTQ2OCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEyOTkiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMTU2ODYyNzUiCiAgICAgICAgIG9mZnNldD0iMC45Mzg5NjU5OCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AxMjEzMDEiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMzkyMTU2ODciCiAgICAgICAgIG9mZnNldD0iMSIgLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIHhsaW5rOmhyZWY9IiNsaW5lYXJHcmFkaWVudDM5MjQtMi0yLTUtOCIKICAgICAgIGlkPSJsaW5lYXJHcmFkaWVudDEyMTc2MCIKICAgICAgIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIgogICAgICAgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjAwMDAwMDMsMCwwLDAuODM3ODM4MTMsLTEuMjQ4MTQ2ZS01LDcuODkxODg1MykiCiAgICAgICB4MT0iMjMuOTk5OTkiCiAgICAgICB5MT0iNi4wNDQ1Mjc1IgogICAgICAgeDI9IjIzLjk5OTk5IgogICAgICAgeTI9IjQxLjc2MzIyMiIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MzkyNC0yLTItNS04Ij4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AzOTI2LTktNC05LTYiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjEiCiAgICAgICAgIG9mZnNldD0iMCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AzOTI4LTktOC02LTUiCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMjM1Mjk0MTIiCiAgICAgICAgIG9mZnNldD0iMC4wOTMwMjMyNSIgLz4KICAgICAgPHN0b3AKICAgICAgICAgaWQ9InN0b3AzOTMwLTMtNS0xLTciCiAgICAgICAgIHN0eWxlPSJzdG9wLWNvbG9yOiNmZmZmZmY7c3RvcC1vcGFjaXR5OjAuMTU2ODYyNzUiCiAgICAgICAgIG9mZnNldD0iMC45MDY5NzY3IiAvPgogICAgICA8c3RvcAogICAgICAgICBpZD0ic3RvcDM5MzItOC0wLTQtOCIKICAgICAgICAgc3R5bGU9InN0b3AtY29sb3I6I2ZmZmZmZjtzdG9wLW9wYWNpdHk6MC4zOTIxNTY4NyIKICAgICAgICAgb2Zmc2V0PSIxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgeGxpbms6aHJlZj0iI2QiCiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMjE3NTgiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4yMTIyOTAzLDAsMCwxLjExNDU1MTQsLTQuNDk5OTAzLC0yLjc2MTI1MzMpIgogICAgICAgeDE9IjIzLjQ1MiIKICAgICAgIHkxPSIzMC41NTUiCiAgICAgICB4Mj0iNDMuMDA3IgogICAgICAgeTI9IjQ1LjkzMzk5OCIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImQiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3AtY29sb3I9IiNmZmYiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iMCIKICAgICAgICAgaWQ9InN0b3A2NSIgLz4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIxIgogICAgICAgICBzdG9wLWNvbG9yPSIjZmZmIgogICAgICAgICBzdG9wLW9wYWNpdHk9IjAiCiAgICAgICAgIGlkPSJzdG9wNjciIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICB4bGluazpocmVmPSIjbGluZWFyR3JhZGllbnQxMDYzMDUiCiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMjE3NTYiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4yMTk2MzY1LDAsMCwxLjMyMDM3MDgsNDAuNzg1OTE1LC0xMy4zMzg3NDQpIgogICAgICAgeDE9Ii01Ljg4NzAzMzUiCiAgICAgICB5MT0iMTkuMzQxOTE1IgogICAgICAgeDI9Ii01Ljg4NzAzMzUiCiAgICAgICB5Mj0iNDMuMzc1NzQ4IiAvPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICBpZD0ibGluZWFyR3JhZGllbnQxMDYzMDUiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3AtY29sb3I9IiNkYWMxOTciCiAgICAgICAgIGlkPSJzdG9wMTA2MzAxIgogICAgICAgICBzdHlsZT0ic3RvcC1jb2xvcjojZTdjNTkxO3N0b3Atb3BhY2l0eToxIiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjEiCiAgICAgICAgIHN0b3AtY29sb3I9IiNiMTk5NzQiCiAgICAgICAgIGlkPSJzdG9wMTA2MzAzIgogICAgICAgICBzdHlsZT0ic3RvcC1jb2xvcjojY2ZhMjVlO3N0b3Atb3BhY2l0eToxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgeGxpbms6aHJlZj0iI2xpbmVhckdyYWRpZW50MTA2MzA1IgogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MTcwMyIKICAgICAgIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIgogICAgICAgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjIxOTYzNjUsMCwwLDEuMzE1NDE2NSw0MC44MDAzMzgsLTEyLjk4MzQyMikiCiAgICAgICB4MT0iLTUuODg3MDMzNSIKICAgICAgIHkxPSIxMS40ODI5NzgiCiAgICAgICB4Mj0iLTUuODg3MDMzNSIKICAgICAgIHkyPSIyMi4xNDg4NjUiIC8+CiAgICA8cmFkaWFsR3JhZGllbnQKICAgICAgIGN4PSI1IgogICAgICAgY3k9IjQxLjUiCiAgICAgICBmeD0iNSIKICAgICAgIGZ5PSI0MS41IgogICAgICAgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjAwMjg4NzEsMCwwLDEuNiwtMTguMTY3MTM4LC0xMTEuOTgyODkpIgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICB4bGluazpocmVmPSIjZyIKICAgICAgIGlkPSJrLTAtNy0zLTktMyIKICAgICAgIHI9IjUiIC8+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIGlkPSJnIj4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIwIgogICAgICAgICBpZD0ic3RvcDEzIiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjEiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iMCIKICAgICAgICAgaWQ9InN0b3AxNSIgLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIHhsaW5rOmhyZWY9IiNoIgogICAgICAgaWQ9ImxpbmVhckdyYWRpZW50MTIxNzU0IgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDIuMTMwNDMzMiwwLDAsMS40NTQ1NSwtODcuNzE5MDE4LC0xMy4zMjcxMSkiCiAgICAgICB4MT0iMTcuNTU0MDAxIgogICAgICAgeTE9IjQ2IgogICAgICAgeDI9IjE3LjU1NDAwMSIKICAgICAgIHkyPSIzNSIgLz4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImgiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iMCIKICAgICAgICAgaWQ9InN0b3A1NCIgLz4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIuNSIKICAgICAgICAgaWQ9InN0b3A1NiIgLz4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIxIgogICAgICAgICBzdG9wLW9wYWNpdHk9IjAiCiAgICAgICAgIGlkPSJzdG9wNTgiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPHJhZGlhbEdyYWRpZW50CiAgICAgICBjeD0iNSIKICAgICAgIGN5PSI0MS41IgogICAgICAgZng9IjUiCiAgICAgICBmeT0iNDEuNSIKICAgICAgIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4wMDI4ODcxLDAsMCwxLjYsNTcuMTM5MDQ4LC0xMTEuOTgyODkpIgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICB4bGluazpocmVmPSIjZyIKICAgICAgIGlkPSJpLTYtOS03LTgtOSIKICAgICAgIHI9IjUiIC8+CiAgICA8bGluZWFyR3JhZGllbnQKICAgICAgIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIgogICAgICAgeGxpbms6aHJlZj0iI2MtMyIKICAgICAgIGlkPSJuIgogICAgICAgeDE9IjI2IgogICAgICAgeDI9IjI2IgogICAgICAgeTE9IjIyIgogICAgICAgeTI9IjgiCiAgICAgICBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTMpIiAvPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICBpZD0iYy0zIj4KICAgICAgPHN0b3AKICAgICAgICAgb2Zmc2V0PSIwIgogICAgICAgICBzdG9wLWNvbG9yPSIjZmZmIgogICAgICAgICBpZD0ic3RvcDM2LTYiIC8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC40MjgxODMwNSIKICAgICAgICAgc3RvcC1jb2xvcj0iI2ZmZiIKICAgICAgICAgaWQ9InN0b3AzOC03IiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAuNTAwOTMzMTciCiAgICAgICAgIHN0b3AtY29sb3I9IiNmZmYiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iLjY0MyIKICAgICAgICAgaWQ9InN0b3A0MC01IiAvPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjEiCiAgICAgICAgIHN0b3AtY29sb3I9IiNmZmYiCiAgICAgICAgIHN0b3Atb3BhY2l0eT0iLjM5MSIKICAgICAgICAgaWQ9InN0b3A0Mi0zIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPG1ldGFkYXRhCiAgICAgaWQ9Im1ldGFkYXRhNjY1NCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGcKICAgICBpZD0iZzEyMTAiCiAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMC43MTE4NjQzOCwwLDAsMC43NSw1MC44MDQ1NjIsNi44MTI4MzI4KSIKICAgICBzdHlsZT0ic3Ryb2tlLXdpZHRoOjEuMzY4NTgiPgogICAgPHJlY3QKICAgICAgIGZpbGw9InVybCgjaSkiCiAgICAgICBoZWlnaHQ9IjE2IgogICAgICAgb3BhY2l0eT0iMC40IgogICAgICAgdHJhbnNmb3JtPSJzY2FsZSgtMSkiCiAgICAgICB3aWR0aD0iNSIKICAgICAgIHg9IjYyLjE1NDAzIgogICAgICAgeT0iLTUzLjU4Mjg5IgogICAgICAgaWQ9InJlY3Q3Ny05LTkwLTItNy04IgogICAgICAgc3R5bGU9ImZpbGw6dXJsKCNpLTYtOS03LTgtOSk7c3Ryb2tlLXdpZHRoOjEuMzY4NTgiIC8+CiAgICA8cmVjdAogICAgICAgZmlsbD0idXJsKCNqKSIKICAgICAgIGhlaWdodD0iMTYiCiAgICAgICBvcGFjaXR5PSIwLjQiCiAgICAgICB3aWR0aD0iNDkiCiAgICAgICB4PSItNjIuMTU0MDMiCiAgICAgICB5PSIzNy41ODI4OSIKICAgICAgIGlkPSJyZWN0NzktNy0yLTAtMS00IgogICAgICAgc3R5bGU9ImZpbGw6dXJsKCNsaW5lYXJHcmFkaWVudDEyMTc1NCk7c3Ryb2tlLXdpZHRoOjEuMzY4NTgiIC8+CiAgICA8cmVjdAogICAgICAgZmlsbD0idXJsKCNrKSIKICAgICAgIGhlaWdodD0iMTYiCiAgICAgICBvcGFjaXR5PSIwLjQiCiAgICAgICB0cmFuc2Zvcm09InNjYWxlKDEsLTEpIgogICAgICAgd2lkdGg9IjUiCiAgICAgICB4PSItMTMuMTU0MDI4IgogICAgICAgeT0iLTUzLjU4Mjg5IgogICAgICAgaWQ9InJlY3Q4MS0zLTgtNi03LTgiCiAgICAgICBzdHlsZT0iZmlsbDp1cmwoI2stMC03LTMtOS0zKTtzdHJva2Utd2lkdGg6MS4zNjg1OCIgLz4KICA8L2c+CiAgPHBhdGgKICAgICBpZD0icmVjdDU1MDUtMjEtMS01LTAtNi01LTEtMi01LTEwIgogICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZvbnQtdmFyaWF0aW9uLXNldHRpbmdzOm5vcm1hbDtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO3Zpc2liaWxpdHk6dmlzaWJsZTt2ZWN0b3ItZWZmZWN0Om5vbmU7ZmlsbDp1cmwoI2xpbmVhckdyYWRpZW50MTcwMyk7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MC4zOy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJNIDExLjU5MDkyMyw1LjUgQyA5LjIzMzkwNSw1LjUgOC4yOTM2NSw2Ljg5NjUxODMgNy4zMzYzNzgsOS4wNTgwMjUyIDYuNjAyNjI1LDEwLjcxMDQ1NyA1Ljc0ODksMTIuNDIwMTYyIDUuMDcwNjEzLDE0LjAzOTI2IDQuNzA5ODY5LDE0LjY2Njk5NCA0LjUwMDAxNCwxNS4zOTQ1MDYgNC41MDAwMTQsMTYuMTc0MDc1IGggMzkuMDAwMDAzIGMgMCwtMC43Nzk1NjkgLTAuMjA5ODU1LC0xLjUwNzA4MSAtMC41NzA1OTgsLTIuMTM0ODE1IEMgNDIuMjMyNzQ0LDEyLjQyODM2MSA0MS40MTc5MiwxMC43MDExOTIgNDAuNjYzNjUzLDkuMDU4MDI1MiAzOS42NzczNzksNi45MDk2ODc3IDM4Ljc2NjEyNiw1LjUgMzYuNDA5MTA4LDUuNSBaIiAvPgogIDxwYXRoCiAgICAgaWQ9InJlY3Q1NTA1LTIxLTEtNS0wLTYtNS0xLTItMyIKICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmb250LXZhcmlhdGlvbi1zZXR0aW5nczpub3JtYWw7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2aXNpYmlsaXR5OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6dXJsKCNsaW5lYXJHcmFkaWVudDEyMTc1Nik7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MC4zOy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJNIDguNzU0NTQ1LDEyIEMgNi45ODE4MTgsMTIgNC41LDEzLjU1NjQ1NyA0LjUsMTcuMzU3MTM5IHYgMjIuODU3MTI2IGMgMCwwLjE4MDAwMiAwLjAxNDU0LDAuMzU2MjQ0IDAuMDM2MDIsMC41MzAxMzQgMC4wMDUsMC4wNDAzMiAwLjAxMTk4LDAuMDgwMDEgMC4wMTgwMSwwLjExOTk3NiAwLjAyMTQyLDAuMTQwNDQzIDAuMDQ4NSwwLjI3ODg0MyAwLjA4MzEsMC40MTQzNDIgMC4wMDg5LDAuMDM0OTcgMC4wMTY2NywwLjA3MDAyIDAuMDI2MzEsMC4xMDQ2MzEgMC4wOTcxMywwLjM0MzgzNyAwLjIzMzc3MywwLjY3MDg5OCAwLjQwNzE3NCwwLjk3Mzc3MiA1LjFlLTQsOS4yOWUtNCA3LjA5ZS00LDAuMDAxOCAwLjAwMTQsMC4wMDI4IDAuNzM0MTUsMS4yODAyNTkgMi4xMDM0MTksMi4xNDAwNyAzLjY4MjUxNSwyLjE0MDA3IGggMzAuNDkwOTEyIGMgMS41NzkwOTYsMCAyLjk0ODM2NSwtMC44NTk4MTEgMy42ODI1NjUsLTIuMTQwMDY2IDMuOTZlLTQsLTkuMjllLTQgNy4wOWUtNCwtMC4wMDE5IDAuMDAxNCwtMC4wMDI4IDAuMTczNDAxLC0wLjMwMjg3NCAwLjMxMDA1LC0wLjYyOTkzNSAwLjQwNzE3NSwtMC45NzM3NzIgMC4wMDk2LC0wLjAzNDYxIDAuMDE3NTIsLTAuMDY5NjYgMC4wMjYzMSwtMC4xMDQ2MzEgMC4wMzQ2LC0wLjEzNTQ5OSAwLjA2MTY5LC0wLjI3Mzg5OCAwLjA4MzEsLTAuNDE0MzQxIDAuMDA1NywtMC4wMzk5NyAwLjAxMzEyLC0wLjA3OTY1IDAuMDE4MDEsLTAuMTE5OTc3IDAuMDIxNDksLTAuMTczODk0IDAuMDM1OTYsLTAuMzUwMTM2IDAuMDM1OTYsLTAuNTMwMTM4IFYgMTcuNzE0MjgyIGMgMCwtMi42NzU0NzUgLTEuMDYzNjM3LC01LjcxNDI4MSAtNC4yNTQ1NDYsLTUuNzE0MjgxIHoiIC8+CiAgPHBhdGgKICAgICBkPSJtIDEwLjY0NDg2MSwxMS4yOTY1MDUgaCAyNi4xNDQxODUgYyAxLjUyNjY3MywwIDIuNDcxMTgyLDAuNTI4MDExIDMuMTEwNzgyLDEuOTc5Njg1IGwgMi4yMDE3MjcsNi4wOTEzMzkgdiAyMS45NTk0MiBjIDAsMS4zODU0OTUgLTAuNzc0MzI3LDIuMDgzNTggLTIuMzAwMjkxLDIuMDgzNTggSCA3LjkwNzc3IGMgLTEuNTI1OTY0LDAgLTIuMTQ4NTQ2LC0wLjc2NzgyMiAtMi4xNDg1NDYsLTIuMTUzMzE3IFYgMTkuMzY2MTA1IGwgMi4xMzA4MTksLTYuMjIxNTYyIGMgMC40MjU0NTUsLTEuMTI0MzM2IDEuMjI4ODU1LC0xLjg0ODc1IDIuNzU0ODE4LC0xLjg0ODc1IHoiCiAgICAgZGlzcGxheT0iYmxvY2siCiAgICAgZmlsbD0ibm9uZSIKICAgICBvcGFjaXR5PSIwLjUwNSIKICAgICBvdmVyZmxvdz0idmlzaWJsZSIKICAgICBzdHJva2U9InVybCgjbSkiCiAgICAgc3Ryb2tlLXdpZHRoPSIwLjc0MTk5OCIKICAgICBzdHlsZT0ic3Ryb2tlOnVybCgjbGluZWFyR3JhZGllbnQxMjE3NTgpO21hcmtlcjpub25lIgogICAgIGlkPSJwYXRoODUtMS04LTUtNy0wIiAvPgogIDxyZWN0CiAgICAgc3R5bGU9Im9wYWNpdHk6MC4zO2ZpbGw6bm9uZTtzdHJva2U6dXJsKCNsaW5lYXJHcmFkaWVudDEyMTc2MCk7c3Ryb2tlLXdpZHRoOjAuOTk5OTg0O3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgaWQ9InJlY3Q2NzQxLTUtMC0yLTMtNC0yLTQiCiAgICAgeT0iMTIuNDk5OTkyIgogICAgIHg9IjUuNDk5OTk0MyIKICAgICByeT0iMy41IgogICAgIGhlaWdodD0iMzEuMDAwMDE3IgogICAgIHdpZHRoPSIzNyIKICAgICByeD0iMy41IiAvPgogIDxwYXRoCiAgICAgaWQ9InJlY3Q1NTA1LTIxLTEtNS0wLTYtNS0xLTItNS0xLTQiCiAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7Zm9udC12YXJpYXRpb24tc2V0dGluZ3M6bm9ybWFsO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmlzaWJpbGl0eTp2aXNpYmxlO3ZlY3Rvci1lZmZlY3Q6bm9uZTtmaWxsOm5vbmU7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiM4MDRiMDA7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MC41Oy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJtIDExLjU5MDkyMyw1LjQ5OTk5OTUgYyAtMi4zNTcwMTgsMCAtMy4yOTcyNzMsMS4zOTE1ODQ0IC00LjI1NDU0NSwzLjU0NTQ1NDYgQyA2LjYwMjYyNSwxMC42OTIwNDggNS43NDg5LDEyLjM5NTcxMyA1LjA3MDYxMywxNC4wMDkwOTEgNC43MDk4NjksMTQuNjM0NjA3IDQuNTAwMDE0LDE1LjM1OTU0OSA0LjUwMDAxNCwxNi4xMzYzNjMgdiAyNC4xMDkwOTIgYyAwLDIuMzU3MDE4IDEuODk3NTI3LDQuMjU0NTQ2IDQuMjU0NTQ1LDQuMjU0NTQ2IGggMzAuNDkwOTEzIGMgMi4zNTcwMTgsMCA0LjI1NDU0NSwtMS44OTc1MjggNC4yNTQ1NDUsLTQuMjU0NTQ2IFYgMTYuMTM2MzYzIGMgMCwtMC43NzY4MTQgLTAuMjA5ODU1LC0xLjUwMTc1NiAtMC41NzA1OTgsLTIuMTI3MjcyIEMgNDIuMjMyNzQ0LDEyLjQwMzg4MyA0MS40MTc5MiwxMC42ODI4MTYgNDAuNjYzNjUzLDkuMDQ1NDU0MSAzOS42NzczNzksNi45MDQ3MDY4IDM4Ljc2NjEyNiw1LjQ5OTk5OTUgMzYuNDA5MTA4LDUuNDk5OTk5NSBaIiAvPgogIDxwYXRoCiAgICAgaWQ9InJlY3Q1NTA1LTIxLTEtNS0wLTYtNS0xLTItNS0xLTctNyIKICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmb250LXZhcmlhdGlvbi1zZXR0aW5nczpub3JtYWw7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2aXNpYmlsaXR5OnZpc2libGU7b3BhY2l0eTowLjE1O3ZlY3Rvci1lZmZlY3Q6bm9uZTtmaWxsOm5vbmU7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOnVybCgjbGluZWFyR3JhZGllbnQxMjE3NjQpO3N0cm9rZS13aWR0aDowLjk5OTk5MTtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDtzdHJva2Utb3BhY2l0eToxOy1pbmtzY2FwZS1zdHJva2U6bm9uZTttYXJrZXI6bm9uZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMCIKICAgICBkPSJNIDQxLjU1OTA5NywxMy4xOCAzOS44NDYyNjEsOS42MDExMDA3IEMgMzkuMzY4MTczLDguNTU5Njc2MSAzOC45MjI4MjksNy43NTkzNzQ5IDM4LjQwNDc1NSw3LjI2MTE2MyAzNy44ODY2NzQsNi43NjI5NTEyIDM3LjMxMzE3Miw2LjQ5OTk5NDUgMzYuMjg5NzksNi40OTk5OTQ1IEggMTEuNzExMjE4IGMgLTEuMDI0NzMsMCAtMS42MDg4MjEsMC4yNjI2MDMyIC0yLjEyODY4MDQsMC43NTg0MTU4IEMgOS4wNjI2ODA1LDcuNzU0MjIyOCA4LjYyMDYzMSw4LjU0ODc0MjMgOC4xNTg4NDg4LDkuNTkxNDY3NyB2IDAuMDAxNDEgTCA2LjU5Nzg2MDMsMTMuMjU2NzI1IiAvPgogIDxwYXRoCiAgICAgZD0ibSAyMiw1IGggNCBWIDE5IEMgMjUuNjA2LDE5IDI1LjIxMywxOC4yMjkgMjQuODE5LDE4LjIyOSAyNC40MTYsMTguMjI5IDI0LjAxMywxOSAyMy42MDksMTkgMjMuMjg1LDE5IDIyLjk2LDE4LjMyNSAyMi42MzYsMTguMzI1IDIyLjQyNCwxOC4zMjUgMjIuMjEyLDE5IDIyLDE5IFoiCiAgICAgZmlsbD0idXJsKCNuKSIKICAgICBvcGFjaXR5PSIwLjMiCiAgICAgb3ZlcmZsb3c9InZpc2libGUiCiAgICAgc3R5bGU9ImZpbGw6dXJsKCNuKTttYXJrZXI6bm9uZSIKICAgICBpZD0icGF0aDg3IiAvPgo8L3N2Zz4K'; \ No newline at end of file diff --git a/src/backend/src/modules/apps/lib/IconResult.js b/src/backend/src/modules/apps/lib/IconResult.js new file mode 100644 index 0000000000000000000000000000000000000000..7ce91b419d09c6ad667837a60603580e9c5c108f --- /dev/null +++ b/src/backend/src/modules/apps/lib/IconResult.js @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { Context } = require('../../../util/context'); +const { stream_to_buffer } = require('../../../util/streamutil'); + +module.exports = class IconResult { + constructor (o) { + Object.assign(this, o); + } + + async get_data_url () { + if ( this.data_url ) { + return this.data_url; + } else { + try { + const buffer = await stream_to_buffer(this.stream); + return `data:${this.mime};base64,${buffer.toString('base64')}`; + } catch (e) { + const svc_error = Context.get(undefined, { + allow_fallback: true, + }).get('services').get('error'); + svc_error.report('IconResult:get_data_url', { + source: e, + }); + // TODO: broken image icon here + return `data:image/png;base64,${Buffer.from([]).toString('base64')}`; + } + } + } +}; diff --git a/src/backend/src/modules/broadcast/BroadcastModule.js b/src/backend/src/modules/broadcast/BroadcastModule.js new file mode 100644 index 0000000000000000000000000000000000000000..affb61aed85708f862d65f6ae7c232935cc3efd4 --- /dev/null +++ b/src/backend/src/modules/broadcast/BroadcastModule.js @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +class BroadcastModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { BroadcastService } = require('./BroadcastService'); + services.registerService('broadcast', BroadcastService); + } +} + +module.exports = { + BroadcastModule, +}; diff --git a/src/backend/src/modules/broadcast/BroadcastService.js b/src/backend/src/modules/broadcast/BroadcastService.js new file mode 100644 index 0000000000000000000000000000000000000000..861e8260fbab4b5224a1c35364375e2d9f72f905 --- /dev/null +++ b/src/backend/src/modules/broadcast/BroadcastService.js @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); +const { CLink } = require('./connection/CLink'); +const { SLink } = require('./connection/SLink'); +const { Context } = require('../../util/context'); + +class BroadcastService extends BaseService { + static MODULES = { + express: require('express'), + // ['socket.io']: require('socket.io'), + }; + + _construct () { + this.peers_ = []; + this.connections_ = []; + this.trustedPublicKeys_ = {}; + } + + async _init () { + const peers = this.config.peers ?? []; + for ( const peer_config of peers ) { + this.trustedPublicKeys_[peer_config.key] = true; + const peer = new CLink({ + keys: this.config.keys, + config: peer_config, + log: this.log, + }); + this.peers_.push(peer); + peer.connect(); + } + + this._register_commands(this.services.get('commands')); + + const svc_event = this.services.get('event'); + svc_event.on('outer.*', this.on_event.bind(this)); + } + + async on_event (key, data, meta) { + if ( meta.from_outside ) return; + + for ( const peer of this.peers_ ) { + try { + peer.send({ key, data, meta }); + } catch (e) { + // + } + } + } + + async ['__on_install.websockets'] () { + const svc_event = this.services.get('event'); + const svc_webServer = this.services.get('web-server'); + + const server = svc_webServer.get_server(); + + const io = require('socket.io')(server, { + cors: { origin: '*' }, + path: '/wssinternal', + }); + + io.on('connection', async socket => { + const conn = new SLink({ + keys: this.config.keys, + trustedKeys: this.trustedPublicKeys_, + socket, + }); + this.connections_.push(conn); + + conn.channels.message.on(({ key, data, meta }) => { + if ( meta.from_outside ) { + console.warn('possible over-sending'); + return; + } + + if ( key === 'test' ) { + this.log.noticeme(`test message: ${ + JSON.stringify(data)}`); + } + + meta.from_outside = true; + const context = Context.get(undefined, { allow_fallback: true }); + context.arun(async () => { + await svc_event.emit(key, data, meta); + }); + }); + }); + } + + _register_commands (commands) { + commands.registerCommands('broadcast', [ + { + id: 'test', + description: 'send a test message', + handler: async (args, ctx) => { + this.on_event('test', { + contents: 'I am a test message', + }, {}); + }, + }, + ]); + } +} + +module.exports = { BroadcastService }; diff --git a/src/backend/src/modules/broadcast/connection/BaseLink.js b/src/backend/src/modules/broadcast/connection/BaseLink.js new file mode 100644 index 0000000000000000000000000000000000000000..b1bf36b4ac92769cf2c7cb0e6f1b7b7ee7fb2102 --- /dev/null +++ b/src/backend/src/modules/broadcast/connection/BaseLink.js @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const { ChannelFeature } = require('../../../traits/ChannelFeature'); + +class BaseLink extends AdvancedBase { + static FEATURES = [ + new ChannelFeature(), + ]; + static CHANNELS = ['message']; + + static MODULES = { + crypto: require('crypto'), + }; + + static AUTHENTICATING = {}; + static ONLINE = {}; + static OFFLINE = {}; + + send (data) { + if ( this.state !== this.constructor.ONLINE ) { + return false; + } + + return this._send(data); + } + + constructor () { + super(); + this.state = this.constructor.AUTHENTICATING; + } +} + +module.exports = { + BaseLink, +}; diff --git a/src/backend/src/modules/broadcast/connection/CLink.js b/src/backend/src/modules/broadcast/connection/CLink.js new file mode 100644 index 0000000000000000000000000000000000000000..cc6f5668a10e7b31b8217e4cb549c34694726303 --- /dev/null +++ b/src/backend/src/modules/broadcast/connection/CLink.js @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { BaseLink } = require('./BaseLink'); +const { KeyPairHelper } = require('./KeyPairHelper'); + +/** + * Client-side link that establishes an encrypted socket.io connection. + * Handles AES-256-CBC encryption for message transmission and uses asymmetric + * key exchange for secure AES key distribution. + */ +class CLink extends BaseLink { + static MODULES = { + sioclient: require('socket.io-client'), + }; + + /** + * Encrypts the data using AES-256-CBC and sends it through the socket. + * The data is JSON stringified, encrypted with a random IV, and transmitted + * as a buffer along with the IV. + * + * @param {*} data - The data to be encrypted and sent through the socket + * @returns {void} + */ + _send (data) { + if ( ! this.socket ) return; + const require = this.require; + const crypto = require('crypto'); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', + this.aesKey, + iv); + const jsonified = JSON.stringify(data); + let buffers = []; + buffers.push(cipher.update(Buffer.from(jsonified, 'utf-8'))); + buffers.push(cipher.final()); + const buffer = Buffer.concat(buffers); + this.socket.send({ + iv, + message: buffer, + }); + } + + /** + * Initializes the client link with local keys, remote server configuration, and logger. + */ + constructor ({ + keys, + log, + config, + }) { + super(); + // keys of client (local) + this.keys = keys; + // keys of server (remote) + this.config = config; + this.log = log; + } + + /** + * Establishes a socket.io connection to the configured server address. + * Generates an AES key, encrypts it using the server's public key, and sends + * it during the handshake. Sets up event handlers for connection lifecycle + * and message reception. + */ + connect () { + let address = this.config.address; + if ( ! ( + address.startsWith('https://') || + address.startsWith('http://') + ) ) { + address = `https://${address}`; + } + const socket = this.modules.sioclient(address, { + transports: ['websocket'], + path: '/wssinternal', + reconnection: true, + extraHeaders: { + ...(this.config.host ? { + Host: this.config.host, + } : {}), + }, + }); + socket.on('connect', () => { + this.log.info('connected', { + address, + }); + + const require = this.require; + const crypto = require('crypto'); + this.aesKey = crypto.randomBytes(32); + + const kp_helper = new KeyPairHelper({ + kpublic: this.config.key, + ksecret: this.keys.secret, + }); + socket.send({ + $: 'take-my-key', + key: this.keys.public, + message: kp_helper.write(this.aesKey.toString('base64')), + }); + this.state = this.constructor.ONLINE; + }); + socket.on('disconnect', () => { + this.log.info('disconnected', { + address, + }); + }); + socket.on('connect_error', e => { + console.error('connection error', { + address, + e: e, + }); + }); + socket.on('error', e => { + console.error(e); + }); + socket.on('message', data => { + if ( this.state.on_message ) { + this.state.on_message.call(this, data); + } + }); + + this.socket = socket; + } +} + +module.exports = { CLink }; diff --git a/src/backend/src/modules/broadcast/connection/KeyPairHelper.js b/src/backend/src/modules/broadcast/connection/KeyPairHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..77168adf0a34b10c4d1184017928c6034c2d8750 --- /dev/null +++ b/src/backend/src/modules/broadcast/connection/KeyPairHelper.js @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +class KeyPairHelper extends AdvancedBase { + static MODULES = { + tweetnacl: require('tweetnacl'), + }; + + constructor ({ + kpublic, + ksecret, + }) { + super(); + this.kpublic = kpublic; + this.ksecret = ksecret; + this.nonce_ = 0; + } + + to_nacl_key_ (key) { + const full_buffer = Buffer.from(key, 'base64'); + + // Remove version byte (assumed to be 0x31 and ignored for now) + const buffer = full_buffer.slice(1); + + return new Uint8Array(buffer); + } + + get naclSecret () { + return this.naclSecret_ ?? ( + this.naclSecret_ = this.to_nacl_key_(this.ksecret)); + } + get naclPublic () { + return this.naclPublic_ ?? ( + this.naclPublic_ = this.to_nacl_key_(this.kpublic)); + } + + write (text) { + const require = this.require; + const nacl = require('tweetnacl'); + + const nonce = nacl.randomBytes(nacl.box.nonceLength); + const message = {}; + + const textUint8 = new Uint8Array(Buffer.from(text, 'utf-8')); + const encryptedText = nacl.box(textUint8, nonce, this.naclPublic, this.naclSecret); + message.text = Buffer.from(encryptedText); + message.nonce = Buffer.from(nonce); + + return message; + } + + read (message) { + const require = this.require; + const nacl = require('tweetnacl'); + + const arr = nacl.box.open(new Uint8Array(message.text), + new Uint8Array(message.nonce), + this.naclPublic, + this.naclSecret); + + return Buffer.from(arr).toString('utf-8'); + } +} + +module.exports = { + KeyPairHelper, +}; diff --git a/src/backend/src/modules/broadcast/connection/SLink.js b/src/backend/src/modules/broadcast/connection/SLink.js new file mode 100644 index 0000000000000000000000000000000000000000..7f446693a1658bcacda29e3587dc2b3e4e3df863 --- /dev/null +++ b/src/backend/src/modules/broadcast/connection/SLink.js @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { BaseLink } = require('./BaseLink'); +const { KeyPairHelper } = require('./KeyPairHelper'); + +class SLink extends BaseLink { + static AUTHENTICATING = { + on_message (data) { + if ( data.$ !== 'take-my-key' ) { + this.disconnect(); + return; + } + + const trustedKeys = this.trustedKeys; + + const hasKey = trustedKeys[data.key]; + if ( ! hasKey ) { + this.disconnect(); + return; + } + + const is_trusted = trustedKeys.hasOwnProperty(data.key); + if ( ! is_trusted ) { + this.disconnect(); + return; + } + + const kp_helper = new KeyPairHelper({ + kpublic: data.key, + ksecret: this.keys.secret, + }); + + const message = kp_helper.read(data.message); + this.aesKey = Buffer.from(message, 'base64'); + + this.state = this.constructor.ONLINE; + }, + }; + static ONLINE = { + on_message (data) { + const require = this.require; + const crypto = require('crypto'); + const decipher = crypto.createDecipheriv('aes-256-cbc', + this.aesKey, + data.iv); + const buffers = []; + buffers.push(decipher.update(data.message)); + buffers.push(decipher.final()); + + const rawjson = Buffer.concat(buffers).toString('utf-8'); + + const output = JSON.parse(rawjson); + + this.channels.message.emit(output); + }, + }; + static OFFLINE = { + on_message () { + throw new Error('unexpected message'); + }, + }; + + _send () { + // TODO: implement as a fallback + throw new Error('cannot send via SLink yet'); + } + + disconnect () { + this.socket.disconnect(); + this.state = this.constructor.OFFLINE; + } + + constructor ({ + keys, + trustedKeys, + socket, + }) { + super(); + this.state = this.constructor.AUTHENTICATING; + // Keys of server (local) + this.keys = keys; + // Allowed client keys (remote) + this.trustedKeys = trustedKeys; + this.socket = socket; + + socket.on('message', data => { + this.state.on_message.call(this, data); + }); + } +} + +module.exports = { SLink }; diff --git a/src/backend/src/modules/captcha/CaptchaModule.js b/src/backend/src/modules/captcha/CaptchaModule.js new file mode 100644 index 0000000000000000000000000000000000000000..fc6b50b0df6442724cdb61ea780af989b5715c69 --- /dev/null +++ b/src/backend/src/modules/captcha/CaptchaModule.js @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const CaptchaService = require('./services/CaptchaService'); + +/** + * @class CaptchaModule + * @extends AdvancedBase + * @description Module that provides captcha verification functionality to protect + * against automated abuse, particularly for login and signup flows. Registers + * a CaptchaService for generating and verifying captchas as well as middlewares + * that can be used to protect routes and determine captcha requirements. + */ +class CaptchaModule extends AdvancedBase { + async install (context) { + + // Get services from context + const services = context.get('services'); + + // Register the captcha service + services.registerService('captcha', CaptchaService); + } +} + +module.exports = { CaptchaModule }; \ No newline at end of file diff --git a/src/backend/src/modules/captcha/README.md b/src/backend/src/modules/captcha/README.md new file mode 100644 index 0000000000000000000000000000000000000000..9b5e09d2b4759f6ea2ba93439b4e112ead0d1a9c --- /dev/null +++ b/src/backend/src/modules/captcha/README.md @@ -0,0 +1,73 @@ +# Captcha Module + +This module provides captcha verification functionality to protect against automated abuse, particularly for login and signup flows. + +## Components + +- **CaptchaModule.js**: Registers the service and middleware +- **CaptchaService.js**: Provides captcha generation and verification functionality +- **captcha-middleware.js**: Express middleware for protecting routes with captcha verification + +## Integration + +The CaptchaService is registered by the CaptchaModule and can be accessed by other services: + +```javascript +const captchaService = services.get('captcha'); +``` + +### Example Usage + +```javascript +// Generate a captcha +const captcha = captchaService.generateCaptcha(); +// captcha.token - The token to verify later +// captcha.image - SVG image data to display to the user + +// Verify a captcha +const isValid = captchaService.verifyCaptcha(token, userAnswer); +``` + +## Configuration + +The CaptchaService can be configured with the following options in the configuration file (`config.json`): + +- `captcha.enabled`: Whether the captcha service is enabled (default: false) +- `captcha.expirationTime`: How long captcha tokens are valid in milliseconds (default: 10 minutes) +- `captcha.difficulty`: The difficulty level of the captcha ('easy', 'medium', 'hard') (default: 'medium') + +These options are set in the main configuration file. For example: + +```json +{ + "services": { + "captcha": { + "enabled": false, + "expirationTime": 600000, + "difficulty": "medium" + } + } +} +``` + +### Development Configuration + +For local development, you can disable captcha by creating or modifying your local configuration file (e.g., in `volatile/config/config.json` or using a profile configuration): + +```json +{ + "$version": "v1.1.0", + "$requires": [ + "config.json" + ], + "config_name": "local", + + "services": { + "captcha": { + "enabled": false + } + } +} +``` + +These options are set when registering the service in CaptchaModule.js. \ No newline at end of file diff --git a/src/backend/src/modules/captcha/middleware/README.md b/src/backend/src/modules/captcha/middleware/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d24ec3cc100e8bf0f223600558f666157b0937a1 --- /dev/null +++ b/src/backend/src/modules/captcha/middleware/README.md @@ -0,0 +1,160 @@ +# Captcha Middleware + +This middleware provides captcha verification for routes that need protection against automated abuse. + +## Middleware Components + +The captcha system is now split into two middleware components: + +1. **checkCaptcha**: Determines if captcha verification is required but doesn't perform verification. +2. **requireCaptcha**: Performs actual captcha verification based on the result from checkCaptcha. + +This split allows frontend applications to know in advance whether captcha verification will be needed for a particular action. + +## Usage Patterns + +### Using Both Middlewares (Recommended) + +For best user experience, use both middlewares together: + +```javascript +const express = require('express'); +const router = express.Router(); + +// Get both middleware components from the context +const { checkCaptcha, requireCaptcha } = context.get('captcha-middleware'); + +// Determine if captcha is required for this route +router.post('/login', checkCaptcha({ eventType: 'login' }), (req, res, next) => { + // Set a flag in the response so frontend knows if captcha is needed + res.locals.captchaRequired = req.captchaRequired; + next(); +}, requireCaptcha(), (req, res) => { + // Handle login logic + // If captcha was required, it has been verified at this point +}); +``` + +### Using Individual Middlewares + +You can also access each middleware separately: + +```javascript +const checkCaptcha = context.get('check-captcha-middleware'); +const requireCaptcha = context.get('require-captcha-middleware'); +``` + +### Using Only requireCaptcha (Legacy Mode) + +For backward compatibility, you can still use only the requireCaptcha middleware: + +```javascript +const requireCaptcha = context.get('require-captcha-middleware'); + +// Always require captcha for this route +router.post('/sensitive-route', requireCaptcha({ always: true }), (req, res) => { + // Route handler +}); + +// Conditionally require captcha based on extensions +router.post('/normal-route', requireCaptcha(), (req, res) => { + // Route handler +}); +``` + +## Configuration Options + +### checkCaptcha Options + +- `always` (boolean): Always require captcha regardless of other factors +- `strictMode` (boolean): If true, fails closed on errors (more secure) +- `eventType` (string): Type of event for extensions (e.g., 'login', 'signup') + +### requireCaptcha Options + +- `strictMode` (boolean): If true, fails closed on errors (more secure) + +## Frontend Integration + +There are two ways to integrate with the frontend: + +### 1. Using the checkCaptcha Result in API Responses + +You can include the captcha requirement in API responses: + +```javascript +router.get('/whoarewe', checkCaptcha({ eventType: 'login' }), (req, res) => { + res.json({ + // Other environment information + captchaRequired: { + login: req.captchaRequired + } + }); +}); +``` + +### 2. Setting GUI Parameters + +For PuterHomepageService, you can add captcha requirements to GUI parameters: + +```javascript +// In PuterHomepageService.js +gui_params: { + // Other parameters + captchaRequired: { + login: req.captchaRequired + } +} +``` + +## Client-Side Integration + +To integrate with the captcha middleware, the client needs to: + +1. Check if captcha is required for the action (using /whoarewe or GUI parameters) +2. If required, call the `/api/captcha/generate` endpoint to get a captcha token and image +3. Display the captcha image to the user and collect their answer +4. Include the captcha token and answer in the request body: + +```javascript +// Example client-side code +async function submitWithCaptcha(formData) { + // Check if captcha is required + const envInfo = await fetch('/api/whoarewe').then(r => r.json()); + + if (envInfo.captchaRequired?.login) { + // Get and display captcha to user + const captcha = await getCaptchaFromServer(); + showCaptchaToUser(captcha); + + // Add captcha token and answer to the form data + formData.captchaToken = captcha.token; + formData.captchaAnswer = await getUserCaptchaAnswer(); + } + + // Submit the form + const response = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + // Handle response + const data = await response.json(); + if (response.status === 400 && data.error === 'captcha_required') { + // Show captcha to the user if not already shown + showCaptcha(); + } +} +``` + +## Error Handling + +The middleware will throw the following errors: + +- `captcha_required`: When captcha verification is required but no token or answer was provided. +- `captcha_invalid`: When the provided captcha answer is incorrect. + +These errors can be caught by the API error handler and returned to the client. \ No newline at end of file diff --git a/src/backend/src/modules/captcha/middleware/captcha-middleware.js b/src/backend/src/modules/captcha/middleware/captcha-middleware.js new file mode 100644 index 0000000000000000000000000000000000000000..f121ff5a5cac635ffcb697748b1443c17df1ace1 --- /dev/null +++ b/src/backend/src/modules/captcha/middleware/captcha-middleware.js @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const APIError = require('../../../api/APIError'); +const { Context } = require('../../../util/context'); + +/** + * Middleware that checks if captcha verification is required + * This is the "first half" of the captcha verification process + * It determines if verification is needed but doesn't perform verification + * + * @param {Object} options - Configuration options + * @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure) + * @returns {Function} Express middleware function + */ +const checkCaptcha = ({ svc_captcha }) => async (req, res, next) => { + // Get services from the Context + const services = Context.get('services'); + + if ( ! svc_captcha.enabled ) { + req.captchaRequired = false; + return next(); + } + const ip = req.headers?.['x-forwarded-for'] || + req.connection?.remoteAddress; + + const svc_event = services.get('event'); + const event = { + ip, + // By default, captcha always appears if enabled + required: true, + }; + await svc_event.emit('captcha.check', event); + + // Set captcha requirement based on service status + req.captchaRequired = event.required; + next(); +}; + +/** + * Middleware that requires captcha verification + * This is the "second half" of the captcha verification process + * It uses the result from checkCaptcha to determine if verification is needed + * + * @param {Object} options - Configuration options + * @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure) + * @returns {Function} Express middleware function + */ +const requireCaptcha = (options = {}) => async (req, res, next) => { + if ( ! req.captchaRequired ) { + return next(); + } + + const services = Context.get('services'); + + try { + let captchaService; + try { + captchaService = services.get('captcha'); + } catch ( error ) { + console.warn('Captcha verification: required service not available', error); + return next(APIError.create('internal_error', null, { + message: 'Captcha service unavailable', + status: 503, + })); + } + + // Fail closed if captcha service doesn't exist or isn't properly initialized + if ( !captchaService || typeof captchaService.verifyCaptcha !== 'function' ) { + return next(APIError.create('internal_error', null, { + message: 'Captcha service misconfigured', + status: 500, + })); + } + + // Check for captcha token and answer in request + const captchaToken = req.body.captchaToken; + const captchaAnswer = req.body.captchaAnswer; + + if ( !captchaToken || !captchaAnswer ) { + return next(APIError.create('captcha_required', null, { + message: 'Captcha verification required', + status: 400, + })); + } + + // Verify the captcha + let isValid; + try { + isValid = captchaService.verifyCaptcha(captchaToken, captchaAnswer); + } catch ( verifyError ) { + console.error('Captcha verification: threw an error', verifyError); + return next(APIError.create('captcha_invalid', null, { + message: 'Captcha verification failed', + status: 400, + })); + } + + // Check verification result + if ( ! isValid ) { + return next(APIError.create('captcha_invalid', null, { + message: 'Invalid captcha response', + status: 400, + })); + } + + // Captcha verified successfully, continue + next(); + } catch ( error ) { + console.error('Captcha verification: unexpected error', error); + return next(APIError.create('internal_error', null, { + message: 'Captcha verification failed', + status: 500, + })); + } +}; + +module.exports = { + checkCaptcha, + requireCaptcha, +}; \ No newline at end of file diff --git a/src/backend/src/modules/captcha/services/CaptchaService.js b/src/backend/src/modules/captcha/services/CaptchaService.js new file mode 100644 index 0000000000000000000000000000000000000000..160822643ec202970c708b5e29f172cfe782a6dd --- /dev/null +++ b/src/backend/src/modules/captcha/services/CaptchaService.js @@ -0,0 +1,647 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../../services/BaseService'); +const { Endpoint } = require('../../../util/expressutil'); +const { checkCaptcha } = require('../middleware/captcha-middleware'); + +/** + * @class CaptchaService + * @extends BaseService + * @description Service that provides captcha generation and verification functionality + * to protect against automated abuse. Uses svg-captcha for generation and maintains + * a token-based verification system. + */ +class CaptchaService extends BaseService { + /** + * Initializes the captcha service with configuration and storage + */ + async _construct () { + // Load dependencies + this.crypto = require('crypto'); + this.svgCaptcha = require('svg-captcha'); + + // In-memory token storage with expiration + this.captchaTokens = new Map(); + + // Service instance diagnostic tracking + this.serviceId = Math.random().toString(36).substring(2, 10); + this.requestCounter = 0; + + // Get configuration from service config + this.enabled = this.config.enabled === true; + this.expirationTime = this.config.expirationTime || (10 * 60 * 1000); // 10 minutes default + this.difficulty = this.config.difficulty || 'medium'; + this.testMode = this.config.testMode === true; + + // Add a static test token for diagnostic purposes + this.captchaTokens.set('test-static-token', { + text: 'testanswer', + expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 1 year + }); + + // Flag to track if endpoints are registered + this.endpointsRegistered = false; + } + + async ['__on_install.middlewares.context-aware'] (_, { app }) { + // Add express middleware + app.use(checkCaptcha({ svc_captcha: this })); + } + + /** + * Sets up API endpoints and cleanup tasks + */ + async _init () { + if ( ! this.enabled ) { + this.log.debug('Captcha service is disabled'); + return; + } + + // Set up periodic cleanup + this.cleanupInterval = setInterval(() => this.cleanupExpiredTokens(), 15 * 60 * 1000); + + // Register endpoints if not already done + if ( ! this.endpointsRegistered ) { + this.registerEndpoints(); + this.endpointsRegistered = true; + } + } + + /** + * Cleanup method called when service is being destroyed + */ + async _destroy () { + if ( this.cleanupInterval ) { + clearInterval(this.cleanupInterval); + } + this.captchaTokens.clear(); + } + + /** + * Registers the captcha API endpoints with the web service + * @private + */ + registerEndpoints () { + if ( this.endpointsRegistered ) { + return; + } + + try { + // Try to get the web service + let webService = null; + try { + webService = this.services.get('web-service'); + } catch ( error ) { + // Web service not available, try web-server + try { + webService = this.services.get('web-server'); + } catch ( innerError ) { + this.log.warn('Neither web-service nor web-server are available yet'); + return; + } + } + + if ( !webService || !webService.app ) { + this.log.warn('Web service found but app is not available'); + return; + } + + const app = webService.app; + + const api = this.require('express').Router(); + app.use('/api/captcha', api); + + // Generate captcha endpoint + Endpoint({ + route: '/generate', + methods: ['GET'], + handler: async (req, res) => { + const captcha = this.generateCaptcha(); + res.json({ + token: captcha.token, + image: captcha.data, + }); + }, + }).attach(api); + + // Verify captcha endpoint + Endpoint({ + route: '/verify', + methods: ['POST'], + handler: (req, res) => { + const { token, answer } = req.body; + + if ( !token || !answer ) { + return res.status(400).json({ + valid: false, + error: 'Missing token or answer', + }); + } + + const isValid = this.verifyCaptcha(token, answer); + res.json({ valid: isValid }); + }, + }).attach(api); + + // Special endpoint for automated testing + // This should be disabled in production + if ( this.testMode ) { + app.post('/api/captcha/create-test-token', (req, res) => { + try { + const { token, answer } = req.body; + + if ( !token || !answer ) { + return res.status(400).json({ + error: 'Missing token or answer', + }); + } + + // Store the test token with the provided answer + this.captchaTokens.set(token, { + text: answer.toLowerCase(), + expiresAt: Date.now() + this.expirationTime, + }); + + this.log.debug(`Created test token: ${token} with answer: ${answer}`); + res.json({ success: true }); + } catch ( error ) { + this.log.error(`Error creating test token: ${error.message}`); + res.status(500).json({ error: 'Failed to create test token' }); + } + }); + } + + // Diagnostic endpoint - should be used carefully and only during debugging + app.get('/api/captcha/diagnostic', (req, res) => { + try { + // Get information about the current state + const diagnosticInfo = { + serviceEnabled: this.enabled, + difficulty: this.difficulty, + expirationTime: this.expirationTime, + testMode: this.testMode, + activeTokenCount: this.captchaTokens.size, + serviceId: this.serviceId, + processId: process.pid, + requestCounter: this.requestCounter, + hasStaticTestToken: this.captchaTokens.has('test-static-token'), + tokensState: Array.from(this.captchaTokens).map(([token, data]) => ({ + tokenPrefix: `${token.substring(0, 8) }...`, + expiresAt: new Date(data.expiresAt).toISOString(), + expired: data.expiresAt < Date.now(), + expectedAnswer: data.text, + })), + }; + + res.json(diagnosticInfo); + } catch ( error ) { + this.log.error(`Error in diagnostic endpoint: ${error.message}`); + res.status(500).json({ error: 'Diagnostic error' }); + } + }); + + // Advanced token debugging endpoint - allows testing + app.get('/api/captcha/debug-tokens', (req, res) => { + try { + // Check if we're the same service instance + const currentTimestamp = Date.now(); + const currentTokens = Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)); + + // Create a test token that won't expire soon + const debugToken = `debug-${ this.crypto.randomBytes(8).toString('hex')}`; + const debugAnswer = 'test123'; + + this.captchaTokens.set(debugToken, { + text: debugAnswer, + expiresAt: currentTimestamp + (60 * 60 * 1000), // 1 hour + }); + + // Information about the current service instance + const serviceInfo = { + message: 'Debug token created - use for testing captcha validation', + serviceId: this.serviceId, + debugToken: debugToken, + debugAnswer: debugAnswer, + tokensBefore: currentTokens, + tokensAfter: Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)), + currentTokenCount: this.captchaTokens.size, + timestamp: currentTimestamp, + processId: process.pid, + }; + + res.json(serviceInfo); + } catch ( error ) { + this.log.error(`Error in debug-tokens endpoint: ${error.message}`); + res.status(500).json({ error: 'Debug token creation error' }); + } + }); + + // Configuration verification endpoint + app.get('/api/captcha/config-status', (req, res) => { + try { + // Information about configuration states + const configInfo = { + serviceEnabled: this.enabled, + serviceDifficulty: this.difficulty, + configSource: 'Service configuration', + centralConfig: { + enabled: this.enabled, + difficulty: this.difficulty, + expirationTime: this.expirationTime, + testMode: this.testMode, + }, + usingCentralizedConfig: true, + configConsistency: this.enabled === (this.enabled === true), + serviceId: this.serviceId, + processId: process.pid, + }; + + res.json(configInfo); + } catch ( error ) { + this.log.error(`Error in config-status endpoint: ${error.message}`); + res.status(500).json({ error: 'Configuration status error' }); + } + }); + + // Test endpoint to validate token lifecycle + app.get('/api/captcha/test-lifecycle', (req, res) => { + try { + // Create a test captcha + const testText = 'test123'; + const testToken = `lifecycle-${ this.crypto.randomBytes(16).toString('hex')}`; + + // Store the test token + this.captchaTokens.set(testToken, { + text: testText, + expiresAt: Date.now() + this.expirationTime, + }); + + // Verify the token exists + const tokenExists = this.captchaTokens.has(testToken); + // Try to verify with correct answer + const correctVerification = this.verifyCaptcha(testToken, testText); + // Check if token was deleted after verification + const tokenAfterVerification = this.captchaTokens.has(testToken); + + // Create another test token + const testToken2 = `lifecycle2-${ this.crypto.randomBytes(16).toString('hex')}`; + + // Store the test token + this.captchaTokens.set(testToken2, { + text: testText, + expiresAt: Date.now() + this.expirationTime, + }); + + res.json({ + message: 'Token lifecycle test completed', + serviceId: this.serviceId, + initialTokens: this.captchaTokens.size - 2, // minus the two we added + tokenCreated: true, + tokenExisted: tokenExists, + verificationResult: correctVerification, + tokenRemovedAfterVerification: !tokenAfterVerification, + secondTokenCreated: this.captchaTokens.has(testToken2), + processId: process.pid, + }); + } catch ( error ) { + console.error('TOKENS_TRACKING: Error in test-lifecycle endpoint:', error); + res.status(500).json({ error: 'Test lifecycle error' }); + } + }); + + this.endpointsRegistered = true; + this.log.debug('Captcha service endpoints registered successfully'); + + // Emit an event that captcha service is ready + try { + const eventService = this.services.get('event'); + if ( eventService ) { + eventService.emit('service-ready', 'captcha'); + } + } catch ( error ) { + // Ignore errors with event service + } + } catch ( error ) { + this.log.warn(`Could not register captcha endpoints: ${error.message}`); + } + } + + /** + * Generates a new captcha with a unique token + * @returns {Object} Object containing token and SVG image + */ + generateCaptcha () { + console.log('====== CAPTCHA GENERATION DIAGNOSTIC ======'); + console.log('TOKENS_TRACKING: generateCaptcha called. Service ID:', this.serviceId); + console.log('TOKENS_TRACKING: Token map size before generation:', this.captchaTokens.size); + console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token')); + + // Increment request counter for diagnostics + this.requestCounter++; + console.log('TOKENS_TRACKING: Request counter value:', this.requestCounter); + + console.log('generateCaptcha called, service enabled:', this.enabled); + + if ( ! this.enabled ) { + console.log('Generation SKIPPED: Captcha service is disabled'); + throw new Error('Captcha service is disabled'); + } + + // Configure captcha options based on difficulty + const options = this._getCaptchaOptions(); + console.log('Using captcha options for difficulty:', this.difficulty); + + // Generate the captcha + const captcha = this.svgCaptcha.create(options); + console.log('Captcha created with text:', captcha.text); + + // Generate a unique token + const token = this.crypto.randomBytes(32).toString('hex'); + console.log('Generated token:', `${token.substring(0, 8) }...`); + + // Store token with captcha text and expiration + const expirationTime = Date.now() + this.expirationTime; + console.log('Token will expire at:', new Date(expirationTime)); + + console.log('TOKENS_TRACKING: Token map size before storing new token:', this.captchaTokens.size); + + this.captchaTokens.set(token, { + text: captcha.text.toLowerCase(), + expiresAt: expirationTime, + }); + + console.log('TOKENS_TRACKING: Token map size after storing new token:', this.captchaTokens.size); + console.log('Token stored in captchaTokens. Current token count:', this.captchaTokens.size); + this.log.debug(`Generated captcha with token: ${token}`); + + return { + token: token, + data: captcha.data, + }; + } + + /** + * Verifies a captcha answer against a stored token + * @param {string} token - The captcha token + * @param {string} userAnswer - The user's answer to verify + * @returns {boolean} Whether the answer is valid + */ + verifyCaptcha (token, userAnswer) { + console.log('====== CAPTCHA SERVICE VERIFICATION DIAGNOSTIC ======'); + console.log('TOKENS_TRACKING: verifyCaptcha called. Service ID:', this.serviceId); + console.log('TOKENS_TRACKING: Request counter during verification:', this.requestCounter); + console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token')); + console.log('TOKENS_TRACKING: Trying to verify token:', token ? `${token.substring(0, 8) }...` : 'undefined'); + + console.log('verifyCaptcha called with token:', token ? `${token.substring(0, 8) }...` : 'undefined'); + console.log('userAnswer:', userAnswer); + console.log('Service enabled:', this.enabled); + console.log('Number of tokens in captchaTokens:', this.captchaTokens.size); + + // Service health check + this._checkServiceHealth(); + + if ( ! this.enabled ) { + console.log('Verification SKIPPED: Captcha service is disabled'); + this.log.warn('Captcha verification attempted while service is disabled'); + throw new Error('Captcha service is disabled'); + } + + // Get captcha data for token + const captchaData = this.captchaTokens.get(token); + console.log('Captcha data found for token:', !!captchaData); + + // Invalid token or expired + if ( ! captchaData ) { + console.log('Verification FAILED: No data found for this token'); + console.log('TOKENS_TRACKING: Available tokens (first 8 chars):', + Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8))); + this.log.debug(`Invalid captcha token: ${token}`); + return false; + } + + if ( captchaData.expiresAt < Date.now() ) { + console.log('Verification FAILED: Token expired at:', new Date(captchaData.expiresAt)); + this.log.debug(`Expired captcha token: ${token}`); + return false; + } + + // Normalize and compare answers + const normalizedUserAnswer = userAnswer.toLowerCase().trim(); + console.log('Expected answer:', captchaData.text); + console.log('User answer (normalized):', normalizedUserAnswer); + const isValid = captchaData.text === normalizedUserAnswer; + console.log('Answer comparison result:', isValid); + + // Remove token after verification (one-time use) + this.captchaTokens.delete(token); + console.log('Token removed after verification (one-time use)'); + console.log('TOKENS_TRACKING: Token map size after removing used token:', this.captchaTokens.size); + + this.log.debug(`Verified captcha token: ${token}, valid: ${isValid}`); + return isValid; + } + + /** + * Simple diagnostic method to check service health + * @private + */ + _checkServiceHealth () { + console.log('TOKENS_TRACKING: Service health check. ID:', this.serviceId, 'Token count:', this.captchaTokens.size); + return true; + } + + /** + * Removes expired captcha tokens from memory + */ + cleanupExpiredTokens () { + console.log('TOKENS_TRACKING: Running token cleanup. Service ID:', this.serviceId); + console.log('TOKENS_TRACKING: Token map size before cleanup:', this.captchaTokens.size); + + const now = Date.now(); + let expiredCount = 0; + let validCount = 0; + + // Log all tokens before cleanup + console.log('TOKENS_TRACKING: Current tokens before cleanup:'); + for ( const [token, data] of this.captchaTokens.entries() ) { + const isExpired = data.expiresAt < now; + console.log(`TOKENS_TRACKING: Token ${token.substring(0, 8)}... expires: ${new Date(data.expiresAt).toISOString()}, expired: ${isExpired}`); + + if ( isExpired ) { + expiredCount++; + } else { + validCount++; + } + } + + // Only do the actual cleanup if we found expired tokens + if ( expiredCount > 0 ) { + console.log(`TOKENS_TRACKING: Found ${expiredCount} expired tokens to remove and ${validCount} valid tokens to keep`); + + // Clean up expired tokens + for ( const [token, data] of this.captchaTokens.entries() ) { + if ( data.expiresAt < now ) { + this.captchaTokens.delete(token); + console.log(`TOKENS_TRACKING: Deleted expired token: ${token.substring(0, 8)}...`); + } + } + } else { + console.log('TOKENS_TRACKING: No expired tokens found, skipping cleanup'); + } + + // Skip cleanup for the static test token + if ( this.captchaTokens.has('test-static-token') ) { + console.log('TOKENS_TRACKING: Static test token still exists after cleanup'); + } else { + console.log('TOKENS_TRACKING: WARNING - Static test token was removed during cleanup'); + + // Restore the static test token for diagnostic purposes + this.captchaTokens.set('test-static-token', { + text: 'testanswer', + expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 1 year + }); + console.log('TOKENS_TRACKING: Restored static test token'); + } + + console.log('TOKENS_TRACKING: Token map size after cleanup:', this.captchaTokens.size); + + if ( expiredCount > 0 ) { + this.log.debug(`Cleaned up ${expiredCount} expired captcha tokens`); + } + } + + /** + * Gets captcha options based on the configured difficulty + * @private + * @returns {Object} Captcha configuration options + */ + _getCaptchaOptions () { + const baseOptions = { + size: 6, // Default captcha length + ignoreChars: '0o1ilI', // Characters to avoid (confusing) + noise: 2, // Lines to add as noise + color: true, + background: '#f0f0f0', + }; + + switch ( this.difficulty ) { + case 'easy': + return { + ...baseOptions, + size: 4, + width: 150, + height: 50, + noise: 1, + }; + case 'hard': + return { + ...baseOptions, + size: 7, + width: 200, + height: 60, + noise: 3, + }; + case 'medium': + default: + return { + ...baseOptions, + width: 180, + height: 50, + }; + } + } + + /** + * Verifies that the captcha service is properly configured and working + * This is used during initialization and can be called to check system status + * @returns {boolean} Whether the service is properly configured and functioning + */ + verifySelfTest () { + try { + // Ensure required dependencies are available + if ( ! this.svgCaptcha ) { + this.log.error('Captcha service self-test failed: svg-captcha module not available'); + return false; + } + + if ( ! this.enabled ) { + this.log.warn('Captcha service self-test failed: service is disabled'); + return false; + } + + // Validate configuration + if ( !this.expirationTime || typeof this.expirationTime !== 'number' ) { + this.log.error('Captcha service self-test failed: invalid expiration time configuration'); + return false; + } + + // Basic functionality test - generate a test captcha and verify storage + const testToken = `test-${ this.crypto.randomBytes(8).toString('hex')}`; + const testText = 'testcaptcha'; + + // Store the test captcha + this.captchaTokens.set(testToken, { + text: testText, + expiresAt: Date.now() + this.expirationTime, + }); + + // Verify the test captcha + const correctVerification = this.verifyCaptcha(testToken, testText); + + // Check if verification worked and token was removed + if ( !correctVerification || this.captchaTokens.has(testToken) ) { + this.log.error('Captcha service self-test failed: verification test failed'); + return false; + } + + this.log.debug('Captcha service self-test passed'); + return true; + } catch ( error ) { + this.log.error(`Captcha service self-test failed with error: ${error.message}`); + return false; + } + } + + /** + * Returns the service's diagnostic information + * @returns {Object} Diagnostic information about the service + */ + getDiagnosticInfo () { + return { + serviceId: this.serviceId, + enabled: this.enabled, + tokenCount: this.captchaTokens.size, + requestCounter: this.requestCounter, + config: { + enabled: this.enabled, + difficulty: this.difficulty, + expirationTime: this.expirationTime, + testMode: this.testMode, + }, + processId: process.pid, + testTokenExists: this.captchaTokens.has('test-static-token'), + }; + } +} + +// Export both as a named export and as a default export for compatibility +module.exports = CaptchaService; +module.exports.CaptchaService = CaptchaService; \ No newline at end of file diff --git a/src/backend/src/modules/core/AlarmService.d.ts b/src/backend/src/modules/core/AlarmService.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..007b835c04760f3a1c5d02fa1b302e1744578445 --- /dev/null +++ b/src/backend/src/modules/core/AlarmService.d.ts @@ -0,0 +1,6 @@ +export class AlarmService { + create (id: string, message: string, fields?: object): void; + clear (id: string): void; + get_alarm (id: string): object | undefined; + // Add more methods/properties as needed for MeteringService usage +} \ No newline at end of file diff --git a/src/backend/src/modules/core/AlarmService.js b/src/backend/src/modules/core/AlarmService.js new file mode 100644 index 0000000000000000000000000000000000000000..65ee4ae11a2c8844627723e564d7066e70dd377f --- /dev/null +++ b/src/backend/src/modules/core/AlarmService.js @@ -0,0 +1,479 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const JSON5 = require('json5'); +const seedrandom = require('seedrandom'); + +const util = require('util'); +const _path = require('path'); +const fs = require('fs'); + +const BaseService = require('../../services/BaseService.js'); + +/** + * AlarmService class is responsible for managing alarms. + * It provides methods for creating, clearing, and handling alarms. + */ +class AlarmService extends BaseService { + static USE = { + logutil: 'core.util.logutil', + identutil: 'core.util.identutil', + stdioutil: 'core.util.stdioutil', + Context: 'core.context', + }; + /** + * This method initializes the AlarmService by setting up its internal data structures and initializing any required dependencies. + * + * It reads in the known errors from a JSON5 file and sets them as the known_errors property of the AlarmService instance. + */ + async _construct () { + this.alarms = {}; + this.alarm_aliases = {}; + + this.known_errors = []; + } + /** + * Method to initialize AlarmService. Sets the known errors and registers commands. + * @returns {Promise} + */ + async _init () { + const services = this.services; + this.pager = services.get('pager'); + + // TODO:[self-hosted] fix this properly + this.known_errors = []; + + } + + /** + * AlarmService registers its commands at the consolidation phase because + * the '_init' method of CommandService may not have been called yet. + */ + ['__on_boot.consolidation'] () { + this._register_commands(this.services.get('commands')); + } + + adapt_id_ (id) { + let shorten = true; + + if ( shorten ) { + const rng = seedrandom(id); + id = this.identutil.generate_identifier('-', rng); + } + + return id; + } + + /** + * Method to create an alarm with the given ID, message, and fields. + * If the ID already exists, it will be updated with the new fields + * and the occurrence count will be incremented. + * + * @param {string} id - Unique identifier for the alarm. + * @param {string} message - Message associated with the alarm. + * @param {object} fields - Additional information about the alarm. + */ + create (id, message, fields) { + if ( this.config.log_upcoming_alarms ) { + this.log.error(`upcoming alarm: ${id}: ${message}`); + } + let existing = false; + /** + * Method to create an alarm with the given ID, message, and fields. + * If the ID already exists, it will be updated with the new fields. + * @param {string} id - Unique identifier for the alarm. + * @param {string} message - Message associated with the alarm. + * @param {object} fields - Additional information about the alarm. + * @returns {void} + */ + const alarm = (() => { + const short_id = this.adapt_id_(id); + + if ( this.alarms[id] ) { + existing = true; + return this.alarms[id]; + } + + const alarm = this.alarms[id] = this.alarm_aliases[short_id] = { + id, + short_id, + started: Date.now(), + occurrences: [], + }; + + Object.defineProperty(alarm, 'count', { + /** + * Method to create a new alarm. + * + * This method takes an id, message, and optional fields as parameters. + * It creates a new alarm object with the provided id and message, + * and adds it to the alarms object. It also keeps track of the number of occurrences of the alarm. + * If the alarm already exists, it increments the occurrence count and calls the handle\_alarm\_repeat\_ method. + * If it's a new alarm, it calls the handle\_alarm\_on\_ method. + * + * @param {string} id - The unique identifier for the alarm. + * @param {string} message - The message associated with the alarm. + * @param {object} [fields] - Optional fields associated with the alarm. + * @returns {void} + */ + get () { + return alarm.timestamps?.length ?? 0; + }, + }); + + Object.defineProperty(alarm, 'id_string', { + /** + * Method to handle creating a new alarm with given parameters. + * This method adds the alarm to the `alarms` object, updates the occurrences count, + * and processes any known errors that may apply to the alarm. + * @param {string} id - The unique identifier for the alarm. + * @param {string} message - The message associated with the alarm. + * @param {Object} fields - Additional fields to associate with the alarm. + */ + get () { + if ( alarm.id.length < 20 ) { + return alarm.id; + } + + const truncatedLongId = `${alarm.id.slice(0, 20) }...`; + + return `${alarm.short_id} (${truncatedLongId})`; + }, + }); + + return alarm; + })(); + + const occurance = { + message, + fields, + timestamp: Date.now(), + }; + + // Keep logs from the previous occurrence if: + // - it's one of the first 3 occurrences + // - the 10th, 100th, 1000th...etc occurrence + if ( alarm.count > 3 && Math.log10(alarm.count) % 1 !== 0 ) { + delete alarm.occurrences[alarm.occurrences.length - 1].logs; + } + occurance.logs = this.log.get_log_buffer(); + + alarm.message = message; + alarm.fields = { ...alarm.fields, ...fields }; + alarm.timestamps = (alarm.timestamps ?? []).concat(Date.now()); + alarm.occurrences.push(occurance); + + if ( fields?.error ) { + alarm.error = fields.error; + } + + if ( alarm.source ) { + console.error(alarm.error); + } + + if ( existing ) { + this.handle_alarm_repeat_(alarm); + } else { + this.handle_alarm_on_(alarm); + } + } + + /** + * Method to clear an alarm with the given ID. + * @param {*} id - The ID of the alarm to clear. + * @returns {void} + */ + clear (id) { + const alarm = this.alarms[id]; + if ( ! alarm ) { + return; + } + delete this.alarms[id]; + this.handle_alarm_off_(alarm); + } + + apply_known_errors_ (alarm) { + const rule_matches = rule => { + const match = rule.match; + if ( match.id !== alarm.id ) return false; + if ( match.message && match.message !== alarm.message ) return false; + if ( match.fields ) { + for ( const [key, value] of Object.entries(match.fields) ) { + if ( alarm.fields[key] !== value ) return false; + } + } + return true; + }; + + const rule_actions = { + 'no-alert': () => alarm.no_alert = true, + 'severity': action => alarm.severity = action.value, + }; + + const apply_action = action => { + rule_actions[action.type](action); + }; + + for ( const rule of this.known_errors ) { + if ( rule_matches(rule) ) apply_action(rule.action); + } + } + + handle_alarm_repeat_ (alarm) { + this.log.warn(`REPEAT ${alarm.id_string} :: ${alarm.message} (${alarm.count})`, + alarm.fields); + + this.apply_known_errors_(alarm); + + if ( alarm.no_alert ) return; + + const severity = alarm.severity ?? 'critical'; + + const fields_clean = {}; + for ( const [key, value] of Object.entries(alarm.fields) ) { + fields_clean[key] = util.inspect(value); + } + + this.pager.alert({ + id: `${alarm.id ?? 'something-bad' }-r_\${alarm.count}`, + message: alarm.message ?? alarm.id ?? 'something bad happened', + source: 'alarm-service', + severity, + custom: { + fields: fields_clean, + trace: alarm.error?.stack, + }, + }); + } + + handle_alarm_on_ (alarm) { + this.log.error(`ACTIVE ${alarm.id_string} :: ${alarm.message} (${alarm.count})`, + alarm.fields); + + this.apply_known_errors_(alarm); + + if ( this.global_config.env === 'dev' && !this.attached_dev ) { + this.attached_dev = true; + const realConsole = globalThis.original_console_object ?? console; + realConsole.error('\x1B[33;1m[alarm]\x1B[0m Active alarms detected; see logs for details.'); + } + + const args = this.Context.get('args') ?? {}; + if ( args['quit-on-alarm'] ) { + const svc_shutdown = this.services.get('shutdown'); + svc_shutdown.shutdown({ + reason: '--quit-on-alarm is set', + code: 1, + }); + } + + if ( alarm.no_alert ) return; + + const severity = alarm.severity ?? 'critical'; + + const fields_clean = {}; + for ( const [key, value] of Object.entries(alarm.fields) ) { + fields_clean[key] = util.inspect(value); + } + + this.pager.alert({ + id: alarm.id ?? 'something-bad', + message: alarm.message ?? alarm.id ?? 'something bad happened', + source: 'alarm-service', + severity, + custom: { + fields: fields_clean, + trace: alarm.error?.stack, + }, + }); + + // Write a .log file for the alert that happened + try { + const lines = []; + lines.push(`ALERT ${alarm.id_string} :: ${alarm.message} (${alarm.count})`); + lines.push(`started: ${new Date(alarm.started).toISOString()}`); + lines.push(`short id: ${alarm.short_id}`); + lines.push(`original id: ${alarm.id}`); + lines.push(`severity: ${severity}`); + lines.push(`message: ${alarm.message}`); + lines.push(`fields: ${JSON.stringify(fields_clean)}`); + + const alert_info = lines.join('\n'); + + (async () => { + try { + fs.appendFileSync(`alert_${alarm.id}.log`, `${alert_info }\n`); + } catch (e) { + this.log.error(`failed to write alert log: ${e.message}`); + } + })(); + } catch (e) { + this.log.error(`failed to write alert log: ${e.message}`); + } + } + + handle_alarm_off_ (alarm) { + this.log.info(`CLEAR ${alarm.id} :: ${alarm.message} (${alarm.count})`, + alarm.fields); + } + + /** + * Method to get an alarm by its ID. + * + * @param {*} id - The ID of the alarm to get. + * @returns + */ + get_alarm (id) { + return this.alarms[id] ?? this.alarm_aliases[id]; + } + + _register_commands (commands) { + // Function to handle a specific alarm event. + // This comment can be added above line 320. + // This function is responsible for processing specific events related to alarms. + // It can be used for tasks such as updating alarm status, sending notifications, or triggering actions. + // This function is called internally by the AlarmService class. + + // /* + // * handleAlarmEvent - Handles a specific alarm event. + // * + // * @param {Object} alarm - The alarm object containing relevant information. + // * @param {Function} callback - Optional callback function to be called when the event is handled. + // */ + // function handleAlarmEvent(alarm, callback) { + // // Implementation goes here. + // } + const completeAlarmID = (args) => { + // The alarm ID is the first argument, so return no results if we're on the second or later. + if ( args.length > 1 ) + { + return; + } + const lastArg = args[args.length - 1]; + + const results = []; + for ( const alarm of Object.values(this.alarms) ) { + if ( alarm.id.startsWith(lastArg) ) { + results.push(alarm.id); + } + if ( alarm.short_id?.startsWith(lastArg) ) { + results.push(alarm.short_id); + } + } + return results; + }; + + commands.registerCommands('alarm', [ + { + id: 'list', + description: 'list alarms', + handler: async (args, log) => { + for ( const alarm of Object.values(this.alarms) ) { + log.log(`${alarm.id_string}: ${alarm.message} (${alarm.count})`); + } + }, + }, + { + id: 'info', + description: 'show info about an alarm', + handler: async (args, log) => { + const [id] = args; + const alarm = this.get_alarm(id); + if ( ! alarm ) { + log.log(`no alarm with id ${id}`); + return; + } + log.log(`\x1B[33;1m${alarm.id_string}\x1B[0m :: ${alarm.message} (${alarm.count})`); + log.log(`started: ${new Date(alarm.started).toISOString()}`); + log.log(`short id: ${alarm.short_id}`); + log.log(`original id: ${alarm.id}`); + + // print stack trace of alarm error + if ( alarm.error ) { + log.log(alarm.error.stack); + } + // print other fields + for ( const [key, value] of Object.entries(alarm.fields) ) { + log.log(`- ${key}: ${util.inspect(value)}`); + } + }, + completer: completeAlarmID, + }, + { + id: 'clear', + description: 'clear an alarm', + handler: async (args, log) => { + const [id] = args; + const alarm = this.get_alarm(id); + if ( ! alarm ) { + log.log(`no alarm with id ${id}; ` + + `but calling clear(${JSON.stringify(id)}) anyway.`); + } + this.clear(id); + }, + completer: completeAlarmID, + }, + { + id: 'clear-all', + description: 'clear all alarms', + handler: async (args, log) => { + const alarms = Object.values(this.alarms); + this.alarms = {}; + for ( const alarm of alarms ) { + this.handle_alarm_off_(alarm); + } + }, + }, + { + id: 'sound', + description: 'sound an alarm', + handler: async (args, log) => { + const [id, message] = args; + this.create(id ?? 'test', message, {}); + }, + }, + { + id: 'inspect', + description: 'show logs that happened an alarm', + handler: async (args, log) => { + const [id, occurance_idx] = args; + const alarm = this.get_alarm(id); + if ( ! alarm ) { + log.log(`no alarm with id ${id}`); + return; + } + const occurance = alarm.occurrences[occurance_idx]; + if ( ! occurance ) { + log.log(`no occurance with index ${occurance_idx}`); + return; + } + log.log(`┏━━ Logs before: ${alarm.id_string} ━━━━`); + for ( const lg of occurance.logs ) { + log.log(`┃ ${ this.logutil.stringify_log_entry(lg)}`); + } + log.log(`┗━━ Logs before: ${alarm.id_string} ━━━━`); + }, + completer: completeAlarmID, + }, + ]); + } +} + +module.exports = { + AlarmService, +}; diff --git a/src/backend/src/modules/core/ContextService.js b/src/backend/src/modules/core/ContextService.js new file mode 100644 index 0000000000000000000000000000000000000000..b399d505c5efc2ae3cd3a930453ade2ebd8650ea --- /dev/null +++ b/src/backend/src/modules/core/ContextService.js @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); +const { Context } = require('../../util/context'); + +/** + * ContextService provides a way for other services to register a hook to be + * called when a context/subcontext is created. + * + * Contexts are used to provide contextual information in the execution + * context (dynamic scope). They can also be used to identify a "span"; + * a span is a labelled frame of execution that can be used to track + * performance, errors, and other metrics. + */ +class ContextService extends BaseService { + register_context_hook (event, hook) { + Context.context_hooks_[event].push(hook); + } +} + +module.exports = { + ContextService, +}; diff --git a/src/backend/src/modules/core/Core2Module.js b/src/backend/src/modules/core/Core2Module.js new file mode 100644 index 0000000000000000000000000000000000000000..0283eb2d118f8786efc3fe39ef584f110a994f31 --- /dev/null +++ b/src/backend/src/modules/core/Core2Module.js @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +/** + * A replacement for CoreModule with as few external relative requires as possible. + * This will eventually be the successor to CoreModule, the main module for Puter's backend. + * + * The scope of this module is: + * - logging and error handling + * - alarm handling + * - services that are tightly coupled with alarm handling are allowed + * - any essential information about server stats or health + * - any very generic service which other services can register + * behavior to. + */ +class Core2Module extends AdvancedBase { + async install (context) { + // === LIBS === // + const useapi = context.get('useapi'); + + const lib = require('./lib/__lib__.js'); + for ( const k in lib ) { + useapi.def(`core.${k}`, lib[k], { assign: true }); + } + + useapi.def('core.context', require('../../util/context.js').Context); + + // === SERVICES === // + const services = context.get('services'); + + const { LogService } = require('./LogService.js'); + services.registerService('log-service', LogService); + + const { AlarmService } = require('./AlarmService.js'); + services.registerService('alarm', AlarmService); + + const { ErrorService } = require('./ErrorService.js'); + services.registerService('error-service', ErrorService); + + const { PagerService } = require('./PagerService.js'); + services.registerService('pager', PagerService); + + const { ExpectationService } = require('./ExpectationService.js'); + services.registerService('expectations', ExpectationService); + + const { ProcessEventService } = require('./ProcessEventService.js'); + services.registerService('process-event', ProcessEventService); + + const { ServerHealthService } = require('./ServerHealthService.js'); + services.registerService('server-health', ServerHealthService); + + const { ParameterService } = require('./ParameterService.js'); + services.registerService('params', ParameterService); + + const { ContextService } = require('./ContextService.js'); + services.registerService('context', ContextService); + } +} + +module.exports = { + Core2Module, +}; diff --git a/src/backend/src/modules/core/ErrorService.js b/src/backend/src/modules/core/ErrorService.js new file mode 100644 index 0000000000000000000000000000000000000000..b68ff04eabccdc4441f143e30cce050e6fbf77a3 --- /dev/null +++ b/src/backend/src/modules/core/ErrorService.js @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); + +/** +* **ErrorContext Class** +* +* The `ErrorContext` class is designed to encapsulate error reporting functionality within a specific logging context. +* It facilitates the reporting of errors by providing a method to log error details along with additional contextual information. +* +* @class +* @classdesc Provides a context for error reporting with specific logging details. +* @param {ErrorService} error_service - The error service instance to use for reporting errors. +* @param {object} log_context - The logging context to associate with the error reports. +*/ +class ErrorContext { + constructor (error_service, log_context) { + this.error_service = error_service; + this.log_context = log_context; + } + report (location, fields) { + fields = { + ...fields, + logger: this.log_context, + }; + this.error_service.report(location, fields); + } +} + +/** +* The ErrorService class is responsible for handling and reporting errors within the system. +* It provides methods to initialize the service, create error contexts, and report errors with detailed logging and alarm mechanisms. + +* @class ErrorService +* @extends BaseService +*/ +class ErrorService extends BaseService { + /** + * Initializes the ErrorService, setting up the alarm and backup logger services. + * + * @async + * @function init + * @memberof ErrorService + * @returns {Promise} A promise that resolves when the initialization is complete. + */ + async init () { + const services = this.services; + this.alarm = services.get('alarm'); + this.backupLogger = services.get('log-service').create('error-service'); + } + + /** + * Creates an ErrorContext instance with the provided logging context. + * + * @param {*} log_context The logging context to associate with the error reports. + * @returns {ErrorContext} An ErrorContext instance. + */ + create (log_context) { + return new ErrorContext(this, log_context); + } + + /** + * Reports an error with the specified location and details. + * The "location" is a string up to the callers discretion to identify + * the source of the error. + * + * @param {*} location The location where the error occurred. + * @param {*} fields The error details to report. + * @param {boolean} [alarm=true] Whether to raise an alarm for the error. + * @returns {void} + */ + report (location, { source, logger, trace, extra, message }, alarm = true) { + message = message ?? source?.message; + logger = logger ?? this.backupLogger; + logger.error(`Error @ ${location}: ${message}; ${ source?.stack}`); + + if ( alarm ) { + const alarm_id = `${location}:${message}`; + this.alarm.create(alarm_id, message, { + error: source, + ...extra, + }); + } + } +} + +module.exports = { ErrorService }; diff --git a/src/backend/src/modules/core/ExpectationService.js b/src/backend/src/modules/core/ExpectationService.js new file mode 100644 index 0000000000000000000000000000000000000000..6766e9d34097cad380ae150dda8b3fbb23209cf3 --- /dev/null +++ b/src/backend/src/modules/core/ExpectationService.js @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { v4: uuidv4 } = require('uuid'); +const BaseService = require('../../services/BaseService'); + +/** +* @class ExpectationService +* @extends BaseService +* +* The `ExpectationService` is a specialized service designed to assist in the diagnosis and +* management of errors related to the intricate interactions among asynchronous operations. +* It facilitates tracking and reporting on expectations, enabling better fault isolation +* and resolution in systems where synchronization and timing of operations are crucial. +* +* This service inherits from the `BaseService` and provides methods for registering, +* purging, and handling expectations, making it a valuable tool for diagnosing complex +* runtime behaviors in a system. +*/ +class ExpectationService extends BaseService { + static USE = { + expect: 'core.expect', + }; + + /** + * Constructs the ExpectationService and initializes its internal state. + * This method is intended to be called asynchronously. + * It sets up the `expectations_` array which will be used to track expectations. + * + * @async + */ + async _construct () { + this.expectations_ = []; + } + + /** + * ExpectationService registers its commands at the consolidation phase because + * the '_init' method of CommandService may not have been called yet. + */ + ['__on_boot.consolidation'] () { + const commands = this.services.get('commands'); + commands.registerCommands('expectations', [ + { + id: 'pending', + description: 'lists pending expectations', + handler: async (args, log) => { + this.purgeExpectations_(); + if ( this.expectations_.length < 1 ) { + log.log('there are none'); + return; + } + for ( const expectation of this.expectations_ ) { + expectation.report(log); + } + }, + }, + ]); + } + + /** + * Initializes the ExpectationService, setting up interval functions and registering commands. + * + * This method sets up a periodic interval to purge expectations and registers a command + * to list pending expectations. The interval invokes `purgeExpectations_` every second. + * The command 'pending' allows users to list and log all pending expectations. + * + * @returns {Promise} A promise that resolves when initialization is complete. + */ + async _init () { + // TODO: service to track all interval functions? + /** + * Initializes the service by setting up interval functions and registering commands. + * This method sets up a periodic interval function to purge expectations and registers + * a command to list pending expectations. + * + * @returns {void} + */ + + // The comment should be placed above the method at line 68 + setInterval(() => { + this.purgeExpectations_(); + }, 1000); + } + + /** + * Purges expectations that have been met. + * + * This method iterates through the list of expectations and removes + * those that have been satisfied. Currently, this functionality is + * disabled and needs to be re-enabled. + * + * @returns {void} This method does not return anything. + */ + purgeExpectations_ () { + return; + // TODO: Re-enable this + // for ( let i=0 ; i < this.expectations_.length ; i++ ) { + // if ( this.expectations_[i].check() ) { + // this.expectations_[i] = null; + // } + // } + // this.expectations_ = this.expectations_.filter(v => v !== null); + } + + /** + * Registers an expectation to be tracked by the service. + * + * @param {Object} workUnit - The work unit to track + * @param {string} checkpoint - The checkpoint to expect + * @returns {void} + */ + expect_eventually ({ workUnit, checkpoint }) { + this.expectations_.push(new this.expect.CheckpointExpectation(workUnit, checkpoint)); + } +} + +module.exports = { + ExpectationService, +}; \ No newline at end of file diff --git a/src/backend/src/modules/core/LogService.js b/src/backend/src/modules/core/LogService.js new file mode 100644 index 0000000000000000000000000000000000000000..9889ba53a0899c5d634783cd501fa8d598b8d4d9 --- /dev/null +++ b/src/backend/src/modules/core/LogService.js @@ -0,0 +1,702 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const logSeverity = (ordinal, label, esc, winst) => ({ ordinal, label, esc, winst }); +const LOG_LEVEL_ERRO = logSeverity(0, 'ERRO', '31;1', 'error'); +const LOG_LEVEL_WARN = logSeverity(1, 'WARN', '33;1', 'warn'); +const LOG_LEVEL_INFO = logSeverity(2, 'INFO', '36;1', 'info'); +const LOG_LEVEL_NOTICEME = logSeverity(3, 'NOTICE_ME', '33;1', 'error'); +const LOG_LEVEL_SYSTEM = logSeverity(3, 'SYSTEM', '36;1', 'system'); +const LOG_LEVEL_DEBU = logSeverity(4, 'DEBU', '37', 'debug'); +const LOG_LEVEL_TICK = logSeverity(10, 'TICK', '34;1', 'info'); + +const winston = require('winston'); +const { Context } = require('../../util/context'); +const BaseService = require('../../services/BaseService'); +const { stringify_log_entry } = require('./lib/log'); +require('winston-daily-rotate-file'); + +const WINSTON_LEVELS = { + system: 0, + error: 1, + warn: 10, + info: 20, + http: 30, + verbose: 40, + debug: 50, + silly: 60, +}; + +let display_log_level = process.env.DEBUG ? 100 : 3; +const display_log_level_label = { + 0: 'ERRO', + 1: 'WARN', + 2: 'INFO', + 3: 'SYSTEM', + 4: 'DEBUG', + 100: 'ALL', +}; + +/** +* Represents a logging context within the LogService. +* This class is used to manage logging operations with specific context information, +* allowing for hierarchical logging structures and dynamic field additions. +* @class LogContext +*/ +class LogContext { + constructor (logService, { crumbs, fields }) { + this.logService = logService; + this.crumbs = crumbs; + this.fields = fields; + } + + sub (name, fields = {}) { + return new LogContext(this.logService, + { + crumbs: name ? [...this.crumbs, name] : [...this.crumbs], + fields: { ...this.fields, ...fields }, + }); + } + + info (message, fields, objects) { + this.log(LOG_LEVEL_INFO, message, fields, objects); + } + warn (message, fields, objects) { + this.log(LOG_LEVEL_WARN, message, fields, objects); + } + debug (message, fields, objects) { + this.log(LOG_LEVEL_DEBU, message, fields, objects); + } + error (message, fields, objects) { + this.log(LOG_LEVEL_ERRO, message, fields, objects); + } + tick (message, fields, objects) { + this.log(LOG_LEVEL_TICK, message, fields, objects); + } + called (fields = {}) { + this.log(LOG_LEVEL_DEBU, 'called', fields); + } + noticeme (message, fields, objects) { + this.log(LOG_LEVEL_NOTICEME, message, fields, objects); + } + system (message, fields, objects) { + this.log(LOG_LEVEL_SYSTEM, message, fields, objects); + } + + cache (isCacheHit, identifier, fields = {}) { + this.log(LOG_LEVEL_DEBU, + isCacheHit ? 'cache_hit' : 'cache_miss', + { identifier, ...fields }); + } + + log (log_level, message, fields = {}, objects = {}) { + fields = { ...this.fields, ...fields }; + { + const x = Context.get(undefined, { allow_fallback: true }); + if ( x && x.get('trace_request') ) { + fields.trace_request = x.get('trace_request'); + } + if ( !fields.actor && x && x.get('actor') ) { + try { + fields.actor = x.get('actor'); + } catch (e) { + console.log('error logging actor (this is probably fine):', e); + } + } + } + for ( const k in fields ) { + if ( + fields[k] && + typeof fields[k].toLogFields === 'function' + ) fields[k] = fields[k].toLogFields(); + } + if ( Context.get('injected_logger', { allow_fallback: true }) ) { + Context.get('injected_logger').log( + message + (fields ? (`; fields: ${ JSON.stringify(fields)}`) : '')); + } + this.logService.log_(log_level, + this.crumbs, + message, + fields, + objects); + } + + /** + * Generates a human-readable trace ID for logging purposes. + * + * @returns {string} A trace ID in the format 'xxxxxx-xxxxxx' where each segment is a + * random string of six lowercase letters and digits. + */ + mkid () { + // generate trace id + const trace_id = []; + for ( let i = 0; i < 2; i++ ) { + trace_id.push(Math.random().toString(36).slice(2, 8)); + } + return trace_id.join('-'); + } + + /** + * Adds a trace id to this logging context for tracking purposes. + * @returns {LogContext} The current logging context with the trace id added. + */ + traceOn () { + this.fields.trace_id = this.mkid(); + return this; + } + + /** + * Gets the log buffer maintained by the LogService. This shows the most + * recent log entries. + * @returns {Array} An array of log entries stored in the buffer. + */ + get_log_buffer () { + return this.logService.get_log_buffer(); + } +} + +/** +* Timestamp in milliseconds since the epoch, used for calculating log entry duration. +*/ + +/** +* @class DevLogger +* @classdesc +* A development logger class designed for logging messages during development. +* This logger can either log directly to console or delegate logging to another logger. +* It provides functionality to turn logging on/off, and can optionally write logs to a file. +* +* @param {function} log - The logging function, typically `console.log` or similar. +* @param {object} [opt_delegate] - An optional logger to which log messages can be delegated. +*/ +class DevLogger { + // TODO: this should eventually delegate to winston logger + constructor (log, opt_delegate) { + this.log = log; + this.off = false; + this.recto = null; + + if ( opt_delegate ) { + this.delegate = opt_delegate; + } + } + onLogMessage (log_lvl, crumbs, message, fields, objects) { + if ( this.delegate ) { + this.delegate.onLogMessage(log_lvl, crumbs, message, fields, objects); + } + + if ( this.off ) return; + + if ( !process.env.DEBUG && log_lvl.ordinal > display_log_level ) return; + + const ld = Context.get('logdent', { allow_fallback: true }); + const prefix = globalThis.dev_console_indent_on + ? Array(ld ?? 0).fill(' ').join('') + : ''; + this.log_(stringify_log_entry({ + prefix, + log_lvl, + crumbs, + message, + fields, + objects, + })); + } + + log_ (text) { + if ( this.recto ) { + const fs = require('node:fs'); + fs.appendFileSync(this.recto, `${text }\n`); + } + this.log(text); + } +} + +/** +* @class NullLogger +* @description A logger that does nothing, effectively disabling logging. +* This class is used when logging is not desired or during development +* to avoid performance overhead or for testing purposes. +*/ +class NullLogger { + // TODO: this should eventually delegate to winston logger + constructor (log, opt_delegate) { + this.log = log; + + if ( opt_delegate ) { + this.delegate = opt_delegate; + } + } + onLogMessage () { + } +} + +/** +* WinstonLogger Class +* +* A logger that delegates log messages to a Winston logger instance. +*/ +class WinstonLogger { + constructor (winst) { + this.winst = winst; + } + onLogMessage (log_lvl, crumbs, message, fields, objects) { + this.winst.log({ + ...fields, + label: crumbs.join('.'), + level: log_lvl.winst, + message, + }); + } +} + +/** +* @class TimestampLogger +* @classdesc A logger that adds timestamps to log messages before delegating them to another logger. +* This class wraps another logger instance to ensure that all log messages include a timestamp, +* which can be useful for tracking the sequence of events in a system. +* +* @param {Object} delegate - The logger instance to which the timestamped log messages are forwarded. +*/ +class TimestampLogger { + constructor (delegate) { + this.delegate = delegate; + } + onLogMessage (log_lvl, crumbs, message, fields, ...a) { + fields = { ...fields, timestamp: new Date() }; + this.delegate.onLogMessage(log_lvl, crumbs, message, fields, ...a); + } +} + +/** +* The `BufferLogger` class extends the logging functionality by maintaining a buffer of log entries. +* This class is designed to: +* - Store a specified number of recent log messages. +* - Allow for retrieval of these logs for debugging or monitoring purposes. +* - Ensure that the log buffer does not exceed the defined size by removing older entries when necessary. +* - Delegate logging messages to another logger while managing its own buffer. +*/ +class BufferLogger { + constructor (size, delegate) { + this.size = size; + this.delegate = delegate; + this.buffer = []; + } + onLogMessage (log_lvl, crumbs, message, fields, ...a) { + this.buffer.push({ log_lvl, crumbs, message, fields, ...a }); + if ( this.buffer.length > this.size ) { + this.buffer.shift(); + } + this.delegate.onLogMessage(log_lvl, crumbs, message, fields, ...a); + } +} + +/** +* Represents a custom logger that can modify log messages before they are passed to another logger. +* @class CustomLogger +* @extends {Object} +* @param {Object} delegate - The delegate logger to which modified log messages will be passed. +* @param {Function} callback - A callback function that modifies log parameters before delegation. +*/ +class CustomLogger { + constructor (delegate, callback) { + this.delegate = delegate; + this.callback = callback; + } + async onLogMessage (log_lvl, crumbs, message, fields, ...a) { + // Logging is allowed to be performed without a context, but we + // don't want log functions to be asynchronous which rules out + // wrapping with Context.allow_fallback. Instead we provide a + // context as a parameter. + const context = Context.get(undefined, { allow_fallback: true }); + + let ret; + try { + ret = await this.callback({ + context, + log_lvl, + crumbs, + message, + fields, + args: a, + }); + } catch (e) { + console.error('error?', e); + } + + if ( ret && ret.skip ) return; + + if ( ! ret ) { + this.delegate.onLogMessage(log_lvl, + crumbs, + message, + fields, + ...a); + return; + } + + const { + log_lvl: _log_lvl, + crumbs: _crumbs, + message: _message, + fields: _fields, + args, + } = ret; + + this.delegate.onLogMessage(_log_lvl ?? log_lvl, + _crumbs ?? crumbs, + _message ?? message, + _fields ?? fields, + ...(args ?? a ?? [])); + } +} + +/** +* The `LogService` class extends `BaseService` and is responsible for managing and +* orchestrating various logging functionalities within the application. It handles +* log initialization, middleware registration, log directory management, and +* provides methods for creating log contexts and managing log output levels. +*/ +class LogService extends BaseService { + static MODULES = { + path: require('path'), + }; + /** + * Defines the modules required by the LogService class. + * This static property contains modules that are used for file path operations. + * @property {Object} MODULES - An object containing required modules. + * @property {Object} MODULES.path - The Node.js path module for handling and resolving file paths. + */ + async _construct () { + this.loggers = []; + this.bufferLogger = null; + } + + /** + * Registers a custom logging middleware with the LogService. + * @param {*} callback - The callback function that modifies log parameters before delegation. + */ + register_log_middleware (callback) { + this.loggers[0] = new CustomLogger(this.loggers[0], callback); + } + + /** + * Registers logging commands with the command service. + */ + ['__on_boot.consolidation'] () { + const commands = this.services.get('commands'); + commands.registerCommands('logs', [ + { + id: 'show', + description: 'toggle log output', + handler: async (args, log) => { + this.devlogger && (this.devlogger.off = !this.devlogger.off); + }, + }, + { + id: 'rec', + description: 'start recording to a file via dev logger', + handler: async (args, ctx) => { + const [name] = args; + const { log } = ctx; + if ( ! this.devlogger ) { + log('no dev logger; what are you doing?'); + } + this.devlogger.recto = name; + }, + }, + { + id: 'stop', + description: 'stop recording to a file via dev logger', + handler: async ([name], log) => { + if ( ! this.devlogger ) { + log('no dev logger; what are you doing?'); + } + this.devlogger.recto = null; + }, + }, + { + id: 'indent', + description: 'toggle log indentation', + handler: async (args, log) => { + globalThis.dev_console_indent_on = + !globalThis.dev_console_indent_on; + }, + }, + { + id: 'get-level', + description: 'get the current log level for displayed logs', + handler: async (args, log) => { + log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`); + }, + }, + { + id: 'set-level', + description: 'set the new log level for displayed logs', + handler: async (args, log) => { + display_log_level = Number(args[0]); + log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`); + }, + }, + ]); + } + /** + * Registers logging commands with the command service. + * + * This method sets up various logging commands that can be used to + * interact with the log output, such as toggling log display, + * starting/stopping log recording, and toggling log indentation. + * + * @memberof LogService + */ + async _init () { + const config = this.global_config; + + this.ensure_log_directory_(); + + let logger; + + if ( ! config.no_winston ) + { + logger = new WinstonLogger(winston.createLogger({ + levels: WINSTON_LEVELS, + transports: [ + new winston.transports.DailyRotateFile({ + level: 'http', + filename: `${this.log_directory}/%DATE%.log`, + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + }), + new winston.transports.DailyRotateFile({ + level: 'error', + filename: `${this.log_directory}/error-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + }), + new winston.transports.DailyRotateFile({ + level: 'system', + filename: `${this.log_directory}/system-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + }), + ], + })); + } + + if ( config.env === 'dev' ) { + logger = config.flag_no_logs // useful for profiling + ? new NullLogger() + : new DevLogger(console.log.bind(console), logger); + + this.devlogger = logger; + } + + logger = new TimestampLogger(logger); + + logger = new BufferLogger(config.log_buffer_size ?? 20, logger); + this.bufferLogger = logger; + + this.loggers.push(logger); + + this.output_lvl = LOG_LEVEL_INFO; + if ( config.logger ) { + // config.logger.level is a string, e.g. 'debug' + + // first we find the appropriate log level + const output_lvl = Object.values({ + LOG_LEVEL_ERRO, + LOG_LEVEL_WARN, + LOG_LEVEL_INFO, + LOG_LEVEL_DEBU, + LOG_LEVEL_TICK, + }).find(lvl => { + return lvl.label === config.logger.level.toUpperCase() || + lvl.winst === config.logger.level.toLowerCase() || + lvl.ordinal === config.logger.level; + }); + + // then we set the output level to the ordinal of that level + this.output_lvl = output_lvl.ordinal; + } + + this.log = this.create('log-service'); + this.log.system('log service started'); + this.log.debug('log service configuration', { + output_lvl: this.output_lvl, + log_directory: this.log_directory, + }); + + this.services.logger = this.create('services-container'); + globalThis.root_context.set('logger', this.create('root-context')); + + { + const util = require('util'); + const logger = this.create('console'); + + if ( ! globalThis.original_console_object ) { + globalThis.original_console_object = console; + } + + // Keep console prototype + const logconsole = Object.create(console); + + // Override simple log functions + const logfn = level => (...a) => { + logger[level](a.map(arg => { + if ( typeof arg === 'string' ) return arg; + return util.inspect(arg, undefined, undefined, true); + }).join(' ')); + }; + + logconsole.log = logfn('info'); + logconsole.info = logfn('info'); + logconsole.warn = logfn('warn'); + logconsole.error = logfn('error'); + logconsole.debug = logfn('debug'); + + globalThis.console = logconsole; + } + } + + /** + * Create a new log context with the specified prefix + * + * @param {1} prefix - The prefix for the log context + * @param {*} fields - Optional fields to include in the log context + * @returns {LogContext} A new log context with the specified prefix and fields + */ + create (prefix, fields = {}) { + const logContext = new LogContext(this, + { + crumbs: [prefix], + fields, + }); + + return logContext; + } + + log_ (log_lvl, crumbs, message, fields, objects) { + try { + // skip messages that are above the output level + if ( log_lvl.ordinal > this.output_lvl ) return; + + if ( this.config.trace_logs ) { + fields.stack = (new Error('logstack')).stack; + } + + for ( const logger of this.loggers ) { + logger.onLogMessage(log_lvl, crumbs, message, fields, objects); + } + } catch (e) { + // If logging fails, we don't want anything to happen + // that might trigger a log message. This causes an + // infinite loop and I learned that the hard way. + console.error('Logging failed', e); + + // TODO: trigger an alarm either in a non-logging + // context (prereq: per-context service overrides) + // or with a cooldown window (prereq: cooldowns in AlarmService) + } + } + + /** + * Ensures that a log directory exists for logging purposes. + * This method attempts to create or locate a directory for log files, + * falling back through several predefined paths if the preferred + * directory does not exist or cannot be created. + * + * @throws {Error} If no suitable log directory can be found or created. + */ + ensure_log_directory_ () { + // STEP 1: Try /var/puter/logs/heyputer + { + const fs = require('fs'); + const path = '/var/puter/logs/heyputer'; + // Making this directory if it doesn't exist causes issues + // for users running with development instructions + if ( ! fs.existsSync('/var/puter') ) { + return; + } + try { + fs.mkdirSync(path, { recursive: true }); + this.log_directory = path; + return; + } catch (e) { + // ignore + } + } + + // STEP 2: Try /tmp/heyputer + { + const fs = require('fs'); + const path = '/tmp/heyputer'; + try { + fs.mkdirSync(path, { recursive: true }); + this.log_directory = path; + return; + } catch (e) { + // ignore + } + } + + // STEP 3: Try working directory + { + const fs = require('fs'); + const path = './heyputer'; + try { + fs.mkdirSync(path, { recursive: true }); + this.log_directory = path; + return; + } catch (e) { + // ignore + } + } + + // STEP 4: Give up + throw new Error('Unable to create or find log directory'); + } + + /** + * Generates a sanitized file path for log files. + * + * @param {string} name - The name of the log file, which will be sanitized to remove any path characters. + * @returns {string} A sanitized file path within the log directory. + */ + get_log_file (name) { + // sanitize name: cannot contain path characters + name = name.replace(/[^a-zA-Z0-9-_]/g, '_'); + return this.modules.path.join(this.log_directory, name); + } + + /** + * Get the most recent log entries from the buffer maintained by the LogService. + * By default, the buffer contains the last 20 log entries. + * @returns + */ + get_log_buffer () { + return this.bufferLogger.buffer; + } +} + +module.exports = { + LogService, + stringify_log_entry, +}; \ No newline at end of file diff --git a/src/backend/src/modules/core/PagerService.js b/src/backend/src/modules/core/PagerService.js new file mode 100644 index 0000000000000000000000000000000000000000..ca4a9c3a1a5dc9aec191dd72baed4895592ecd49 --- /dev/null +++ b/src/backend/src/modules/core/PagerService.js @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const pdjs = require('@pagerduty/pdjs'); +const BaseService = require('../../services/BaseService'); +const util = require('util'); + +/** +* @class PagerService +* @extends BaseService +* @description The PagerService class is responsible for handling pager alerts. +* It extends the BaseService class and provides methods for constructing, +* initializing, and managing alert handlers. The class interacts with PagerDuty +* through the pdjs library to send alerts and integrates with other services via +* command registration. +*/ +class PagerService extends BaseService { + static USE = { + Context: 'core.context', + }; + + async _construct () { + this.config = this.global_config.pager; + this.alertHandlers_ = []; + + } + + /** + * PagerService registers its commands at the consolidation phase because + * the '_init' method of CommandService may not have been called yet. + */ + ['__on_boot.consolidation'] () { + this._register_commands(this.services.get('commands')); + } + + /** + * Initializes the PagerService instance by setting the configuration and + * initializing an empty alert handler array. + * + * @async + * @memberOf PagerService + * @returns {Promise} + */ + async _init () { + this.alertHandlers_ = []; + + if ( ! this.config ) { + return; + } + + this.onInit(); + } + + /** + * Initializes PagerDuty configuration and registers alert handlers. + * If PagerDuty is enabled in the configuration, it sets up an alert handler + * to send alerts to PagerDuty. + * + * @method onInit + */ + onInit () { + if ( this.config.pagerduty && this.config.pagerduty.enabled ) { + this.alertHandlers_.push(async alert => { + const event = pdjs.event; + + const fields_clean = {}; + for ( const [key, value] of Object.entries(alert?.fields ?? {}) ) { + fields_clean[key] = util.inspect(value); + } + + const custom_details = { + ...(alert.custom || {}), + server_id: this.global_config.server_id, + }; + + const ctx = this.Context.get(undefined, { allow_fallback: true }); + + // Add request payload if any exists + const req = ctx.get('req'); + if ( req ) { + if ( req.body ) { + // Remove fields which may contain sensitive information + delete req.body.password; + delete req.body.email; + + // Add the request body to the custom details + custom_details.request_body = req.body; + } + } + + this.log.info('it is sending to PD'); + await event({ + data: { + routing_key: this.config.pagerduty.routing_key, + event_action: 'trigger', + dedup_key: alert.id, + payload: { + summary: alert.message, + source: alert.source, + severity: alert.severity, + custom_details, + }, + }, + }); + }); + } + } + + /** + * Sends an alert to all registered alert handlers. + * + * This method iterates through all alert handlers and attempts to send the alert. + * If any handler fails to send the alert, an error message is logged. + * + * @param {Object} alert - The alert object containing details about the alert. + */ + async alert (alert) { + for ( const handler of this.alertHandlers_ ) { + try { + await handler(alert); + } catch (e) { + this.log.error(`failed to send pager alert: ${e?.message}`); + } + } + } + + _register_commands (commands) { + commands.registerCommands('pager', [ + { + id: 'test-alert', + description: 'create a test alert', + handler: async (args, log) => { + const [severity] = args; + await this.alert({ + id: 'test-alert', + message: 'test alert', + source: 'test', + severity, + }); + }, + }, + ]); + } + +} + +module.exports = { + PagerService, +}; diff --git a/src/backend/src/modules/core/ParameterService.js b/src/backend/src/modules/core/ParameterService.js new file mode 100644 index 0000000000000000000000000000000000000000..05d0661eec9f1c6626905588aa3fbec427699cbd --- /dev/null +++ b/src/backend/src/modules/core/ParameterService.js @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); + +/** +* @class Parameter +* @description Represents a configurable parameter with value management, constraints, and change notification capabilities. +* Provides functionality for setting/getting values, binding to object instances, and subscribing to value changes. +* Supports validation through configurable constraints and maintains a list of value change listeners. +*/ +class Parameter { + constructor (spec) { + this.spec_ = spec; + this.valueListeners_ = []; + + if ( spec.default ) { + this.value_ = spec.default; + } + } + + /** + * Sets a new value for the parameter after validating against constraints + * @param {*} value - The new value to set for the parameter + * @throws {Error} If the value fails any constraint checks + * @fires valueListeners with new value and old value + * @async + */ + async set (value) { + for ( const constraint of (this.spec_.constraints ?? []) ) { + if ( ! await constraint.check(value) ) { + throw new Error(`value ${value} does not satisfy constraint ${constraint.id}`); + } + } + + const old = this.value_; + this.value_ = value; + for ( const listener of this.valueListeners_ ) { + listener(value, { old }); + } + } + + /** + * Gets the current value of this parameter + * @returns {Promise<*>} The parameter's current value + */ + async get () { + return this.value_; + } + + bindToInstance (instance, name) { + const value = this.value_; + instance[name] = value; + this.valueListeners_.push((value) => { + instance[name] = value; + }); + } + + subscribe (listener) { + this.valueListeners_.push(listener); + } +} + +/** +* @class ParameterService +* @extends BaseService +* @description Service class for managing system parameters and their values. +* Provides functionality for creating, getting, setting, and subscribing to parameters. +* Supports parameter binding to instances and includes command registration for parameter management. +* Parameters can have constraints, default values, and change listeners. +*/ +class ParameterService extends BaseService { + _construct () { + /** @type {Array} */ + this.parameters_ = []; + } + + /** + * Initializes the service by registering commands with the command service. + * This method is called during service startup to set up command handlers + * for parameter management. + * @private + */ + ['__on_boot.consolidation'] () { + this._registerCommands(this.services.get('commands')); + } + + createParameters (serviceName, parameters, opt_instance) { + for ( const parameter of parameters ) { + this.log.debug(`registering parameter ${serviceName}:${parameter.id}`); + this.parameters_.push(new Parameter({ + ...parameter, + id: `${serviceName}:${parameter.id}`, + })); + if ( opt_instance ) { + this.bindToInstance(`${serviceName}:${parameter.id}`, + opt_instance, + parameter.id); + } + } + } + + /** + * Gets the value of a parameter by its ID + * @param {string} id - The unique identifier of the parameter to retrieve + * @returns {Promise<*>} The current value of the parameter + * @throws {Error} If parameter with given ID is not found + */ + async get (id) { + const parameter = this._get_param(id); + return await parameter.get(); + } + + bindToInstance (id, instance, name) { + const parameter = this._get_param(id); + return parameter.bindToInstance(instance, name); + } + + subscribe (id, listener) { + const parameter = this._get_param(id); + return parameter.subscribe(listener); + } + + _get_param (id) { + const parameter = this.parameters_.find(p => p.spec_.id === id); + if ( ! parameter ) { + throw new Error(`unknown parameter: ${id}`); + } + return parameter; + } + + /** + * Registers parameter-related commands with the command service + * @param {Object} commands - The command service instance to register with + */ + _registerCommands (commands) { + const completeParameterName = (args) => { + // The parameter name is the first argument, so return no results if we're on the second or later. + if ( args.length > 1 ) + { + return; + } + const lastArg = args[args.length - 1]; + + return this.parameters_ + .map(parameter => parameter.spec_.id) + .filter(parameterName => parameterName.startsWith(lastArg)); + }; + + commands.registerCommands('params', [ + { + id: 'get', + description: 'get a parameter', + handler: async (args, log) => { + const [name] = args; + const value = await this.get(name); + log.log(value); + }, + completer: completeParameterName, + }, + { + id: 'set', + description: 'set a parameter', + handler: async (args, log) => { + const [name, value] = args; + const parameter = this._get_param(name); + parameter.set(value); + log.log(value); + }, + completer: completeParameterName, + }, + { + id: 'list', + description: 'list parameters', + handler: async (args, log) => { + const [prefix] = args; + let parameters = this.parameters_; + if ( prefix ) { + parameters = parameters + .filter(p => p.spec_.id.startsWith(prefix)); + } + log.log(`available parameters${ + prefix ? ` (starting with: ${prefix})` : '' + }:`); + for ( const parameter of parameters ) { + // log.log(`- ${parameter.spec_.id}: ${parameter.spec_.description}`); + // Log parameter description and value + const value = await parameter.get(); + log.log(`- ${parameter.spec_.id} = ${value}`); + log.log(` ${parameter.spec_.description}`); + } + }, + }, + ]); + } +} + +module.exports = { + ParameterService, +}; diff --git a/src/backend/src/modules/core/ProcessEventService.js b/src/backend/src/modules/core/ProcessEventService.js new file mode 100644 index 0000000000000000000000000000000000000000..e8c993a08362039b3a7b21b738b72d3dc8b1cd65 --- /dev/null +++ b/src/backend/src/modules/core/ProcessEventService.js @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); + +/** +* Service class that handles process-wide events and errors. +* Provides centralized error handling for uncaught exceptions and unhandled promise rejections. +* Sets up event listeners on the process object to capture and report critical errors +* through the logging and error reporting services. +* +* @class ProcessEventService +*/ +class ProcessEventService extends BaseService { + static USE = { + Context: 'core.context', + }; + + _init () { + const services = this.services; + const log = services.get('log-service').create('process-event-service'); + const errors = services.get('error-service').create(log); + + process.on('uncaughtException', async (err, origin) => { + /** + * Handles uncaught exceptions in the process + * Sets up an event listener that reports errors when uncaught exceptions occur + * @param {Error} err - The uncaught exception error object + * @param {string} origin - The origin of the uncaught exception + * @returns {Promise} + */ + await this.Context.allow_fallback(async () => { + errors.report('process:uncaughtException', { + source: err, + origin, + trace: true, + alarm: true, + }); + }); + + }); + + process.on('unhandledRejection', async (reason, promise) => { + /** + * Handles unhandled promise rejections by reporting them to the error service + * @param {*} reason - The rejection reason/error + * @param {Promise} promise - The rejected promise + * @returns {Promise} Resolves when error is reported + */ + await this.Context.allow_fallback(async () => { + errors.report('process:unhandledRejection', { + source: reason, + promise, + trace: true, + alarm: true, + }); + }); + }); + } +} + +module.exports = { + ProcessEventService, +}; diff --git a/src/backend/src/modules/core/README.md b/src/backend/src/modules/core/README.md new file mode 100644 index 0000000000000000000000000000000000000000..482bfc772915feed056d6494112fb7c1342eadf8 --- /dev/null +++ b/src/backend/src/modules/core/README.md @@ -0,0 +1,253 @@ +# Core2Module + +A replacement for CoreModule with as few external relative requires as possible. +This will eventually be the successor to CoreModule, the main module for Puter's backend. + +## Services + +### AlarmService + +AlarmService class is responsible for managing alarms. +It provides methods for creating, clearing, and handling alarms. + +#### Listeners + +##### `boot.consolidation` + +AlarmService registers its commands at the consolidation phase because +the '_init' method of CommandService may not have been called yet. + +#### Methods + +##### `create` + +Method to create an alarm with the given ID, message, and fields. +If the ID already exists, it will be updated with the new fields +and the occurrence count will be incremented. + +###### Parameters + +- **id:** Unique identifier for the alarm. +- **message:** Message associated with the alarm. +- **fields:** Additional information about the alarm. + +##### `clear` + +Method to clear an alarm with the given ID. + +###### Parameters + +- **id:** The ID of the alarm to clear. + +##### `get_alarm` + +Method to get an alarm by its ID. + +###### Parameters + +- **id:** The ID of the alarm to get. + +### ErrorService + +The ErrorService class is responsible for handling and reporting errors within the system. +It provides methods to initialize the service, create error contexts, and report errors with detailed logging and alarm mechanisms. + +#### Methods + +##### `init` + +Initializes the ErrorService, setting up the alarm and backup logger services. + +##### `create` + +Creates an ErrorContext instance with the provided logging context. + +###### Parameters + +- **log_context:** The logging context to associate with the error reports. + +##### `report` + +Reports an error with the specified location and details. +The "location" is a string up to the callers discretion to identify +the source of the error. + +###### Parameters + +- **location:** The location where the error occurred. +- **fields:** The error details to report. + +### ExpectationService + + + +#### Listeners + +##### `boot.consolidation` + +ExpectationService registers its commands at the consolidation phase because +the '_init' method of CommandService may not have been called yet. + +#### Methods + +##### `expect_eventually` + +Registers an expectation to be tracked by the service. + +###### Parameters + +- **workUnit:** The work unit to track +- **checkpoint:** The checkpoint to expect + +### LogService + +The `LogService` class extends `BaseService` and is responsible for managing and +orchestrating various logging functionalities within the application. It handles +log initialization, middleware registration, log directory management, and +provides methods for creating log contexts and managing log output levels. + +#### Listeners + +##### `boot.consolidation` + +Registers logging commands with the command service. + +#### Methods + +##### `register_log_middleware` + +Registers a custom logging middleware with the LogService. + +###### Parameters + +- **callback:** The callback function that modifies log parameters before delegation. + +##### `create` + +Create a new log context with the specified prefix + +###### Parameters + +- **prefix:** The prefix for the log context +- **fields:** Optional fields to include in the log context + +##### `get_log_file` + +Generates a sanitized file path for log files. + +###### Parameters + +- **name:** The name of the log file, which will be sanitized to remove any path characters. + +##### `get_log_buffer` + +Get the most recent log entries from the buffer maintained by the LogService. +By default, the buffer contains the last 20 log entries. + +### PagerService + + + +#### Listeners + +##### `boot.consolidation` + +PagerService registers its commands at the consolidation phase because +the '_init' method of CommandService may not have been called yet. + +#### Methods + +##### `onInit` + +Initializes PagerDuty configuration and registers alert handlers. +If PagerDuty is enabled in the configuration, it sets up an alert handler +to send alerts to PagerDuty. + +##### `alert` + +Sends an alert to all registered alert handlers. + +This method iterates through all alert handlers and attempts to send the alert. +If any handler fails to send the alert, an error message is logged. + +###### Parameters + +- **alert:** The alert object containing details about the alert. + +### ProcessEventService + +Service class that handles process-wide events and errors. +Provides centralized error handling for uncaught exceptions and unhandled promise rejections. +Sets up event listeners on the process object to capture and report critical errors +through the logging and error reporting services. + +## Libraries + +### core.expect + +### core.util.identutil + +#### Functions + +##### `randomItem` + +Select a random item from an array using a random number generator function. + +###### Parameters + +- **arr:** The array to select an item from + +### core.util.logutil + +#### Functions + +##### `stringify_log_entry` + +Stringifies a log entry into a formatted string for console output. + +###### Parameters + +- **logEntry:** The log entry object containing: + +### stdio + +#### Functions + +##### `visible_length` + +METADATA // {"ai-commented":{"service":"claude"}} + +##### `split_lines` + +Split a string into lines according to the terminal width, +preserving ANSI escape sequences, and return an array of lines. + +###### Parameters + +- **str:** The string to split into lines + +### core.util.strutil + +#### Functions + +##### `quot` + +METADATA // {"def":"core.util.strutil","ai-params":{"service":"claude"},"ai-commented":{"service":"claude"}} + +## Notes + +### Outside Imports + +This module has external relative imports. When these are +removed it may become possible to move this module to an +extension. + +**Imports:** +- `../../services/BaseService.js` +- `../../util/context.js` +- `../../services/BaseService` (use.BaseService) +- `../../services/BaseService` (use.BaseService) +- `../../util/context` +- `../../services/BaseService` (use.BaseService) +- `../../services/BaseService` (use.BaseService) +- `../../services/BaseService` (use.BaseService) diff --git a/src/backend/src/modules/core/ServerHealthService.js b/src/backend/src/modules/core/ServerHealthService.js new file mode 100644 index 0000000000000000000000000000000000000000..a9d94f2208390866f9f39b597dfe344d94ca1457 --- /dev/null +++ b/src/backend/src/modules/core/ServerHealthService.js @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); +const { promise } = require('@heyputer/putility').libs; +const SECOND = 1000; + +/** +* The ServerHealthService class provides comprehensive health monitoring for the server. +* It extends the BaseService class to include functionality for: +* - Periodic system checks (e.g., RAM usage, service checks) +* - Managing health check results and failures +* - Triggering alarms for critical conditions +* - Logging and managing statistics for health metrics +* +* This service is designed to work primarily on Linux systems, reading system metrics +* from `/proc/meminfo` and handling alarms via an external 'alarm' service. +*/ +class ServerHealthService extends BaseService { + static USE = { + linuxutil: 'core.util.linuxutil', + }; + + /** + * Defines the modules used by ServerHealthService. + * This static property is used to initialize and access system modules required for health checks. + * @type {Object} + * @property {fs} fs - The file system module for reading system information. + */ + static MODULES = { + fs: require('fs'), + }; + + /** + * Initializes the internal checks and failure tracking for the service. + * This method sets up empty arrays to store health checks and their failure statuses. + * + * @private + */ + _construct () { + this.checks_ = []; + this.failures_ = []; + } + + async _init () { + this.init_service_checks_(); + + /* + There's an interesting thread here: + https://github.com/nodejs/node/issues/23892 + + It's a discussion about whether to report "free" or "available" memory + in `os.freemem()`. There was no clear consensus in the discussion, + and then libuv was changed to report "available" memory instead. + + I've elected not to use `os.freemem()` here and instead read + `/proc/meminfo` directly. + */ + + const min_available_KiB = 1024 * 1024 * 2; // 2 GiB + + const svc_alarm = this.services.get('alarm'); + + this.stats_ = {}; + + // Disable if we're not on Linux + if ( process.platform !== 'linux' ) { + return; + } + + if ( this.config.no_system_checks ) return; + + /** + * Adds a health check to the service. + * + * @param {string} name - The name of the health check. + * @param {Function} fn - The function to execute for the health check. + * @returns {Object} A chainable object to add failure handlers. + */ + this.add_check('ram-usage', async () => { + const meminfo_text = await this.modules.fs.promises.readFile('/proc/meminfo', 'utf8'); + const meminfo = this.linuxutil.parse_meminfo(meminfo_text); + const log_fields = { + mem_free: meminfo.MemFree, + mem_available: meminfo.MemAvailable, + mem_total: meminfo.MemTotal, + }; + + this.log.debug('memory', log_fields); + + Object.assign(this.stats_, log_fields); + + if ( meminfo.MemAvailable < min_available_KiB ) { + svc_alarm.create('low-available-memory', 'Low available memory', log_fields); + } + }); + } + + /** + * Initializes service health checks by setting up periodic checks. + * This method configures an interval-based execution of health checks, + * handles timeouts, and manages failure states. + * + * @param {none} - This method does not take any parameters. + * @returns {void} - This method does not return any value. + */ + init_service_checks_ () { + const svc_alarm = this.services.get('alarm'); + /** + * Initializes periodic health checks for the server. + * + * This method sets up an interval to run all registered health checks + * at a specified frequency. It manages the execution of checks, handles + * timeouts, and logs errors or triggers alarms when checks fail. + * + * @private + * @method init_service_checks_ + * @memberof ServerHealthService + * @param {none} - No parameters are passed to this method. + * @returns {void} + */ + promise.asyncSafeSetInterval(async () => { + this.log.tick('service checks'); + const check_failures = []; + for ( const { name, fn, chainable } of this.checks_ ) { + const p_timeout = new promise.TeePromise(); + /** + * Creates a TeePromise to handle potential timeouts during health checks. + * + * @returns {Promise} A promise that can be resolved or rejected from multiple places. + */ + const timeout = setTimeout(() => { + p_timeout.reject(new Error('Health check timed out')); + }, 5 * SECOND); + try { + await Promise.race([ + fn(), + p_timeout, + ]); + clearTimeout(timeout); + } catch ( err ) { + // Trigger an alarm if this check isn't already in the failure list + + if ( this.failures_.some(v => v.name === name) ) { + return; + } + + svc_alarm.create('health-check-failure', + `Health check ${name} failed`, + { error: err }); + check_failures.push({ name }); + + this.log.error(`Error for healthcheck fail on ${name}: ${ err.stack}`); + + // Run the on_fail handlers + for ( const fn of chainable.on_fail_ ) { + try { + await fn(err); + } catch ( e ) { + this.log.error(`Error in on_fail handler for ${name}`, e); + } + } + } + } + + this.failures_ = check_failures; + }, 10 * SECOND, null, { + onBehindSchedule: (drift) => { + svc_alarm.create('health-checks-behind-schedule', + 'Health checks are behind schedule', + { drift }); + }, + }); + } + + /** + * Retrieves the current server health statistics. + * + * @returns {Object} An object containing the current health statistics. + * This method returns a shallow copy of the internal `stats_` object to prevent + * direct manipulation of the service's data. + */ + async get_stats () { + return { ...this.stats_ }; + } + + add_check (name, fn) { + const chainable = { + on_fail_: [], + }; + chainable.on_fail = (fn) => { + chainable.on_fail_.push(fn); + return chainable; + }; + this.checks_.push({ name, fn, chainable }); + return chainable; + } + + /** + * Retrieves the current health status of the server. + * Results are cached for 30 seconds to reduce computation overhead. + * + * @returns {Object} An object containing: + * - `ok` {boolean}: Indicates if all health checks passed. + * - `failed` {Array}: An array of names of failed health checks, if any. + */ + async get_status () { + const cache_key = 'server-health:status'; + + // Check cache first + if ( globalThis.kv ) { + const cached = globalThis.kv.get(cache_key); + if ( cached ) { + return cached; + } + } + + // Compute status + const failures = this.failures_.map(v => v.name); + const status = { + ok: failures.length === 0, + ...(failures.length ? { failed: failures } : {}), + }; + + // Cache with 30 second TTL + if ( globalThis.kv ) { + globalThis.kv.set(cache_key, status, { EX: 30 }); + } + + return status; + } +} + +module.exports = { ServerHealthService }; diff --git a/src/backend/src/modules/core/lib/__lib__.js b/src/backend/src/modules/core/lib/__lib__.js new file mode 100644 index 0000000000000000000000000000000000000000..f608d4a09e2d9b29b32655a66b976ba385bc4d17 --- /dev/null +++ b/src/backend/src/modules/core/lib/__lib__.js @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +module.exports = { + util: { + logutil: require('./log.js'), + identutil: require('./identifier.js'), + stdioutil: require('./stdio.js'), + linuxutil: require('./linux.js'), + }, + expect: require('./expect.js'), +}; diff --git a/src/backend/src/modules/core/lib/expect.js b/src/backend/src/modules/core/lib/expect.js new file mode 100644 index 0000000000000000000000000000000000000000..dbdcd5b1fd1fee8a7ef4284ca7d5ba564e7dc5ad --- /dev/null +++ b/src/backend/src/modules/core/lib/expect.js @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// METADATA // {"def":"core.expect"} +const { v4: uuidv4 } = require('uuid'); +const global_config = require('../../../config'); + +/** +* @class WorkUnit +* @description The WorkUnit class represents a unit of work that can be tracked and monitored for checkpoints. +* It includes methods to create instances, set checkpoints, and manage the state of the work unit. +*/ +class WorkUnit { + /** + * Represents a unit of work with checkpointing capabilities. + * + * @class + */ + + /** + * Creates and returns a new instance of WorkUnit. + * + * @static + * @returns {WorkUnit} A new instance of WorkUnit. + */ + static create () { + return new WorkUnit(); + } + /** + * Creates a new instance of the WorkUnit class. + * @static + * @returns {WorkUnit} A new WorkUnit instance. + */ + constructor () { + this.id = uuidv4(); + this.checkpoint_ = null; + } + checkpoint (label) { + if ( (global_config.logging ?? [] ).includes('checkpoint') ) { + console.log('CHECKPOINT', label); + } + this.checkpoint_ = label; + } +} + +/** +* @class CheckpointExpectation +* @classdesc The CheckpointExpectation class is used to represent an expectation that a specific checkpoint +* will be reached during the execution of a work unit. It includes methods to check if the checkpoint has +* been reached and to report the results of this check. +*/ +class CheckpointExpectation { + constructor (workUnit, checkpoint) { + this.workUnit = workUnit; + this.checkpoint = checkpoint; + } + /** + * Constructor for CheckpointExpectation class. + * Initializes the instance with a WorkUnit and a checkpoint label. + * @param {WorkUnit} workUnit - The work unit associated with the checkpoint. + * @param {string} checkpoint - The checkpoint label to be checked. + */ + check () { + // TODO: should be true if checkpoint was ever reached + return this.workUnit.checkpoint_ == this.checkpoint; + } + report (log) { + if ( this.check() ) return; + log.log(`operation(${this.workUnit.id}): ` + + `expected ${JSON.stringify(this.checkpoint)} ` + + `and got ${JSON.stringify(this.workUnit.checkpoint_)}.`); + } +} + +module.exports = { + WorkUnit, + CheckpointExpectation, +}; diff --git a/src/backend/src/modules/core/lib/identifier.js b/src/backend/src/modules/core/lib/identifier.js new file mode 100644 index 0000000000000000000000000000000000000000..1e9d1f6d30181548b7051a4e2e1d6ebaf4e0ec98 --- /dev/null +++ b/src/backend/src/modules/core/lib/identifier.js @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const adjectives = [ + 'amazing', 'ambitious', 'articulate', 'cool', 'bubbly', 'mindful', 'noble', 'savvy', 'serene', + 'sincere', 'sleek', 'sparkling', 'spectacular', 'splendid', 'spotless', 'stunning', + 'awesome', 'beaming', 'bold', 'brilliant', 'cheerful', 'modest', 'motivated', + 'friendly', 'fun', 'funny', 'generous', 'gifted', 'graceful', 'grateful', + 'passionate', 'patient', 'peaceful', 'perceptive', 'persistent', + 'helpful', 'sensible', 'loyal', 'honest', 'clever', 'capable', + 'calm', 'smart', 'genius', 'bright', 'charming', 'creative', 'diligent', 'elegant', 'fancy', + 'colorful', 'avid', 'active', 'gentle', 'happy', 'intelligent', + 'jolly', 'kind', 'lively', 'merry', 'nice', 'optimistic', 'polite', + 'quiet', 'relaxed', 'silly', 'witty', 'young', + 'strong', 'brave', 'agile', 'bold', 'confident', 'daring', + 'fearless', 'heroic', 'mighty', 'powerful', 'valiant', 'wise', 'wonderful', 'zealous', + 'warm', 'swift', 'neat', 'tidy', 'nifty', 'lucky', 'keen', + 'blue', 'red', 'aqua', 'green', 'orange', 'pink', 'purple', 'cyan', 'magenta', 'lime', + 'teal', 'lavender', 'beige', 'maroon', 'navy', 'olive', 'silver', 'gold', 'ivory', +]; + +const nouns = [ + 'street', 'roof', 'floor', 'tv', 'idea', 'morning', 'game', 'wheel', 'bag', 'clock', 'pencil', 'pen', + 'magnet', 'chair', 'table', 'house', 'room', 'book', 'car', 'tree', 'candle', 'light', 'planet', + 'flower', 'bird', 'fish', 'sun', 'moon', 'star', 'cloud', 'rain', 'snow', 'wind', 'mountain', + 'river', 'lake', 'sea', 'ocean', 'island', 'bridge', 'road', 'train', 'plane', 'ship', 'bicycle', + 'circle', 'square', 'garden', 'harp', 'grass', 'forest', 'rock', 'cake', 'pie', 'cookie', 'candy', + 'butterfly', 'computer', 'phone', 'keyboard', 'mouse', 'cup', 'plate', 'glass', 'door', + 'window', 'key', 'wallet', 'pillow', 'bed', 'blanket', 'soap', 'towel', 'lamp', 'mirror', + 'camera', 'hat', 'shirt', 'pants', 'shoes', 'watch', 'ring', + 'necklace', 'ball', 'toy', 'doll', 'kite', 'balloon', 'guitar', 'violin', 'piano', 'drum', + 'trumpet', 'flute', 'viola', 'cello', 'harp', 'banjo', 'tuba', +]; + +const words = { + adjectives, + nouns, +}; + +/** + * Select a random item from an array using a random number generator function. + * + * @param {Array} arr - The array to select an item from + * @param {function} [random=Math.random] - Random number generator function + * @returns {T} A random item from the array + */ +const randomItem = (arr, random) => arr[Math.floor((random ?? Math.random)() * arr.length)]; + +/** + * A function that generates a unique identifier by combining a random adjective, a random noun, and a random number (between 0 and 9999). + * The result is returned as a string with components separated by the specified separator. + * It is useful when you need to create unique identifiers that are also human-friendly. + * + * @param {string} [separator='_'] - The character used to separate the adjective, noun, and number. Defaults to '_' if not provided. + * @param {function} [rng=Math.random] - Random number generator function + * @returns {string} A unique, human-friendly identifier. + * + * @example + * + * let identifier = window.generate_identifier(); + * // identifier would be something like 'clever-idea-123' + * + */ +function generate_identifier (separator = '_', rng = Math.random) { + // return a random combination of first_adj + noun + number (between 0 and 9999) + // e.g. clever-idea-123 + return [ + randomItem(adjectives, rng), + randomItem(nouns, rng), + Math.floor(rng() * 10000), + ].join(separator); +} + +// Character set used for generating human-readable, case-insensitive random codes +const HUMAN_READABLE_CASE_INSENSITIVE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +function generate_random_code (n, { + rng = Math.random, + chars = HUMAN_READABLE_CASE_INSENSITIVE, +} = {}) { + let code = ''; + for ( let i = 0 ; i < n ; i++ ) { + code += randomItem(chars, rng); + } + return code; +} + +/** +* Composes a code by combining a mask string with a base-36 converted number +* @param {string} mask - Initial string template to use as base +* @param {number} value - Number to convert to base-36 and append to the right +* @returns {string} Combined uppercase code +*/ +function compose_code (mask, value) { + const right_str = value.toString(36); + let out_str = mask; + console.log('right_str', right_str); + console.log('out_str', out_str); + for ( let i = 0 ; i < right_str.length ; i++ ) { + out_str[out_str.length - 1 - i] = right_str[right_str.length - 1 - i]; + } + + out_str = out_str.toUpperCase(); + return out_str; +} + +module.exports = { + randomItem, + generate_identifier, + generate_random_code, +}; diff --git a/src/backend/src/modules/core/lib/linux.js b/src/backend/src/modules/core/lib/linux.js new file mode 100644 index 0000000000000000000000000000000000000000..50111136b6b5550f7519dcc918a4ea97b596a3c2 --- /dev/null +++ b/src/backend/src/modules/core/lib/linux.js @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const parse_meminfo = text => { + const lines = text.split('\n'); + + let meminfo = {}; + + for ( const line of lines ) { + if ( line.trim().length == 0 ) continue; + + const [keyPart, rest] = line.split(':'); + if ( rest === undefined ) continue; + + const key = keyPart.trim(); + // rest looks like " 123 kB"; parseInt ignores the unit. + const value = Number.parseInt(rest, 10); + meminfo[key] = value; + } + + return meminfo; +}; + +module.exports = { + parse_meminfo, +}; diff --git a/src/backend/src/modules/core/lib/log.js b/src/backend/src/modules/core/lib/log.js new file mode 100644 index 0000000000000000000000000000000000000000..075eb7de41d7aeadfdba5b84a30de029de3301af --- /dev/null +++ b/src/backend/src/modules/core/lib/log.js @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const config = require('../../../config.js'); + +const module_epoch = Date.now(); +const module_epoch_d = new Date(); +const display_time = (now) => { + const pad2 = n => String(n).padStart(2, '0'); + + const yyyy = now.getFullYear(); + const mm = pad2(now.getMonth() + 1); + const dd = pad2(now.getDate()); + const HH = pad2(now.getHours()); + const MM = pad2(now.getMinutes()); + const SS = pad2(now.getSeconds()); + const time = `${HH}:${MM}:${SS}`; + + const needYear = yyyy !== module_epoch_d.getFullYear(); + const needMonth = needYear || (now.getMonth() !== module_epoch_d.getMonth()); + const needDay = needMonth || (now.getDate() !== module_epoch_d.getDate()); + + if ( needYear ) return `${yyyy}-${mm}-${dd} ${time}`; + if ( needMonth ) return `${mm}-${dd} ${time}`; + if ( needDay ) return `${dd} ${time}`; + return time; +}; + +// Example: +// log("booting"); // → "14:07:12 booting" +// (next day) log("tick"); // → "16 00:00:01 tick" +// (next month) log("tick"); // → "11-01 00:00:01 tick" +// (next year) log("tick"); // → "2026-01-01 00:00:01 tick" + +/** +* Stringifies a log entry into a formatted string for console output. +* @param {Object} logEntry - The log entry object containing: +* @param {string} [prefix] - Optional prefix for the log message. +* @param {Object} log_lvl - Log level object with properties for label, escape code, etc. +* @param {string[]} crumbs - Array of context crumbs. +* @param {string} message - The log message. +* @param {Object} fields - Additional fields to be included in the log. +* @param {Object} objects - Objects to be logged. +* @returns {string} A formatted string representation of the log entry. +*/ +const stringify_log_entry = ({ prefix, log_lvl, crumbs, message, fields, objects, stack }) => { + const { colorize } = require('json-colorizer'); + + let lines = [], m; + + const lf = () => { + if ( ! m ) return; + lines.push(m); + m = ''; + }; + + m = ''; + + if ( ! config.show_relative_time ) { + m += `${display_time(fields.timestamp)} `; + } + + m += prefix ? `${prefix} ` : ''; + let levelLabelShown = false; + if ( log_lvl.label !== 'INFO' || !config.log_hide_info_label ) { + levelLabelShown = true; + m += `\x1B[${log_lvl.esc}m[${log_lvl.label}\x1B[0m`; + } else { + m += `\x1B[${log_lvl.esc}m[\x1B[0m`; + } + for ( let crumb of crumbs ) { + if ( crumb.startsWith('extension/') ) { + crumb = `\x1B[34;1m${crumb}\x1B[0m`; + } + if ( levelLabelShown ) { + m += '::'; + } else levelLabelShown = true; + m += crumb; + } + m += `\x1B[${log_lvl.esc}m]\x1B[0m`; + if ( fields.timestamp ) { + if ( config.show_relative_time ) { + // display seconds since logger epoch + const n = (fields.timestamp - module_epoch) / 1000; + m += ` (${n.toFixed(3)}s)`; + } + } + m += ` ${message} `; + lf(); + for ( const k in fields ) { + // Extensions always have the system actor in context which makes logs + // too verbose. To combat this, we disable logging the 'actor' field + // when the actor's username is 'system' and the `crumbs` include a + // string that starts with 'extension'. + if ( k === 'actor' && crumbs.some(crumb => crumb.startsWith('extension/')) ) { + if ( typeof fields[k] === 'object' && fields[k]?.username === 'system' ) { + continue; + } + } + + if ( k === 'timestamp' ) continue; + if ( k === 'stack' ) continue; + let v; try { + v = colorize(JSON.stringify(fields[k])); + } catch (e) { + v = `${ fields[k]}`; + } + m += ` \x1B[1m${k}:\x1B[0m ${v}`; + lf(); + } + if ( fields.stack ) { + lines.push(fields.stack); + } + return lines.join('\n'); +}; + +module.exports = { + stringify_log_entry, +}; diff --git a/src/backend/src/modules/core/lib/stdio.js b/src/backend/src/modules/core/lib/stdio.js new file mode 100644 index 0000000000000000000000000000000000000000..0cb14ebc5ce777c9e09aed2fc7a8bc23e922c083 --- /dev/null +++ b/src/backend/src/modules/core/lib/stdio.js @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Strip ANSI escape sequences from a string (e.g. color codes) + * and then return the length of the resulting string. + * + * @param {string} str - The string to calculate visible length for + * @returns {number} The length of the string without ANSI escape sequences + */ +const visible_length = (str) => { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, '').length; +}; + +/** + * Split a string into lines according to the terminal width, + * preserving ANSI escape sequences, and return an array of lines. + * + * @param {string} str The string to split into lines + * @returns {string[]} Array of lines split according to terminal width + */ +const split_lines = (str) => { + const lines = []; + let line = ''; + let line_length = 0; + for ( const c of str ) { + line += c; + if ( c === '\n' ) { + lines.push(line); + line = ''; + line_length = 0; + } else { + line_length++; + if ( line_length >= process.stdout.columns ) { + lines.push(line); + line = ''; + line_length = 0; + } + } + } + if ( line.length ) { + lines.push(line); + } + return lines; +}; + +module.exports = { + visible_length, + split_lines, +}; diff --git a/src/backend/src/modules/development/DevelopmentModule.js b/src/backend/src/modules/development/DevelopmentModule.js new file mode 100644 index 0000000000000000000000000000000000000000..17e351ea515ff8bdfdd593ba41d250db5cede1cf --- /dev/null +++ b/src/backend/src/modules/development/DevelopmentModule.js @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +/** + * Enable this module when you want performance monitoring. + * + * Performance monitoring requires additional setup. Jaegar should be installed + * and running. + */ +class DevelopmentModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const LocalTerminalService = require('./LocalTerminalService'); + services.registerService('local-terminal', LocalTerminalService); + } +} + +module.exports = { + DevelopmentModule, +}; diff --git a/src/backend/src/modules/development/LocalTerminalService.js b/src/backend/src/modules/development/LocalTerminalService.js new file mode 100644 index 0000000000000000000000000000000000000000..cdc89d74e54cf07990245bbe9896e9e5a553d4f1 --- /dev/null +++ b/src/backend/src/modules/development/LocalTerminalService.js @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { spawn } = require('child_process'); +const APIError = require('../../api/APIError'); +const configurable_auth = require('../../middleware/configurable_auth'); +const { Endpoint } = require('../../util/expressutil'); + +const PERM_LOCAL_TERMINAL = 'local-terminal:access'; + +const path_ = require('path'); +const { Actor } = require('../../services/auth/Actor'); +const BaseService = require('../../services/BaseService'); +const { Context } = require('../../util/context'); + +class LocalTerminalService extends BaseService { + _construct () { + this.sessions_ = {}; + } + get_profiles () { + return { + ['api-test']: { + cwd: path_.join(__dirname, + '../../../../../', + 'tools/api-tester'), + shell: [ + '/usr/bin/env', 'node', + 'apitest.js', + '--config=config.yml', + ], + allow_args: true, + }, + }; + }; + ['__on_install.routes'] (_, { app }) { + const r_group = (() => { + const require = this.require; + const express = require('express'); + return express.Router(); + })(); + app.use('/local-terminal', r_group); + + Endpoint({ + route: '/new', + methods: ['POST'], + mw: [configurable_auth()], + handler: async (req, res) => { + const term_uuid = require('uuid').v4(); + + const svc_permission = this.services.get('permission'); + const actor = Context.get('actor'); + const can_access = actor && + await svc_permission.check(actor, PERM_LOCAL_TERMINAL); + + if ( ! can_access ) { + throw APIError.create('permission_denied', null, { + permission: PERM_LOCAL_TERMINAL, + }); + } + + const profiles = this.get_profiles(); + if ( ! profiles[req.body.profile] ) { + throw APIError.create('invalid_profile', null, { + profile: req.body.profile, + }); + } + + const profile = profiles[req.body.profile]; + + const args = profile.shell.slice(1); + if ( profile.allow_args && req.body.args ) { + args.push(...req.body.args); + } + const proc = spawn(profile.shell[0], args, { + shell: true, + env: { + ...process.env, + ...(profile.env ?? {}), + }, + cwd: profile.cwd, + }); + + // stdout to websocket + { + const svc_socketio = req.services.get('socketio'); + proc.stdout.on('data', data => { + const base64 = data.toString('base64'); + console.log('---------------------- CHUNK?', base64); + svc_socketio.send({ room: req.user.id }, + 'local-terminal.stdout', + { + term_uuid, + base64, + }); + }); + proc.stderr.on('data', data => { + const base64 = data.toString('base64'); + console.log('---------------------- CHUNK?', base64); + svc_socketio.send({ room: req.user.id }, + 'local-terminal.stderr', + { + term_uuid, + base64, + }); + }); + } + + proc.on('exit', () => { + this.log.noticeme(`[${term_uuid}] Process exited (${proc.exitCode})`); + delete this.sessions_[term_uuid]; + + const svc_socketio = req.services.get('socketio'); + svc_socketio.send({ room: req.user.id }, + 'local-terminal.exit', + { + term_uuid, + }); + }); + + this.sessions_[term_uuid] = { + uuid: term_uuid, + proc, + }; + + res.json({ term_uuid }); + }, + }).attach(r_group); + } + async _init () { + const svc_event = this.services.get('event'); + svc_event.on('web.socket.user-connected', async (_, { + socket, + user, + }) => { + const svc_permission = this.services.get('permission'); + const actor = Actor.adapt(user); + const can_access = actor && + await svc_permission.check(actor, PERM_LOCAL_TERMINAL); + + if ( ! can_access ) { + return; + } + + socket.on('local-terminal.stdin', async msg => { + console.log('local term message', msg); + + const session = this.sessions_[msg.term_uuid]; + if ( ! session ) { + return; + } + + const base64 = Buffer.from(msg.data, 'base64'); + session.proc.stdin.write(base64); + }); + }); + } +} + +module.exports = LocalTerminalService; diff --git a/src/backend/src/modules/dns/DNSModule.js b/src/backend/src/modules/dns/DNSModule.js new file mode 100644 index 0000000000000000000000000000000000000000..fb5795115dec995087454caab98e80a1ee706d75 --- /dev/null +++ b/src/backend/src/modules/dns/DNSModule.js @@ -0,0 +1,14 @@ +const { AdvancedBase } = require('@heyputer/putility'); + +class DNSModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { DNSService } = require('./DNSService'); + services.registerService('dns', DNSService); + } +} + +module.exports = { + DNSModule, +}; diff --git a/src/backend/src/modules/dns/DNSService.js b/src/backend/src/modules/dns/DNSService.js new file mode 100644 index 0000000000000000000000000000000000000000..c315db64d73c7baa5411487e5b4852cc8f7b64dc --- /dev/null +++ b/src/backend/src/modules/dns/DNSService.js @@ -0,0 +1,115 @@ +const BaseService = require('../../services/BaseService'); +const { sleep } = require('../../util/asyncutil'); + +/** + * DNS service that provides DNS client functionality and optional test server + * @extends BaseService + */ +class DNSService extends BaseService { + /** + * Initializes the DNS service by creating a DNS client and optionally starting a test server + * @returns {Promise} + */ + async _init () { + const dns2 = require('dns2'); + // this.dns = new dns2(this.config.client); + this.dns = new dns2({ + nameServers: ['127.0.0.1'], + port: 5300, + }); + + if ( this.config.test_server ) { + this.test_server_(); + } + } + + /** + * Returns the DNS client instance + * @returns {Object} The DNS client + */ + get_client () { + return this.dns; + } + + /** + * Creates and starts a test DNS server that responds to A and TXT record queries + * The server listens on port 5300 and returns mock responses for testing purposes + */ + test_server_ () { + const dns2 = require('dns2'); + const { Packet } = dns2; + + const server = dns2.createServer({ + udp: true, + handle: (request, send, rinfo) => { + const { questions } = request; + const response = Packet.createResponseFromRequest(request); + for ( const question of questions ) { + if ( question.type === Packet.TYPE.A || question.type === Packet.TYPE.ANY ) { + response.answers.push({ + name: question.name, + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + ttl: 300, + address: '127.0.0.11', + }); + } + + if ( question.type === Packet.TYPE.TXT || question.type === Packet.TYPE.ANY ) { + response.answers.push({ + name: question.name, + type: Packet.TYPE.TXT, + class: Packet.CLASS.IN, + ttl: 300, + data: [ + JSON.stringify({ username: 'ed3' }), + ], + }); + } + } + send(response); + }, + }); + + server.on('listening', () => { + this.log.debug('Fake DNS server listening', server.addresses()); + + if ( this.config.test_server_selftest ) { + (async () => { + await sleep(5000); + { + console.log('Trying first test'); + const result = await this.dns.resolveA('test.local'); + console.log('Test 1', result); + } + { + console.log('Trying second test'); + const result = await this.dns.resolve('_puter-verify.test.local', 'TXT'); + console.log('Test 2', result); + } + })(); + } + }); + + server.on('close', () => { + console.log('Fake DNS server closed'); + }); + + server.on('request', (request, response, rinfo) => { + console.log(request.header.id, request.questions[0]); + }); + + server.on('requestError', (error) => { + console.log('Client sent an invalid request', error); + }); + + server.listen({ + udp: { + port: 5300, + address: '127.0.0.1', + }, + }); + } +} + +module.exports = { DNSService }; diff --git a/src/backend/src/modules/domain/DomainModule.js b/src/backend/src/modules/domain/DomainModule.js new file mode 100644 index 0000000000000000000000000000000000000000..b80e42aef844c6677e54a2ce8a50fb81ca045336 --- /dev/null +++ b/src/backend/src/modules/domain/DomainModule.js @@ -0,0 +1,16 @@ +const { AdvancedBase } = require('@heyputer/putility'); + +class DomainModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { DomainVerificationService } = require('./DomainVerificationService'); + services.registerService('domain-verification', DomainVerificationService); + + // TODO: enable flag + const { TXTVerifyService } = require('./TXTVerifyService'); + services.registerService('__txt-verify', TXTVerifyService); + } +} + +module.exports = { DomainModule }; diff --git a/src/backend/src/modules/domain/DomainVerificationService.js b/src/backend/src/modules/domain/DomainVerificationService.js new file mode 100644 index 0000000000000000000000000000000000000000..f256a36d25a6050bb1cef53f40fa9f32abe2754a --- /dev/null +++ b/src/backend/src/modules/domain/DomainVerificationService.js @@ -0,0 +1,43 @@ +const { get_user } = require('../../helpers'); +const BaseService = require('../../services/BaseService'); + +class DomainVerificationService extends BaseService { + _init () { + this._register_commands(); + } + async get_controlling_user ({ domain }) { + const svc_event = this.services.get('event'); + + // 1 :: Allow event listeners to verify domains + const event = { + domain, + user: undefined, + }; + await svc_event.emit('domain.get-controlling-user', event); + if ( event.user ) { + return event.user; + } + + // 2 :: If there is no controlling user, 'admin' is the + // controlling user. + return await get_user({ username: 'admin' }); + } + + _register_commands (commands) { + const svc_commands = this.services.get('commands'); + svc_commands.registerCommands('domain', [ + { + id: 'user', + description: '', + handler: async (args, log) => { + const res = await this.get_controlling_user({ domain: args[0] }); + log.log(res); + }, + }, + ]); + } +} + +module.exports = { + DomainVerificationService, +}; diff --git a/src/backend/src/modules/domain/TXTVerifyService.js b/src/backend/src/modules/domain/TXTVerifyService.js new file mode 100644 index 0000000000000000000000000000000000000000..91b87a01bf36d7c19d8691642a9877d0c0c886b8 --- /dev/null +++ b/src/backend/src/modules/domain/TXTVerifyService.js @@ -0,0 +1,32 @@ +const { get_user } = require('../../helpers'); +const BaseService = require('../../services/BaseService'); +const { atimeout } = require('../../util/asyncutil'); + +class TXTVerifyService extends BaseService { + ['__on_boot.consolidation'] () { + const svc_dns = this.services.get('dns'); + const dns = svc_dns.get_client(); + + const svc_event = this.services.get('event'); + svc_event.on('domain.get-controlling-user', async (_, event) => { + const record_name = `_puter-verify.${event.domain}`; + try { + const result = await atimeout(5000, + dns.resolve(record_name, 'TXT')); + + const answer = result.answers.filter(a => a.name === record_name && + a.type === 16)[0]; + + const data_raw = answer.data; + const data = JSON.parse(data_raw); + event.user = await get_user({ username: data.username }); + } catch (e) { + console.error('ERROR', e); + } + }); + } +} + +module.exports = { + TXTVerifyService, +}; diff --git a/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js b/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js new file mode 100644 index 0000000000000000000000000000000000000000..c2edf777191b9dccdca25461584c24ff35f0c8d5 --- /dev/null +++ b/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2025-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); + +/** +* Service class that manages Entity Store interface registrations. +* Handles registration of the crud-q interface which is used by various +* entity storage services. +* @extends BaseService +*/ +class EntityStoreInterfaceService extends BaseService { + /** + * Service class for managing Entity Store interface registrations. + * Extends the base service to provide entity storage interface management. + */ + async ['__on_driver.register.interfaces'] () { + const svc_registry = this.services.get('registry'); + const col_interfaces = svc_registry.get('interfaces'); + + // Define the standard CRUD interface methods that will be reused + const crudMethods = { + create: { + parameters: { + object: { + type: 'json', + subtype: 'object', + required: true, + }, + options: { type: 'json' }, + }, + }, + read: { + parameters: { + uid: { type: 'string' }, + id: { type: 'json' }, + params: { type: 'json' }, + }, + }, + select: { + parameters: { + predicate: { type: 'json' }, + offset: { type: 'number' }, + limit: { type: 'number' }, + params: { type: 'json' }, + }, + }, + update: { + parameters: { + id: { type: 'json' }, + object: { + type: 'json', + subtype: 'object', + required: true, + }, + options: { type: 'json' }, + }, + }, + upsert: { + parameters: { + id: { type: 'json' }, + object: { + type: 'json', + subtype: 'object', + required: true, + }, + options: { type: 'json' }, + }, + }, + delete: { + parameters: { + uid: { type: 'string' }, + id: { type: 'json' }, + }, + }, + }; + + // Register the crud-q interface + col_interfaces.set('crud-q', { + methods: { ...crudMethods }, + }); + + // Register entity-specific interfaces that use crud-q + const entityInterfaces = [ + { + name: 'puter-apps', + description: 'Manage a developer\'s apps on Puter.', + }, + { + name: 'puter-subdomains', + description: 'Manage subdomains on Puter.', + }, + { + name: 'puter-notifications', + description: 'Read notifications on Puter.', + }, + ]; + + // Register each entity interface with the same CRUD methods + for ( const entity of entityInterfaces ) { + col_interfaces.set(entity.name, { + description: entity.description, + methods: { ...crudMethods }, + }); + } + } +} + +module.exports = { + EntityStoreInterfaceService, +}; \ No newline at end of file diff --git a/src/backend/src/modules/entitystore/EntityStoreModule.js b/src/backend/src/modules/entitystore/EntityStoreModule.js new file mode 100644 index 0000000000000000000000000000000000000000..bdd68c5bd1b9dddbc5c88e145b85df08ee578340 --- /dev/null +++ b/src/backend/src/modules/entitystore/EntityStoreModule.js @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const { EntityStoreInterfaceService } = require('./EntityStoreInterfaceService'); + +/** + * A module for registering entity store interfaces. + */ +class EntityStoreModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + // Register interface services + services.registerService('entitystore-interface', EntityStoreInterfaceService); + } +} + +module.exports = { + EntityStoreModule, +}; \ No newline at end of file diff --git a/src/backend/src/modules/filesystem/roadmap.md b/src/backend/src/modules/filesystem/roadmap.md new file mode 100644 index 0000000000000000000000000000000000000000..687584b12f1b322cf8f87faaf2110659a93ee348 --- /dev/null +++ b/src/backend/src/modules/filesystem/roadmap.md @@ -0,0 +1,21 @@ +## Mountpounts hurdles + +- [ ] subdomains use integer IDs to to reference files, which + only works with PuterFS. This means other filesystem + providers will not be usable for subdomains. + + Possible solutions: + - GUI logic to disable subdomains feature for other providers + - Add a new column to associate subdomains with paths + - Map non-puterfs nodes to (1B + path_id), where path_id is + a numeric identifier that is associated with the path, and + the association is stored in the database or system runtime + directory. + +- [ ] permissions are associated with UUIDs, but will need to + be able to be associated with paths instead for non-puterfs + mountpoints. + + - Make path-to-uuid re-writer act on puter-fs only. + - ACL needs to be able to check path-based permissions + on non-puterfs mountpoints. diff --git a/src/backend/src/modules/hostos/HostOSModule.js b/src/backend/src/modules/hostos/HostOSModule.js new file mode 100644 index 0000000000000000000000000000000000000000..05ae25fe9cce8858f184e0385699f3f806c87d58 --- /dev/null +++ b/src/backend/src/modules/hostos/HostOSModule.js @@ -0,0 +1,14 @@ +const { AdvancedBase } = require('@heyputer/putility'); + +class HostOSModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const ProcessService = require('./ProcessService'); + services.registerService('process', ProcessService); + } +} + +module.exports = { + HostOSModule, +}; diff --git a/src/backend/src/modules/hostos/ProcessService.js b/src/backend/src/modules/hostos/ProcessService.js new file mode 100644 index 0000000000000000000000000000000000000000..fb13d72806ef243c257ec49865f1b13fc960dbca --- /dev/null +++ b/src/backend/src/modules/hostos/ProcessService.js @@ -0,0 +1,96 @@ +const BaseService = require('../../services/BaseService'); + +class ProxyLogger { + constructor (log) { + this.log = log; + } + attach (stream) { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString(); + let lineEndIndex = buffer.indexOf('\n'); + while ( lineEndIndex !== -1 ) { + const line = buffer.substring(0, lineEndIndex); + this.log(line); + buffer = buffer.substring(lineEndIndex + 1); + lineEndIndex = buffer.indexOf('\n'); + } + }); + + stream.on('end', () => { + if ( buffer.length ) { + this.log(buffer); + } + }); + } +} + +class ProcessService extends BaseService { + static CONCERN = 'workers'; + + static MODULES = { + path: require('path'), + spawn: require('child_process').spawn, + }; + + _construct () { + this.instances = []; + } + + async _init (args) { + this.args = args; + + process.on('exit', () => { + this.exit_all_(); + }); + } + + log_ (name, isErr, line) { + let txt = `[${name}:`; + txt += isErr + ? '\x1B[34;1m2\x1B[0m' + : '\x1B[32;1m1\x1B[0m'; + txt += `] ${ line}`; + this.log.info(txt); + } + + async exit_all_ () { + for ( const { proc } of this.instances ) { + proc.kill(); + } + } + + async start ({ name, fullpath, command, args, env }) { + this.log.info(`Starting ${name} in ${fullpath}`); + const env_processed = { ...(env ?? {}) }; + for ( const k in env_processed ) { + if ( typeof env_processed[k] !== 'function' ) continue; + env_processed[k] = env_processed[k]({ + global_config: this.global_config, + }); + } + this.log.debug('command', + { command, args }); + const proc = this.modules.spawn(command, args, { + shell: true, + env: { + ...process.env, + ...env_processed, + }, + cwd: fullpath, + }); + this.instances.push({ + name, proc, + }); + const out = new ProxyLogger((line) => this.log_(name, false, line)); + out.attach(proc.stdout); + const err = new ProxyLogger((line) => this.log_(name, true, line)); + err.attach(proc.stderr); + proc.on('exit', () => { + this.log.info(`[${name}:exit] Process exited (${proc.exitCode})`); + this.instances = this.instances.filter((inst) => inst.proc !== proc); + }); + } +} + +module.exports = ProcessService; diff --git a/src/backend/src/modules/internet/InternetModule.js b/src/backend/src/modules/internet/InternetModule.js new file mode 100644 index 0000000000000000000000000000000000000000..5b85344f81a0f18b3786ea0b6b426617723c4140 --- /dev/null +++ b/src/backend/src/modules/internet/InternetModule.js @@ -0,0 +1,16 @@ +const { AdvancedBase } = require('@heyputer/putility'); +const config = require('../../config.js'); + +class InternetModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + if ( config?.services?.['wisp-relay'] ) { + const WispRelayService = require('./WispRelayService.js'); + services.registerService('wisp-relay', WispRelayService); + } + + } +} + +module.exports = { InternetModule }; diff --git a/src/backend/src/modules/internet/WispRelayService.js b/src/backend/src/modules/internet/WispRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..8de676b04d8d0e149b537a1cbaf4be4b77bbd675 --- /dev/null +++ b/src/backend/src/modules/internet/WispRelayService.js @@ -0,0 +1,20 @@ +const BaseService = require('../../services/BaseService'); + +class WispRelayService extends BaseService { + _init () { + const path_ = require('path'); + const svc_process = this.services.get('process'); + svc_process.start({ + name: 'internet.js', + command: this.config.node_path, + fullpath: this.config.wisp_relay_path, + args: ['index.js'], + env: { + PORT: this.config.wisp_relay_port, + WISP_AUTH_SERVER: this.config.origin, + }, + }); + } +} + +module.exports = WispRelayService; diff --git a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js new file mode 100644 index 0000000000000000000000000000000000000000..6ee12cbfccdaf8e0e22c30188075448371d097ab --- /dev/null +++ b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2025-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); + +/** + * @typedef {Object} KVStoreInterface + * @property {function(KVStoreGetParams): Promise} get - Retrieve the value(s) for the given key(s). + * @property {function(KVStoreSetParams): Promise} set - Set a value for a key, with optional expiration. + * @property {function(KVStoreDelParams): Promise} del - Delete a value by key. + * @property {function(KVStoreListParams): Promise} list - List all key-value pairs, optionally as a specific type. + * @property {function(): Promise} flush - Delete all key-value pairs in the store. + * @property {(params: {key:string, pathAndAmountMap: Record}) => Promise} incr - Increment a numeric value by key. + * @property {(params: {key:string, pathAndAmountMap: Record}) => Promise} decr - Decrement a numeric value by key. + * @property {function(KVStoreExpireAtParams): Promise} expireAt - Set a key to expire at a specific UNIX timestamp (seconds). + * @property {function(KVStoreExpireParams): Promise} expire - Set a key to expire after a given TTL (seconds). + * + * @typedef {Object} KVStoreGetParams + * @property {string|string[]} key - The key or array of keys to retrieve. + * + * @typedef {Object} KVStoreSetParams + * @property {string} key - The key to set. + * @property {*} value - The value to store. + * @property {number} [expireAt] - Optional UNIX timestamp (seconds) when the key should expire. + * + * @typedef {Object} KVStoreDelParams + * @property {string} key - The key to delete. + * + * @typedef {Object} KVStoreListParams + * @property {string} [as] - Optional type to list as (e.g., 'array', 'object'). + * + * @typedef {Object} KVStoreExpireAtParams + * @property {string} key - The key to set expiration for. + * @property {number} timestamp - UNIX timestamp (seconds) when the key should expire. + * + * @typedef {Object} KVStoreExpireParams + * @property {string} key - The key to set expiration for. + * @property {number} ttl - Time-to-live in seconds. + */ + +/** + * Service for registering the puter-kvstore interface, exposing a simple key-value store API + * with support for get, set, delete, list, flush, increment, decrement, and key expiration. + * @extends BaseService + */ +class KVStoreInterfaceService extends BaseService { + /** + * Service class for managing KVStore interface registrations. + * Extends the base service to provide key-value store interface management. + */ + async ['__on_driver.register.interfaces'] () { + const svc_registry = this.services.get('registry'); + const col_interfaces = svc_registry.get('interfaces'); + + // Register the puter-kvstore interface + col_interfaces.set('puter-kvstore', { + description: 'A simple key-value store.', + methods: { + get: { + description: 'Get a value by key.', + parameters: { + key: { type: 'json', required: true }, + }, + result: { type: 'json' }, + }, + set: { + description: 'Set a value by key.', + parameters: { + key: { type: 'string', required: true }, + value: { type: 'json' }, + expireAt: { type: 'number' }, + }, + result: { type: 'void' }, + }, + del: { + description: 'Delete a value by key.', + parameters: { + key: { type: 'string' }, + }, + result: { type: 'void' }, + }, + list: { + description: 'List all key-value pairs.', + parameters: { + as: { + type: 'string', + }, + }, + result: { type: 'array' }, + }, + flush: { + description: 'Delete all key-value pairs.', + parameters: {}, + result: { type: 'void' }, + }, + incr: { + description: 'Increment a value by key.', + parameters: { + key: { type: 'string', required: true }, + pathAndAmountMap: { type: 'json', required: true, description: 'map of period-joined path to amount to increment by' }, + }, + result: { type: 'json', description: 'The updated value' }, + }, + decr: { + description: 'Decrement a value by key.', + parameters: { + key: { type: 'string', required: true }, + pathAndAmountMap: { type: 'json', required: true, description: 'map of period-joined path to amount to increment by' }, + + }, + result: { type: 'json', description: 'The updated value' }, + }, + expireAt: { + description: 'Set a key to expire at a given timestamp in sec.', + parameters: { + key: { type: 'string', required: true }, + timestamp: { type: 'number', required: true }, + + }, + result: { type: 'number' }, + }, + expire: { + description: 'Set a key to expire in ttl many seconds.', + parameters: { + key: { type: 'string', required: true }, + ttl: { type: 'number', required: true }, + + }, + result: { type: 'number' }, + }, + }, + }); + } +} + +module.exports = { + KVStoreInterfaceService, +}; \ No newline at end of file diff --git a/src/backend/src/modules/kvstore/KVStoreModule.js b/src/backend/src/modules/kvstore/KVStoreModule.js new file mode 100644 index 0000000000000000000000000000000000000000..ba2a07bef853f6d8fcdff386dd3633521050df0f --- /dev/null +++ b/src/backend/src/modules/kvstore/KVStoreModule.js @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const { KVStoreInterfaceService } = require('./KVStoreInterfaceService'); + +/** + * A module for registering key-value store interfaces. + */ +class KVStoreModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + // Register interface services + services.registerService('kvstore-interface', KVStoreInterfaceService); + } +} + +module.exports = { + KVStoreModule, +}; \ No newline at end of file diff --git a/src/backend/src/modules/perfmon/PerfMonModule.js b/src/backend/src/modules/perfmon/PerfMonModule.js new file mode 100644 index 0000000000000000000000000000000000000000..95ac3f8e1f1f1fda47971e5d4fd01628dd08c10f --- /dev/null +++ b/src/backend/src/modules/perfmon/PerfMonModule.js @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +/** + * Enable this module when you want performance monitoring. + * + * Performance monitoring requires additional setup. Jaegar should be installed + * and running. + */ +class PerfMonModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const TelemetryService = require('./TelemetryService'); + services.registerService('telemetry', TelemetryService); + } +} + +module.exports = { + PerfMonModule, +}; diff --git a/src/backend/src/modules/perfmon/TelemetryService.js b/src/backend/src/modules/perfmon/TelemetryService.js new file mode 100644 index 0000000000000000000000000000000000000000..42b4a36ea804d46093fededadf766924b0fb6b4e --- /dev/null +++ b/src/backend/src/modules/perfmon/TelemetryService.js @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const opentelemetry = require('@opentelemetry/api'); +const { NodeSDK } = require('@opentelemetry/sdk-node'); +const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node'); +const { PeriodicExportingMetricReader, ConsoleMetricExporter } = require('@opentelemetry/sdk-metrics'); + +const { Resource } = require('@opentelemetry/resources'); +const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions'); +const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base'); +const config = require('../../config'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc'); + +const BaseService = require('../../services/BaseService'); + +class TelemetryService extends BaseService { + _construct () { + const resource = Resource.default().merge( + new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'puter-backend', + [SemanticResourceAttributes.SERVICE_VERSION]: '0.1.0', + })); + + const exporter = this.#getConfiguredExporter(); + this.exporter = exporter; + + const sdk = new NodeSDK({ + resource, + traceExporter: exporter, + metricReader: new PeriodicExportingMetricReader({ + exporter: new ConsoleMetricExporter(), + }), + instrumentations: [getNodeAutoInstrumentations()], + }); + + this.sdk = sdk; + + this.sdk.start(); + + this.tracer_ = opentelemetry.trace.getTracer('puter-tracer'); + } + + _init () { + const svc_context = this.services.get('context'); + svc_context.register_context_hook('pre_arun', ({ hints, trace_name, callback, replace_callback }) => { + if ( ! trace_name ) return; + if ( ! hints.trace ) return; + console.log('APPLYING TRACE NAME', trace_name); + replace_callback(async () => { + return await this.tracer_.startActiveSpan(trace_name, async span => { + try { + return await callback(); + } catch ( error ) { + span.setStatus({ code: opentelemetry.SpanStatusCode.ERROR, message: error.message }); + throw error; + } finally { + span.end(); + } + }); + }); + }); + } + + #getConfiguredExporter () { + if ( config.jaeger ?? this.config.jaeger ) { + return new OTLPTraceExporter(config.jaeger ?? this.config.jaeger); + } + const exporter = new ConsoleSpanExporter(); + return exporter; + } +} + +module.exports = TelemetryService; diff --git a/src/backend/src/modules/puterfs/MountpointService.js b/src/backend/src/modules/puterfs/MountpointService.js new file mode 100644 index 0000000000000000000000000000000000000000..cbbdb6f7e3b483b11f506d91fad83ab65793fefe --- /dev/null +++ b/src/backend/src/modules/puterfs/MountpointService.js @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { RootNodeSelector, NodeUIDSelector, NodeChildSelector, NodePathSelector, try_infer_attributes } = require('../../filesystem/node/selectors'); +const BaseService = require('../../services/BaseService'); + +/** + * This will eventually be a service which manages the storage + * backends for mountpoints. + * + * For the moment, this is a way to access the storage backend + * in situations where ContextInitService isn't able to + * initialize a context. + */ + +/** +* @class MountpointService +* @extends BaseService +* @description Service class responsible for managing storage backends for mountpoints. +* Currently provides a temporary solution for accessing storage backend when context +* initialization is not possible. Will be expanded to handle multiple mountpoints +* and their associated storage backends in future implementations. +*/ +class MountpointService extends BaseService { + + #storage = {}; + #mounters = {}; + #mountpoints = {}; + + register_mounter (name, mounter) { + this.#mounters[name] = mounter; + } + + async ['__on_boot.consolidation'] () { + // Emit event for registering filesystem types + const svc_event = this.services.get('event'); + const event = {}; + event.createFilesystemType = (name, filesystemType) => { + this.#mounters[name] = filesystemType; + }; + await svc_event.emit('create.filesystem-types', event); + + // Determine mountpoints configuration + const mountpoints = this.config.mountpoints ?? { + '/': { + mounter: 'puterfs', + }, + }; + + // Mount filesystems + for ( const path of Object.keys(mountpoints) ) { + const { mounter: mounter_name, options } = + mountpoints[path]; + const mounter = this.#mounters[mounter_name]; + if ( ! mounter ) { + throw new Error(`unrecognized filesystem type: ${mounter_name}`); + } + const provider = await mounter.mount({ + path, + options, + }); + this.#mountpoints[path] = { + provider, + }; + } + + this.services.emit('filesystem.ready', { + mountpoints: Object.keys(this.#mountpoints), + }); + } + + async get_provider (selector) { + // If there is only one provider, we don't need to do any of this, + // and that's a big deal because the current implementation requires + // fetching a filesystem entry before we even have operation-level + // transient memoization instantiated. + if ( Object.keys(this.#mountpoints).length === 1 ) { + return Object.values(this.#mountpoints)[0].provider; + } + + try_infer_attributes(selector); + + if ( selector instanceof RootNodeSelector ) { + return this.#mountpoints['/'].provider; + } + + if ( selector instanceof NodeUIDSelector ) { + for ( const { provider } of Object.values(this.#mountpoints) ) { + const result = await provider.quick_check({ + selector, + }); + if ( result ) { + return provider; + } + } + + // No provider found, but we shouldn't throw an error here + // because it's a valid case for a node that doesn't exist. + } + + if ( selector instanceof NodeChildSelector ) { + if ( selector.path ) { + return this.get_provider(new NodePathSelector(selector.path)); + } else { + return this.get_provider(selector.parent); + } + } + + const probe = {}; + selector.setPropertiesKnownBySelector(probe); + if ( probe.path ) { + let longest_mount_path = ''; + for ( const path of Object.keys(this.#mountpoints) ) { + if ( ! probe.path.startsWith(path) ) { + continue; + } + if ( path.length > longest_mount_path.length ) { + longest_mount_path = path; + } + } + + if ( longest_mount_path ) { + return this.#mountpoints[longest_mount_path].provider; + } + } + + // Use root mountpoint as fallback + return this.#mountpoints['/'].provider; + } + + // Temporary solution - we'll develop this incrementally + set_storage (provider, storage) { + this.#storage[provider] = storage; + } + + /** + * Gets the current storage backend instance + * @returns {Object} The storage backend instance + */ + get_storage (provider) { + const storage = this.#storage[provider]; + if ( ! storage ) { + throw new Error(`MountpointService.get_storage: storage for provider "${provider}" not found`); + } + return storage; + } +} + +module.exports = { + MountpointService, +}; diff --git a/src/backend/src/modules/puterfs/PuterFSModule.js b/src/backend/src/modules/puterfs/PuterFSModule.js new file mode 100644 index 0000000000000000000000000000000000000000..44846f915888931cd012fe5e7ccca5abf26fd31c --- /dev/null +++ b/src/backend/src/modules/puterfs/PuterFSModule.js @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const FSNodeContext = require('../../filesystem/FSNodeContext'); +const capabilities = require('../../filesystem/definitions/capabilities'); +const selectors = require('../../filesystem/node/selectors'); +const { RuntimeModule } = require('../../extension/RuntimeModule'); +const { MODE_READ, MODE_WRITE } = require('../../services/fs/FSLockService'); +const { UploadProgressTracker } = require('../../filesystem/storage/UploadProgressTracker'); +const { PuterPath } = require('../../filesystem/lib/PuterPath'); + +class PuterFSModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { RESOURCE_STATUS_PENDING_CREATE } = require('./ResourceService'); + + // Expose filesystem declarations to extensions + { + const runtimeModule = new RuntimeModule({ name: 'fs' }); + runtimeModule.exports = { + capabilities, + selectors, + FSNodeContext, + PuterPath, + lock: { + MODE_READ, + MODE_WRITE, + }, + resource: { + RESOURCE_STATUS_PENDING_CREATE, + }, + util: { + UploadProgressTracker, + }, + }; + context.get('runtime-modules').register(runtimeModule); + } + + const { ResourceService } = require('./ResourceService'); + services.registerService('resourceService', ResourceService); + + const { SizeService } = require('./SizeService'); + services.registerService('sizeService', SizeService); + + const { MountpointService } = require('./MountpointService'); + services.registerService('mountpoint', MountpointService); + + const { MemoryFSService } = require('./customfs/MemoryFSService'); + services.registerService('memoryfs', MemoryFSService); + } +} + +module.exports = { PuterFSModule }; diff --git a/src/backend/src/modules/puterfs/ResourceService.js b/src/backend/src/modules/puterfs/ResourceService.js new file mode 100644 index 0000000000000000000000000000000000000000..5c234b1598cd515b22b6d697b368d6c8f637d070 --- /dev/null +++ b/src/backend/src/modules/puterfs/ResourceService.js @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); +const { + NodePathSelector, + NodeUIDSelector, + NodeInternalIDSelector, + NodeChildSelector, +} = require('../../filesystem/node/selectors'); + +const RESOURCE_STATUS_PENDING_CREATE = {}; +const RESOURCE_STATUS_PENDING_UPDATE = {}; +const RS_DIRECTORY_PENDING_CHILD_INSERT = {}; + +/** + * ResourceService is a very simple locking mechanism meant + * only to ensure consistency between requests being sent + * to the same server. + * + * For example, if you send an HTTP request to `/write`, and + * then a subsequent HTTP request to `/read`, you would expect + * the newly written file to be available. Therefore, the call + * to `/read` should wait until the write is complete. + * + * At least for now; I'm sure we'll think of a smarter way to + * handle this in the future. + */ +class ResourceService extends BaseService { + _construct () { + this.uidToEntry = {}; + this.uidToPath = {}; + this.pathToEntry = {}; + } + + register (entry) { + entry = { ...entry }; + + if ( ! entry.uid ) { + // TODO: resource service needs logger access + return; + } + + entry.freePromise = new Promise((resolve, reject) => { + entry.free = () => { + resolve(); + }; + }); + entry.onFree = entry.freePromise.then.bind(entry.freePromise); + this.log.debug('registering resource', { uid: entry.uid }); + this.uidToEntry[entry.uid] = entry; + if ( entry.path ) { + this.uidToPath[entry.uid] = entry.path; + this.pathToEntry[entry.path] = entry; + } + return entry; + } + + free (uid) { + this.log.debug('freeing', { uid }); + const entry = this.uidToEntry[uid]; + if ( ! entry ) return; + delete this.uidToEntry[uid]; + if ( this.uidToPath.hasOwnProperty(uid) ) { + const path = this.uidToPath[uid]; + delete this.pathToEntry[path]; + delete this.uidToPath[uid]; + } + entry.free(); + } + + async waitForResourceByPath (path) { + const entry = this.pathToEntry[path]; + if ( ! entry ) { + return; + } + await entry.freePromise; + } + + async waitForResourceByUID (uid) { + const entry = this.uidToEntry[uid]; + if ( ! entry ) { + return; + } + await entry.freePromise; + } + + async waitForResource (selector) { + if ( selector instanceof NodePathSelector ) { + await this.waitForResourceByPath(selector.value); + } + else + if ( selector instanceof NodeUIDSelector ) { + await this.waitForResourceByUID(selector.value); + } + else + if ( selector instanceof NodeInternalIDSelector ) { + // Can't wait intelligently for this + } + if ( selector instanceof NodeChildSelector ) { + await this.waitForResource(selector.parent); + } + } + + getResourceInfo (uid) { + if ( ! uid ) return; + return this.uidToEntry[uid]; + } +} + +module.exports = { + ResourceService, + RESOURCE_STATUS_PENDING_CREATE, + RESOURCE_STATUS_PENDING_UPDATE, + RS_DIRECTORY_PENDING_CHILD_INSERT, +}; diff --git a/src/backend/src/modules/puterfs/SizeService.js b/src/backend/src/modules/puterfs/SizeService.js new file mode 100644 index 0000000000000000000000000000000000000000..8bb5b41923cf244c3375e1d12f7702c3b50f0c0c --- /dev/null +++ b/src/backend/src/modules/puterfs/SizeService.js @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { get_dir_size, id2path, get_user, invalidate_cached_user_by_id } = require('../../helpers'); +const BaseService = require('../../services/BaseService'); +const { DB_WRITE } = require('../../services/database/consts'); + +// TODO: expose to a utility library +class UserParameter { + static async adapt (value) { + if ( typeof value == 'object' ) return value; + const query_object = typeof value === 'number' + ? { id: value } + : { username: value }; + return await get_user(query_object); + } +} + +class SizeService extends BaseService { + _construct () { + this.usages = {}; + } + + _init () { + this.db = this.services.get('database').get(DB_WRITE, 'filesystem'); + + } + + ['__on_boot.consolidate'] () { + const svc_commands = this.services.get('commands'); + svc_commands.registerCommands('size', [ + { + id: 'get-usage', + description: 'get usage for a user', + handler: async (args, log) => { + const user = await UserParameter.adapt(args[0]); + const usage = await this.get_usage(user.id); + log.log(`usage: ${usage} bytes`); + }, + }, + { + id: 'get-capacity', + description: 'get storage capacity for a user', + handler: async (args, log) => { + const user = await UserParameter.adapt(args[0]); + const capacity = await this.get_storage_capacity(user); + log.log(`capacity: ${capacity} bytes`); + }, + }, + { + id: 'get-cache-size', + description: 'get the number of cached users', + handler: async (args, log) => { + const size = Object.keys(this.usages).length; + log.log(`cache size: ${size}`); + }, + }, + ]); + } + + async get_usage (user_id) { + // if ( this.usages.hasOwnProperty(user_id) ) { + // return this.usages[user_id]; + // } + + const fsentry = await this.db.read('SELECT SUM(size) AS total FROM `fsentries` WHERE `user_id` = ? LIMIT 1', + [user_id]); + if ( !fsentry[0] || !fsentry[0].total ) { + this.usages[user_id] = 0; + } else { + this.usages[user_id] = parseInt(fsentry[0].total); + } + + return this.usages[user_id]; + } + + async change_usage (user_id, delta) { + const usage = await this.get_usage(user_id); + this.usages[user_id] = usage + delta; + } + + // TODO: remove fs arg and update all calls + async add_node_size (fs, node, user, factor = 1) { + + let sz; + if ( node.entry.is_dir ) { + if ( node.entry.uuid ) { + sz = await node.fetchSize(); + } else { + // very unlikely, but a warning is better than a throw right now + // TODO: remove this once we're sure this is never hit + this.log.warn('add_node_size: node has no uuid :(', node); + sz = await get_dir_size(await id2path(node.mysql_id), user); + } + } else { + sz = node.entry.size; + } + await this.change_usage(user.id, sz * factor); + } + + async get_storage_capacity (user_or_id) { + const user = await UserParameter.adapt(user_or_id); + if ( ! this.global_config.is_storage_limited ) { + return this.global_config.available_device_storage; + } + + if ( !user.free_storage && user.free_storage !== 0 ) { + return this.global_config.storage_capacity; + } + + return user.free_storage; + } + + /** + * Attempt to add storage for a user. + + * In the case of an error, this method will fail silently to the caller and + * produce an alarm for further investigation. + * + * @param {*} user_or_id - user id, username, or user object + * @param {*} amount_in_bytes - amount of bytes to add + * @param {*} reason - please specify a reason for the storage increase + * @param {*} param3 - optional fields to add to the audit log + */ + async add_storage (user_or_id, amount_in_bytes, reason, { field_a, field_b } = {}) { + const user = await UserParameter.adapt(user_or_id); + const capacity = await this.get_storage_capacity(user); + + // Audit log + { + const entry = { + user_id: user.id, + user_id_keep: user.id, + amount: amount_in_bytes, + reason, + ...(field_a ? { field_a } : {}), + ...(field_b ? { field_b } : {}), + }; + + const fields_ = Object.keys(entry); + const fields = fields_.join(', '); + const placeholders = fields_.map(_ => '?').join(', '); + const values = fields_.map(f => entry[f]); + + try { + await this.db.write(`INSERT INTO storage_audit (${fields}) VALUES (${placeholders})`, + values); + } catch (e) { + this.errors.report('size-service.audit-add-storage', { + source: e, + trace: true, + alarm: true, + }); + } + } + + // Storage increase + { + try { + const res = await this.db.write('UPDATE `user` SET `free_storage` = ? WHERE `id` = ? LIMIT 1', + [capacity + amount_in_bytes, user.id]); + if ( ! res.anyRowsAffected ) { + throw new Error(`add_storage: failed to update user ${user.id}`); + } + } catch (e) { + this.errors.report('size-service.add-storage', { + source: e, + trace: true, + alarm: true, + }); + } + invalidate_cached_user_by_id(user.id); + } + } +} + +module.exports = { + SizeService, +}; diff --git a/src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js b/src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js new file mode 100644 index 0000000000000000000000000000000000000000..df2b57b746589796f35d58db3eeb6dfeba134667 --- /dev/null +++ b/src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js @@ -0,0 +1,615 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const FSNodeContext = require('../../../filesystem/FSNodeContext'); +const _path = require('path'); +const { Context } = require('../../../util/context'); +const { v4: uuidv4 } = require('uuid'); +const config = require('../../../config'); +const { + NodeChildSelector, + NodePathSelector, + NodeUIDSelector, + NodeRawEntrySelector, + RootNodeSelector, + try_infer_attributes, +} = require('../../../filesystem/node/selectors'); +const fsCapabilities = require('../../../filesystem/definitions/capabilities'); +const APIError = require('../../../api/APIError'); + +class MemoryFile { + /** + * @param {Object} param + * @param {string} param.path - Relative path from the mountpoint. + * @param {boolean} param.is_dir + * @param {Buffer|null} param.content - The content of the file, `null` if the file is a directory. + * @param {string|null} [param.parent_uid] - UID of parent directory; null for root. + */ + constructor ({ path, is_dir, content, parent_uid = null }) { + this.uuid = uuidv4(); + + this.is_public = true; + this.path = path; + this.name = _path.basename(path); + this.is_dir = is_dir; + + this.content = content; + + // parent_uid should reflect the actual parent's uid; null for root + this.parent_uid = parent_uid; + + // TODO (xiaochen): return sensible values for "user_id", currently + // it must be 2 (admin) to pass the test. + this.user_id = 2; + + // TODO (xiaochen): return sensible values for following fields + this.id = 123; + this.parent_id = 123; + this.immutable = 0; + this.is_shortcut = 0; + this.is_symlink = 0; + this.symlink_path = null; + this.created = Math.floor(Date.now() / 1000); + this.accessed = Math.floor(Date.now() / 1000); + this.modified = Math.floor(Date.now() / 1000); + this.size = is_dir ? 0 : content ? content.length : 0; + } +} + +class MemoryFSProvider { + constructor (mountpoint) { + this.mountpoint = mountpoint; + + // key: relative path from the mountpoint, always starts with `/` + // value: entry uuid + this.entriesByPath = new Map(); + + // key: entry uuid + // value: entry (MemoryFile) + // + // We declare 2 maps to support 2 lookup apis: by-path/by-uuid. + this.entriesByUUID = new Map(); + + const root = new MemoryFile({ + path: '/', + is_dir: true, + content: null, + parent_uid: null, + }); + this.entriesByPath.set('/', root.uuid); + this.entriesByUUID.set(root.uuid, root); + } + + /** + * Get the capabilities of this filesystem provider. + * + * @returns {Set} - Set of capabilities supported by this provider. + */ + get_capabilities () { + return new Set([ + fsCapabilities.READDIR_UUID_MODE, + fsCapabilities.UUID, + fsCapabilities.READ, + fsCapabilities.WRITE, + fsCapabilities.COPY_TREE, + ]); + } + + /** + * Normalize the path to be relative to the mountpoint. Returns `/` if the path is empty/undefined. + * + * @param {string} path - The path to normalize. + * @returns {string} - The normalized path, always starts with `/`. + */ + _inner_path (path) { + if ( ! path ) { + return '/'; + } + + if ( path.startsWith(this.mountpoint) ) { + path = path.slice(this.mountpoint.length); + } + + if ( ! path.startsWith('/') ) { + path = `/${ path}`; + } + + return path; + } + + /** + * Check the integrity of the whole memory filesystem. Throws error if any violation is found. + * + * @returns {Promise} + */ + _integrity_check () { + if ( config.env !== 'dev' ) { + // only check in debug mode since it's expensive + return; + } + + // check the 2 maps are consistent + if ( this.entriesByPath.size !== this.entriesByUUID.size ) { + throw new Error('Path map and UUID map have different sizes'); + } + + for ( const [inner_path, uuid] of this.entriesByPath ) { + const entry = this.entriesByUUID.get(uuid); + + // entry should exist + if ( ! entry ) { + throw new Error(`Entry ${uuid} does not exist`); + } + + // path should match + if ( this._inner_path(entry.path) !== inner_path ) { + throw new Error(`Path ${inner_path} does not match entry ${uuid}`); + } + + // uuid should match + if ( entry.uuid !== uuid ) { + throw new Error(`UUID ${uuid} does not match entry ${entry.uuid}`); + } + + // parent should exist + if ( entry.parent_uid ) { + const parent_entry = this.entriesByUUID.get(entry.parent_uid); + if ( ! parent_entry ) { + throw new Error(`Parent ${entry.parent_uid} does not exist`); + } + } + + // parent's path should be a prefix of the entry's path + if ( entry.parent_uid ) { + const parent_entry = this.entriesByUUID.get(entry.parent_uid); + if ( ! entry.path.startsWith(parent_entry.path) ) { + throw new Error(`Parent ${entry.parent_uid} path ${parent_entry.path} is not a prefix of entry ${entry.path}`); + } + } + + // parent should be a directory + if ( entry.parent_uid ) { + const parent_entry = this.entriesByUUID.get(entry.parent_uid); + if ( ! parent_entry.is_dir ) { + throw new Error(`Parent ${entry.parent_uid} is not a directory`); + } + } + } + } + + /** + * Check if a given node exists. + * + * @param {Object} param + * @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} param.selector - The selector used for checking. + * @returns {Promise} - True if the node exists, false otherwise. + */ + async quick_check ({ selector }) { + if ( selector instanceof NodePathSelector ) { + const inner_path = this._inner_path(selector.value); + return this.entriesByPath.has(inner_path); + } + + if ( selector instanceof NodeUIDSelector ) { + return this.entriesByUUID.has(selector.value); + } + + // fallback to stat + const entry = await this.stat({ selector }); + return !!entry; + } + + /** + * Performs a stat operation using the given selector. + * + * NB: Some returned fields currently contain placeholder values. And the + * `path` of the absolute path from the root. + * + * @param {Object} param + * @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} param.selector - The selector to stat. + * @returns {Promise} - The result of the stat operation, or `null` if the node doesn't exist. + */ + async stat ({ selector }) { + try_infer_attributes(selector); + + let entry_uuid = null; + + if ( selector instanceof NodePathSelector ) { + // stat by path + const inner_path = this._inner_path(selector.value); + entry_uuid = this.entriesByPath.get(inner_path); + } else if ( selector instanceof NodeUIDSelector ) { + // stat by uid + entry_uuid = selector.value; + } else if ( selector instanceof NodeChildSelector ) { + if ( selector.path ) { + // Shouldn't care about about parent when the "path" is present + // since it might have different provider. + return await this.stat({ + selector: new NodePathSelector(selector.path), + }); + } else { + // recursively stat the parent and then stat the child + const parent_entry = await this.stat({ + selector: selector.parent, + }); + if ( parent_entry ) { + const full_path = _path.join(parent_entry.path, selector.name); + return await this.stat({ + selector: new NodePathSelector(full_path), + }); + } + } + } else { + // other selectors shouldn't reach here, i.e., it's an internal logic error + throw APIError.create('invalid_node'); + } + + const entry = this.entriesByUUID.get(entry_uuid); + if ( ! entry ) { + return null; + } + + // Return a copied entry with `full_path`, since external code only cares + // about full path. + const copied_entry = { ...entry }; + copied_entry.path = _path.join(this.mountpoint, entry.path); + return copied_entry; + } + + /** + * Read directory contents. + * + * @param {Object} param + * @param {Context} param.context - The context of the operation. + * @param {FSNodeContext} param.node - The directory node to read. + * @returns {Promise} - Array of child UUIDs. + */ + async readdir ({ context, node }) { + // prerequistes: get required path via stat + const entry = await this.stat({ selector: node.selector }); + if ( ! entry ) { + throw APIError.create('invalid_node'); + } + + const inner_path = this._inner_path(entry.path); + const child_uuids = []; + + // Find all entries that are direct children of this directory + for ( const [path, uuid] of this.entriesByPath ) { + if ( path === inner_path ) { + continue; // Skip the directory itself + } + + const dirname = _path.dirname(path); + if ( dirname === inner_path ) { + child_uuids.push(uuid); + } + } + + return child_uuids; + } + + /** + * Create a new directory. + * + * @param {Object} param + * @param {Context} param.context - The context of the operation. + * @param {FSNodeContext} param.parent - The parent node to create the directory in. Must exist and be a directory. + * @param {string} param.name - The name of the new directory. + * @returns {Promise} - The new directory node. + */ + async mkdir ({ context, parent, name }) { + // prerequistes: get required path via stat + const parent_entry = await this.stat({ selector: parent.selector }); + if ( ! parent_entry ) { + throw APIError.create('invalid_node'); + } + + const full_path = _path.join(parent_entry.path, name); + const inner_path = this._inner_path(full_path); + + let entry = null; + if ( this.entriesByPath.has(inner_path) ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: full_path, + }); + } else { + entry = new MemoryFile({ + path: inner_path, + is_dir: true, + content: null, + parent_uid: parent_entry.uuid, + }); + this.entriesByPath.set(inner_path, entry.uuid); + this.entriesByUUID.set(entry.uuid, entry); + } + + // create the node + const fs = context.get('services').get('filesystem'); + const node = await fs.node(new NodeUIDSelector(entry.uuid)); + await node.fetchEntry(); + + this._integrity_check(); + + return node; + } + + /** + * Remove a directory. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The directory to remove. + * @param {Object} param.options: The options for the operation. + * @returns {Promise} + */ + async rmdir ({ context, node, options = {} }) { + this._integrity_check(); + + // prerequistes: get required path via stat + const entry = await this.stat({ selector: node.selector }); + if ( ! entry ) { + throw APIError.create('invalid_node'); + } + + const inner_path = this._inner_path(entry.path); + + // for mode: non-recursive + if ( ! options.recursive ) { + const children = await this.readdir({ context, node }); + if ( children.length > 0 ) { + throw APIError.create('not_empty'); + } + } + + // remove all descendants + for ( const [other_inner_path, other_entry_uuid] of this.entriesByPath ) { + if ( other_entry_uuid === entry.uuid ) { + // skip the directory itself + continue; + } + + if ( other_inner_path.startsWith(inner_path) ) { + this.entriesByPath.delete(other_inner_path); + this.entriesByUUID.delete(other_entry_uuid); + } + } + + // for mode: non-descendants-only + if ( ! options.descendants_only ) { + // remove the directory itself + this.entriesByPath.delete(inner_path); + this.entriesByUUID.delete(entry.uuid); + } + + this._integrity_check(); + } + + /** + * Remove a file. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The file to remove. + * @returns {Promise} + */ + async unlink ({ context, node }) { + // prerequistes: get required path via stat + const entry = await this.stat({ selector: node.selector }); + if ( ! entry ) { + throw APIError.create('invalid_node'); + } + + const inner_path = this._inner_path(entry.path); + this.entriesByPath.delete(inner_path); + this.entriesByUUID.delete(entry.uuid); + } + + /** + * Move a file. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The file to move. + * @param {FSNodeContext} param.new_parent: The new parent directory of the file. + * @param {string} param.new_name: The new name of the file. + * @param {Object} param.metadata: The metadata of the file. + * @returns {Promise} + */ + async move ({ context, node, new_parent, new_name, metadata }) { + // prerequistes: get required path via stat + const new_parent_entry = await this.stat({ selector: new_parent.selector }); + if ( ! new_parent_entry ) { + throw APIError.create('invalid_node'); + } + + // create the new entry + const new_full_path = _path.join(new_parent_entry.path, new_name); + const new_inner_path = this._inner_path(new_full_path); + const entry = new MemoryFile({ + path: new_inner_path, + is_dir: node.entry.is_dir, + content: node.entry.content, + parent_uid: new_parent_entry.uuid, + }); + entry.uuid = node.entry.uuid; + this.entriesByPath.set(new_inner_path, entry.uuid); + this.entriesByUUID.set(entry.uuid, entry); + + // remove the old entry + const inner_path = this._inner_path(node.path); + this.entriesByPath.delete(inner_path); + // NB: should not delete the entry by uuid because uuid does not change + // after the move. + + this._integrity_check(); + + return entry; + } + + /** + * Copy a tree of files and directories. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.source - The source node to copy. + * @param {FSNodeContext} param.parent - The parent directory for the copy. + * @param {string} param.target_name - The name for the copied item. + * @returns {Promise} - The copied node. + */ + async copy_tree ({ context, source, parent, target_name }) { + const fs = context.get('services').get('filesystem'); + + if ( source.entry.is_dir ) { + // Create the directory + const new_dir = await this.mkdir({ context, parent, name: target_name }); + + // Copy all children + const children = await this.readdir({ context, node: source }); + for ( const child_uuid of children ) { + const child_node = await fs.node(new NodeUIDSelector(child_uuid)); + await child_node.fetchEntry(); + const child_name = child_node.entry.name; + + await this.copy_tree({ + context, + source: child_node, + parent: new_dir, + target_name: child_name, + }); + } + + return new_dir; + } else { + // Copy the file + const new_file = await this.write_new({ + context, + parent, + name: target_name, + file: { stream: { read: () => source.entry.content } }, + }); + return new_file; + } + } + + /** + * Write a new file to the filesystem. Throws an error if the destination + * already exists. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.parent: The parent directory of the destination directory. + * @param {string} param.name: The name of the destination directory. + * @param {Object} param.file: The file to write. + * @returns {Promise} + */ + async write_new ({ context, parent, name, file }) { + // prerequistes: get required path via stat + const parent_entry = await this.stat({ selector: parent.selector }); + if ( ! parent_entry ) { + throw APIError.create('invalid_node'); + } + const full_path = _path.join(parent_entry.path, name); + const inner_path = this._inner_path(full_path); + + let entry = null; + if ( this.entriesByPath.has(inner_path) ) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: full_path, + }); + } else { + entry = new MemoryFile({ + path: inner_path, + is_dir: false, + content: file.stream.read(), + parent_uid: parent_entry.uuid, + }); + this.entriesByPath.set(inner_path, entry.uuid); + this.entriesByUUID.set(entry.uuid, entry); + } + + const fs = context.get('services').get('filesystem'); + const node = await fs.node(new NodeUIDSelector(entry.uuid)); + await node.fetchEntry(); + + this._integrity_check(); + + return node; + } + + /** + * Overwrite an existing file. Throws an error if the destination does not + * exist. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The node to write to. + * @param {Object} param.file: The file to write. + * @returns {Promise} + */ + async write_overwrite ({ context, node, file }) { + const entry = await this.stat({ selector: node.selector }); + if ( ! entry ) { + throw APIError.create('invalid_node'); + } + const inner_path = this._inner_path(entry.path); + + this.entriesByPath.set(inner_path, entry.uuid); + let original_entry = this.entriesByUUID.get(entry.uuid); + if ( ! original_entry ) { + throw new Error(`File ${entry.path} does not exist`); + } else { + if ( original_entry.is_dir ) { + throw new Error('Cannot overwrite a directory'); + } + + original_entry.content = file.stream.read(); + original_entry.modified = Math.floor(Date.now() / 1000); + original_entry.size = original_entry.content ? original_entry.content.length : 0; + this.entriesByUUID.set(entry.uuid, original_entry); + } + + const fs = context.get('services').get('filesystem'); + node = await fs.node(new NodeUIDSelector(original_entry.uuid)); + await node.fetchEntry(); + + this._integrity_check(); + + return node; + } + + async read ({ + context, + node, + }) { + // TODO: once MemoryFS aggregates its own storage, don't get it + // via mountpoint service. + const svc_mountpoint = context.get('services').get('mountpoint'); + const storage = svc_mountpoint.get_storage(this.constructor.name); + const stream = (await storage.create_read_stream(await node.get('uid'), { + memory_file: node.entry, + })); + return stream; + } +} + +module.exports = { + MemoryFSProvider, +}; diff --git a/src/backend/src/modules/puterfs/customfs/MemoryFSService.js b/src/backend/src/modules/puterfs/customfs/MemoryFSService.js new file mode 100644 index 0000000000000000000000000000000000000000..a7cc3d9872c2871e1e675c7b7af884ef793a25a2 --- /dev/null +++ b/src/backend/src/modules/puterfs/customfs/MemoryFSService.js @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../../services/BaseService'); +const { MemoryFSProvider } = require('./MemoryFSProvider'); + +class MemoryFSService extends BaseService { + async _init () { + const svc_mountpoint = this.services.get('mountpoint'); + svc_mountpoint.register_mounter('memoryfs', this.as('mounter')); + } + + static IMPLEMENTS = { + mounter: { + async mount ({ path, options }) { + const provider = new MemoryFSProvider(path); + return provider; + }, + }, + }; +} + +module.exports = { + MemoryFSService, +}; \ No newline at end of file diff --git a/src/backend/src/modules/puterfs/customfs/README.md b/src/backend/src/modules/puterfs/customfs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a05a7733451e13971d805659c2e7f8d3219b1abf --- /dev/null +++ b/src/backend/src/modules/puterfs/customfs/README.md @@ -0,0 +1,15 @@ +# Custom FS Providers + +This directory contains custom FS providers that are not part of the core PuterFS. + +## MemoryFSProvider + +This is a demo FS provider that illustrates how to implement a custom FS provider. + +## NullFSProvider + +A FS provider that mimics `/dev/null`. + +## LinuxFSProvider + +Provide the ability to mount a Linux directory as a FS provider. \ No newline at end of file diff --git a/src/backend/src/modules/selfhosted/ComplainAboutVersionsService.js b/src/backend/src/modules/selfhosted/ComplainAboutVersionsService.js new file mode 100644 index 0000000000000000000000000000000000000000..bd4c360b35f6a3b8b42b4daa41b00fe5442daef8 --- /dev/null +++ b/src/backend/src/modules/selfhosted/ComplainAboutVersionsService.js @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); + +class ComplainAboutVersionsService extends BaseService { + static DESCRIPTION = ` + This service doesn't mandate a specific version of node.js, + but it will complain (create a sticky notification in the + dev console) if you're using something that's past EOL. + + This is mostly just for fun, because it feels cool when the + system calls people out for using old versions of node. + That said, maybe one day we'll come across some nuanced error + that only happens an a recently EOL'd node version. + `; + + static MODULES = { + axios: require('axios'), + }; + + async _init () { + const eol_data = await this.get_eol_data_(); + + const [major] = process.versions.node.split('.'); + const current_version_data = eol_data.find(({ cycle }) => cycle === major); + + if ( ! current_version_data ) { + this.log.warn(`failed to check ${major} in the EOL database`); + return; + } + + const eol_date = new Date(current_version_data.eol); + const cur_date_obj = new Date(); + + if ( cur_date_obj < eol_date ) { + this.log.debug('node.js version looks good'); + return; + } + + let timeago = (() => { + let years = cur_date_obj.getFullYear() - eol_date.getFullYear(); + let months = cur_date_obj.getMonth() - eol_date.getMonth(); + + let str = ''; + while ( years > 0 ) { + years -= 1; + months += 12; + } + if ( months > 0 ) { + str += `at least ${months} month${months > 1 ? 's' : ''}`; + } else { + str += 'a few days'; + } + return str; + })(); + + this.log.warn(`Node.js version ${major} is past EOL by ${timeago}`); + } + + async get_eol_data_ () { + const require = this.require; + const axios = require('axios'); + const url = 'https://endoflife.date/api/nodejs.json'; + let data; + try { + ({ data } = await axios.get(url)); + return data; + } catch (e) { + this.log.error(e); + return []; + } + } +} + +module.exports = ComplainAboutVersionsService; diff --git a/src/backend/src/modules/selfhosted/DefaultUserService.js b/src/backend/src/modules/selfhosted/DefaultUserService.js new file mode 100644 index 0000000000000000000000000000000000000000..dd12fd9822130b4a784dfb82362b4afb76158757 --- /dev/null +++ b/src/backend/src/modules/selfhosted/DefaultUserService.js @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { QuickMkdir } = require('../../filesystem/hl_operations/hl_mkdir'); +const { HLWrite } = require('../../filesystem/hl_operations/hl_write'); +const { NodePathSelector } = require('../../filesystem/node/selectors'); +const { get_user, invalidate_cached_user } = require('../../helpers'); +const { Context } = require('../../util/context'); +const { buffer_to_stream } = require('../../util/streamutil'); +const BaseService = require('../../services/BaseService'); +const { Actor, UserActorType } = require('../../services/auth/Actor'); +const { DB_WRITE } = require('../../services/database/consts'); +const { quot } = require('@heyputer/putility').libs.string; +const bcrypt = require('bcrypt'); +const uuidv4 = require('uuid').v4; +const crypto = require('crypto'); + +const USERNAME = 'admin'; + +const DEFAULT_FILES = { + '.policy': { + 'drivers.json': JSON.stringify({ + 'temp': { + 'kv': { + 'rate-limit': { + 'max': 1000, + 'period': 30000, + }, + }, + 'es': { + 'rate-limit': { + 'max': 1000, + 'period': 30000, + }, + }, + }, + 'user': { + 'kv': { + 'rate-limit': { + 'max': 3000, + 'period': 30000, + }, + }, + 'es': { + 'rate-limit': { + 'max': 3000, + 'period': 30000, + }, + }, + }, + }, undefined, ' '), + }, +}; + +class DefaultUserService extends BaseService { + async _init () { + this._register_commands(this.services.get('commands')); + } + async ['__on_ready.webserver'] () { + // check if a user named `admin` exists + let user = await get_user({ username: USERNAME, cached: false }); + if ( ! user ) { + user = await this.create_default_user_(); + } else { + await this.#createDefaultUserFiles(Actor.adapt(user)); + } + + // check if user named `admin` is using default password + const tmp_password = await this.get_tmp_password_(user); + const is_default_password = await bcrypt.compare(tmp_password, + user.password); + if ( ! is_default_password ) return; + + // console.log(`password for admin is: ${tmp_password}`); + // NB: this is needed for the CI to extract the password + console.log(`password for admin is: ${tmp_password}`); + + const realConsole = globalThis.original_console_object ?? console; + realConsole.log('\n************************************************************'); + realConsole.log('* Your default login credentials are:'); + realConsole.log(`* Username: admin`); + realConsole.log(`* Password: ${tmp_password}`); + realConsole.log('* (change the password to remove this message)'); + realConsole.log('************************************************************\n'); + } + async create_default_user_ () { + const db = this.services.get('database').get(DB_WRITE, USERNAME); + await db.write(` + INSERT INTO user (uuid, username, free_storage) + VALUES (?, ?, ?) + `, + [ + uuidv4(), + USERNAME, + 1024 * 1024 * 1024 * 10, // 10 GB + ]); + const svc_group = this.services.get('group'); + await svc_group.add_users({ + uid: 'ca342a5e-b13d-4dee-9048-58b11a57cc55', // admin + users: [USERNAME], + }); + const user = await get_user({ username: USERNAME, cached: false }); + const actor = Actor.adapt(user); + const tmp_password = await this.get_tmp_password_(user); + const password_hashed = await bcrypt.hash(tmp_password, 8); + await db.write('UPDATE user SET password = ? WHERE id = ?', + [ + password_hashed, + user.id, + ]); + user.password = password_hashed; + const svc_user = this.services.get('user'); + await svc_user.generate_default_fsentries({ user }); + // generate default files for admin user + + await this.#createDefaultUserFiles(actor); + + invalidate_cached_user(user); + await new Promise(rslv => setTimeout(rslv, 2000)); + return user; + } + + async #recursiveCreateDefaultFilesIfMissing ({ components, tree, actor }) { + const svc_fs = this.services.get('filesystem'); + + const parent = await svc_fs.node(new NodePathSelector(`/${components.join('/')}`)); + for ( const k in tree ) { + + if ( typeof tree[k] === 'string' ) { + try { + const buffer = Buffer.from(tree[k], 'utf-8'); + const hl_write = new HLWrite(); + await hl_write.run({ + destination_or_parent: parent, + specified_name: k, + file: { + size: buffer.length, + stream: buffer_to_stream(buffer), + }, + actor, + }); + } catch (e) { + if ( e.message.includes('already exists.') ) { + // ignore + } else { + // throw if it actually fails to create the files + throw e; + } + } + } else { + try { + const hl_qmkdir = new QuickMkdir(); + await hl_qmkdir.run({ + parent, + path: k, + actor, + }); + } catch (e) { + if ( e.message.includes('already exists.') ) { + // ignore + } else { + // throw if it actually fails to create the files + throw e; + } + } + const components_ = [...components, k]; + await this.#recursiveCreateDefaultFilesIfMissing({ + components: components_, + tree: tree[k], + actor, + }); + } + + } + }; + async #createDefaultUserFiles (actor) { + await this.services.get('su').sudo(actor, async () => { + await this.#recursiveCreateDefaultFilesIfMissing({ + components: ['admin'], + tree: DEFAULT_FILES, + actor, + }); + }); + + } + async get_tmp_password_ (user) { + const actor = await Actor.create(UserActorType, { user }); + return await Context.get().sub({ actor }).arun(async () => { + const svc_driver = this.services.get('driver'); + const driver_response = await svc_driver.call({ + iface: 'puter-kvstore', + method: 'get', + args: { key: 'tmp_password' }, + }); + + if ( driver_response.result ) return driver_response.result; + + const tmp_password = crypto.randomBytes(4).toString('hex'); + await svc_driver.call({ + iface: 'puter-kvstore', + method: 'set', + args: { + key: 'tmp_password', + value: tmp_password, + }, + }); + return tmp_password; + }); + } + async force_tmp_password_ (user) { + const db = this.services.get('database') + .get(DB_WRITE, 'terminal-password-reset'); + const actor = await Actor.create(UserActorType, { user }); + return await Context.get().sub({ actor }).arun(async () => { + const svc_driver = this.services.get('driver'); + const tmp_password = crypto.randomBytes(4).toString('hex'); + const password_hashed = await bcrypt.hash(tmp_password, 8); + await svc_driver.call({ + iface: 'puter-kvstore', + method: 'set', + args: { + key: 'tmp_password', + value: tmp_password, + }, + }); + await db.write('UPDATE user SET password = ? WHERE id = ?', + [ + password_hashed, + user.id, + ]); + return tmp_password; + }); + } + _register_commands (commands) { + commands.registerCommands('default-user', [ + { + id: 'reset-password', + handler: async (args, ctx) => { + const [ username ] = args; + const user = await get_user({ username }); + const tmp_pwd = await this.force_tmp_password_(user); + ctx.log(`New password for ${quot(username)} is: ${tmp_pwd}`); + }, + }, + ]); + } +} + +module.exports = DefaultUserService; diff --git a/src/backend/src/modules/selfhosted/DevCreditService.js b/src/backend/src/modules/selfhosted/DevCreditService.js new file mode 100644 index 0000000000000000000000000000000000000000..cc906a9005598d616dd4ca49e832aadc560fedf5 --- /dev/null +++ b/src/backend/src/modules/selfhosted/DevCreditService.js @@ -0,0 +1,82 @@ +const BaseService = require('../../services/BaseService'); + +/** + * PermissiveCreditService listens to the event where DriverService asks + * for a credit context, and always provides one that allows use of + * cost-incurring services for no charge. This grants free use to + * everyone to services that incur a cost, as long as the user has + * permission to call the respective service. + */ +class PermissiveCreditService extends BaseService { + static MODULES = { + uuidv4: require('uuid').v4, + }; + _init () { + // Maps usernames to simulated credit amounts + // (used when config.simulated_credit is set) + this.simulated_credit_ = {}; + + const svc_event = this.services.get('event'); + svc_event.on('credit.check-available', (_, event) => { + const username = event.actor.type.user.username; + event.available = this.get_user_credit_(username); + + // Useful for testing with Dall-E + // event.available = 4 * Math.pow(10,6); + + // Useful for testing with Polly + // event.available = 9000; + + // Useful for testing judge0 + // event.available = 50_000; + // event.avaialble = 49_999; + + // Useful for testing ConvertAPI + // event.available = 4_500_000; + // event.available = 4_499_999; + + // Useful for testing with textract + // event.available = 150_000; + // event.available = 149_999; + }); + + svc_event.on('usages.query', (_, event) => { + const username = event.actor.type.user.username; + if ( ! this.config.simulated_credit ) { + event.usages.push({ + id: 'dev-credit', + name: 'Unlimited Credit', + used: 0, + available: 1, + }); + return; + } + event.usages.push({ + id: 'dev-credit', + name: `Simulated Credit (${this.config.simulated_credit})`, + used: this.config.simulated_credit - + this.get_user_credit_(username), + available: this.config.simulated_credit, + }); + }); + } + get_user_credit_ (username) { + if ( ! this.config.simulated_credit ) { + return Number.MAX_SAFE_INTEGER; + } + + return this.simulated_credit_[username] ?? + (this.simulated_credit_[username] = this.config.simulated_credit); + + } + consume_user_credit_ (username, amount) { + if ( ! this.config.simulated_credit ) return; + + if ( ! this.simulated_credit_[username] ) { + this.simulated_credit_[username] = this.config.simulated_credit; + } + this.simulated_credit_[username] -= amount; + } +} + +module.exports = PermissiveCreditService; diff --git a/src/backend/src/modules/selfhosted/DevWatcherService.js b/src/backend/src/modules/selfhosted/DevWatcherService.js new file mode 100644 index 0000000000000000000000000000000000000000..3b449ef3f9a48a5fd0cd0709868f12a35ad1e8df --- /dev/null +++ b/src/backend/src/modules/selfhosted/DevWatcherService.js @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { webpack, web } = require('webpack'); +const BaseService = require('../../services/BaseService'); + +const path_ = require('node:path'); +const fs = require('node:fs'); + +class ProxyLogger { + constructor (log) { + this.log = log; + } + attach (stream) { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString(); + let lineEndIndex = buffer.indexOf('\n'); + while ( lineEndIndex !== -1 ) { + const line = buffer.substring(0, lineEndIndex); + this.log(line); + buffer = buffer.substring(lineEndIndex + 1); + lineEndIndex = buffer.indexOf('\n'); + } + }); + + stream.on('end', () => { + if ( buffer.length ) { + this.log(buffer); + } + }); + } +} + +/** + * @description + * This service is used to run webpack watchers. + */ +class DevWatcherService extends BaseService { + static MODULES = { + path: require('path'), + spawn: require('child_process').spawn, + }; + + async _init (args) { + this.args = args; + } + + // Oh geez we need to wait for the web server to initialize + // so that `config.origin` has the actual port in it if the + // port is set to `auto` - you have no idea how confusing + // this was to debug the first time, like Ahhhhhh!! + // but hey at least we have this convenient event listener. + async ['__on_ready.webserver'] () { + const svc_process = this.services.get('process'); + + let { root, commands, webpack } = this.args; + if ( ! webpack ) webpack = []; + + let promises = []; + for ( const entry of commands ) { + const { directory } = entry; + const fullpath = this.modules.path.join(root, directory); + // promises.push(this.start_({ ...entry, fullpath })); + promises.push(svc_process.start({ ...entry, fullpath })); + } + for ( const entry of webpack ) { + const p = this.start_a_webpack_watcher_(entry); + promises.push(p); + } + await Promise.all(promises); + + // It's difficult to tell when webpack is "done" its first + // run so we just wait a bit before we say we're ready. + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + + async get_configjs ({ directory, configIsFor, possibleConfigNames }) { + let configjsPath, moduleType; + + for ( const [configName, supposedModuleType] of possibleConfigNames ) { + // There isn't really an async fs.exists() funciton. I assume this + // is because 'exists' is already a very fast operation. + const supposedPath = path_.join(this.args.root, directory, configName); + if ( fs.existsSync(supposedPath) ) { + configjsPath = supposedPath; + moduleType = supposedModuleType; + break; + } + } + + if ( ! configjsPath ) { + throw new Error(`could not find ${configIsFor} config for: ${directory}`); + } + + // If the webpack config ends with .js it could be an ES6 module or a + // CJS module, so the absolute safest thing to do so as not to completely + // break in specific patch version of supported versions of node.js is + // to read the package.json and see what it says is the import mechanism. + if ( moduleType === 'package.json' ) { + const packageJSONPath = path_.join(this.args.root, directory, 'package.json'); + const packageJSONObject = JSON.parse(fs.readFileSync(packageJSONPath)); + moduleType = packageJSONObject?.type ?? 'module'; + } + + return { + configjsPath, + moduleType, + }; + } + + async start_a_webpack_watcher_ (entry) { + const possibleConfigNames = [ + ['webpack.config.js', 'package.json'], + ['webpack.config.cjs', 'commonjs'], + ['webpack.config.mjs', 'module'], + ]; + + const { + configjsPath: webpackConfigPath, + moduleType, + } = await this.get_configjs({ + directory: entry.directory, + configIsFor: 'webpack', // for error message + possibleConfigNames, + }); + + let oldEnv; + + if ( entry.env ) { + oldEnv = process.env; + const newEnv = Object.create(process.env); + let global_config = null; + try { + const svc_config = this.services.get('config'); + global_config = svc_config ? svc_config.get('global_config') : null; + } catch (e) { + // Config service not available yet, will use null + } + + for ( const k in entry.env ) { + const envValue = entry.env[k]; + // If it's a function, call it with the config, otherwise use the value directly + if ( typeof envValue === 'function' ) { + try { + const result = envValue({ global_config: global_config }); + // Only set the env var if we got a non-empty result + // This allows the webpack config to use its fallback values + if ( result ) { + newEnv[k] = result; + } + } catch (e) { + // If config is not available yet, don't set the env var + // This allows the webpack config to use its fallback values from config files + // Only log if it's not a null/undefined access error (which is expected) + if ( !e.message.includes('Cannot read properties of null') && + !e.message.includes('Cannot read properties of undefined') ) { + this.log.warn(`Could not evaluate env function for ${k}: ${e.message}`); + } + } + } else { + newEnv[k] = envValue; + } + } + process.env = newEnv; // Yep, it totally lets us do this + } + let webpackConfig = moduleType === 'module' + ? (await import(webpackConfigPath)).default + : require(webpackConfigPath); + + // The webpack config can sometimes be a function + if ( typeof webpackConfig === 'function' ) { + webpackConfig = await webpackConfig(); + } + + if ( oldEnv ) process.env = oldEnv; + + webpackConfig.context = webpackConfig.context + ? path_.resolve(path_.join(this.args.root, entry.directory), webpackConfig.context) + : path_.join(this.args.root, entry.directory); + + if ( entry.onConfig ) entry.onConfig(webpackConfig); + + const webpacker = webpack(webpackConfig); + + let errorAfterLastEnd = false; + let firstEvent = true; + webpacker.watch({}, (err, stats) => { + let hideSuccess = false; + if ( firstEvent ) { + firstEvent = false; + hideSuccess = true; + } + if ( err || stats.hasErrors() ) { + // Extract error information without serializing the entire stats object + const errorInfo = { + err: err ? err.message : null, + errors: stats.compilation?.errors?.map(e => e.message) || [], + warnings: stats.compilation?.warnings?.map(w => w.message) || [], + }; + this.log.error(`error information: ${entry.directory} using Webpack`, errorInfo); + this.log.error(`❌ failed to update ${entry.directory} using Webpack`); + } else { + // Normally success messages aren't important, but sometimes it takes + // a little bit for the bundle to update so a developer probably would + // like to have a visual indication in the console when it happens. + if ( ! hideSuccess ) { + this.log.info(`✅ updated ${entry.directory} using Webpack`); + } + } + }); + } +}; + +module.exports = DevWatcherService; diff --git a/src/backend/src/modules/selfhosted/SelfHostedModule.js b/src/backend/src/modules/selfhosted/SelfHostedModule.js new file mode 100644 index 0000000000000000000000000000000000000000..ca67c2a1740a98d0632e424acee8e164aaf36215 --- /dev/null +++ b/src/backend/src/modules/selfhosted/SelfHostedModule.js @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const config = require('../../config'); + +class SelfHostedModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { SelfhostedService } = require('./SelfhostedService'); + services.registerService('__selfhosted', SelfhostedService); + + const DefaultUserService = require('./DefaultUserService'); + services.registerService('__default-user', DefaultUserService); + + const ComplainAboutVersionsService = require('./ComplainAboutVersionsService'); + services.registerService('complain-about-versions', ComplainAboutVersionsService); + + const DevWatcherService = require('./DevWatcherService'); + const path_ = require('path'); + + const DevCreditService = require('./DevCreditService'); + services.registerService('dev-credit', DevCreditService); + + const { DBKVServiceWrapper } = require('../../services/repositories/DBKVStore/index.mjs'); + services.registerService('puter-kvstore', DBKVServiceWrapper); + + // TODO: sucks + const RELATIVE_PATH = '../../../../../'; + + if ( ! config.no_devwatch ) + { + services.registerService('__dev-watcher', DevWatcherService, { + root: path_.resolve(__dirname, RELATIVE_PATH), + webpack: [ + { + name: 'puter.js', + directory: 'src/puter-js', + onConfig: config => { + config.output.filename = 'puter.dev.js'; + config.devtool = 'source-map'; + }, + env: { + PUTER_ORIGIN: ({ global_config: config }) => config?.origin || '', + PUTER_API_ORIGIN: ({ global_config: config }) => config?.api_base_url || '', + }, + }, + { + name: 'gui', + directory: 'src/gui', + }, + ], + commands: [ + ], + }); + } + + const { ServeStaticFilesService } = require('./ServeStaticFilesService'); + services.registerService('__serve-puterjs', ServeStaticFilesService, { + directories: [ + { + prefix: '/sdk', + path: path_.resolve(__dirname, RELATIVE_PATH, 'src/puter-js/dist'), + }, + { + prefix: '/builtin/git', + path: path_.resolve(__dirname, RELATIVE_PATH, 'src/git/dist'), + }, + { + prefix: '/builtin/dev-center', + path: path_.resolve(__dirname, RELATIVE_PATH, 'src/dev-center'), + }, + { + prefix: '/builtin/dev-center', + path: path_.resolve(__dirname, RELATIVE_PATH, 'src/dev-center'), + }, + { + prefix: '/vendor/v86/bios', + path: path_.resolve(__dirname, RELATIVE_PATH, 'submodules/v86/bios'), + }, + { + prefix: '/vendor/v86', + path: path_.resolve(__dirname, RELATIVE_PATH, 'submodules/v86/build'), + }, + ], + }); + + const { ServeSingleFileService } = require('./ServeSingeFileService'); + services.registerService('__serve-puterjs-new', ServeSingleFileService, { + path: path_.resolve(__dirname, + RELATIVE_PATH, + 'src/puter-js/dist/puter.dev.js'), + route: '/puter.js/v2', + }); + services.registerService('__serve-putilityjs-new', ServeSingleFileService, { + path: path_.resolve(__dirname, + RELATIVE_PATH, + 'src/putility/dist/putility.dev.js'), + route: '/putility.js/v1', + }); + services.registerService('__serve-gui-js', ServeSingleFileService, { + path: path_.resolve(__dirname, + RELATIVE_PATH, + 'src/gui/dist/gui.dev.js'), + route: '/putility.js/v1', + }); + } +} + +module.exports = SelfHostedModule; diff --git a/src/backend/src/modules/selfhosted/SelfhostedService.js b/src/backend/src/modules/selfhosted/SelfhostedService.js new file mode 100644 index 0000000000000000000000000000000000000000..39be0543bd132f117957f7d3c45e5225fe9722c4 --- /dev/null +++ b/src/backend/src/modules/selfhosted/SelfhostedService.js @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { Actor } = require('../../services/auth/Actor'); +const BaseService = require('../../services/BaseService'); +const { DB_WRITE } = require('../../services/database/consts'); +const { Context } = require('../../util/context'); + +class SelfhostedService extends BaseService { + static description = ` + Registers drivers for self-hosted Puter instances. + `; + + async _init () { + this._register_commands(this.services.get('commands')); + } + + _register_commands (commands) { + const db = this.services.get('database').get(DB_WRITE, 'selfhosted'); + commands.registerCommands('app', [ + { + id: 'godmode-on', + description: 'Toggle godmode for an app', + handler: async (args, log) => { + const svc_su = this.services.get('su'); + await await svc_su.sudo(async () => { + const [app_uid] = args; + const es_app = await this.services.get('es:app'); + const app = await es_app.read(app_uid); + if ( ! app ) { + throw new Error(`App ${app_uid} not found`); + } + await db.write('UPDATE apps SET godmode = 1 WHERE uid = ?', [app_uid]); + }); + }, + }, + ]); + commands.registerCommands('app', [ + { + id: 'godmode-off', + description: 'Toggle godmode for an app', + handler: async (args, log) => { + const svc_su = this.services.get('su'); + await await svc_su.sudo(async () => { + const [app_uid] = args; + const es_app = await this.services.get('es:app'); + const app = await es_app.read(app_uid); + if ( ! app ) { + throw new Error(`App ${app_uid} not found`); + } + await db.write('UPDATE apps SET godmode = 0 WHERE uid = ?', [app_uid]); + }); + }, + }, + ]); + } +} + +module.exports = { SelfhostedService }; diff --git a/src/backend/src/modules/selfhosted/ServeSingeFileService.js b/src/backend/src/modules/selfhosted/ServeSingeFileService.js new file mode 100644 index 0000000000000000000000000000000000000000..83035640e82b23e16a6a3b6a66f58d359411dd48 --- /dev/null +++ b/src/backend/src/modules/selfhosted/ServeSingeFileService.js @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); + +class ServeSingleFileService extends BaseService { + async _init (args) { + this.route = args.route; + this.path = args.path; + } + async ['__on_install.routes'] () { + const { app } = this.services.get('web-server'); + + app.get(this.route, (req, res) => { + return res.sendFile(this.path); + }); + } +} + +module.exports = { + ServeSingleFileService, +}; diff --git a/src/backend/src/modules/selfhosted/ServeStaticFilesService.js b/src/backend/src/modules/selfhosted/ServeStaticFilesService.js new file mode 100644 index 0000000000000000000000000000000000000000..1e161ddf2acd5068b10cc5fec8db229d724b184f --- /dev/null +++ b/src/backend/src/modules/selfhosted/ServeStaticFilesService.js @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const BaseService = require('../../services/BaseService'); + +class ServeStaticFilesService extends BaseService { + async _init (args) { + this.directories = args.directories; + } + + async ['__on_install.routes'] () { + const { app } = this.services.get('web-server'); + + for ( const { prefix, path } of this.directories ) { + app.use(prefix, require('express').static(path)); + } + } +} + +module.exports = { ServeStaticFilesService }; diff --git a/src/backend/src/modules/template/README.md b/src/backend/src/modules/template/README.md new file mode 100644 index 0000000000000000000000000000000000000000..28d524eccea0deae0a1048fa40ac5de73e64d940 --- /dev/null +++ b/src/backend/src/modules/template/README.md @@ -0,0 +1,56 @@ +# TemplateModule + +This is a template module that you can copy and paste to create new modules. + +This module is also included in `EssentialModules`, which means it will load +when Puter boots. If you're just testing something, you can add it here +temporarily. + +## Services + +### TemplateService + +This is a template service that you can copy and paste to create new services. +You can also add to this service temporarily to test something. + +#### Listeners + +##### `install.routes` + +TemplateService listens to this event to provide an example endpoint + +##### `boot.consolidation` + +TemplateService listens to this event to provide an example event + +##### `boot.activation` + +TemplateService listens to this event to show you that it's here + +##### `start.webserver` + +TemplateService listens to this event to show you that it's here + +## Libraries + +### hello_world + +#### Functions + +##### `hello_world` + +This is a simple function that returns a string. +You can probably guess what string it returns. + +## Notes + +### Outside Imports + +This module has external relative imports. When these are +removed it may become possible to move this module to an +extension. + +**Imports:** +- `../../util/context.js` +- `../../services/BaseService` (use.BaseService) +- `../../util/expressutil` diff --git a/src/backend/src/modules/template/TemplateModule.js b/src/backend/src/modules/template/TemplateModule.js new file mode 100644 index 0000000000000000000000000000000000000000..455cb7ac80ab59c98adf02ce0ee6b3410fb3ebe1 --- /dev/null +++ b/src/backend/src/modules/template/TemplateModule.js @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +/** + * This is a template module that you can copy and paste to create new modules. + * + * This module is also included in `EssentialModules`, which means it will load + * when Puter boots. If you're just testing something, you can add it here + * temporarily. + */ +class TemplateModule extends AdvancedBase { + async install (context) { + // === LIBS === // + const useapi = context.get('useapi'); + + const lib = require('./lib/__lib__.js'); + + // In extensions: use('workinprogress').hello_world(); + // In services classes: see TemplateService.js + useapi.def('workinprogress', lib, { assign: true }); + + useapi.def('core.context', require('../../util/context.js').Context); + + // === SERVICES === // + const services = context.get('services'); + + const { TemplateService } = require('./TemplateService.js'); + services.registerService('template-service', TemplateService); + } + +} + +module.exports = { + TemplateModule, +}; diff --git a/src/backend/src/modules/template/TemplateService.js b/src/backend/src/modules/template/TemplateService.js new file mode 100644 index 0000000000000000000000000000000000000000..089ec3b3dd76b549b5e8a180bc6e57e3ee63d66f --- /dev/null +++ b/src/backend/src/modules/template/TemplateService.js @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// TODO: import via `USE` static member +const BaseService = require('../../services/BaseService'); +const { Endpoint } = require('../../util/expressutil'); + +/** + * This is a template service that you can copy and paste to create new services. + * You can also add to this service temporarily to test something. + */ +class TemplateService extends BaseService { + static USE = { + // - Defined by lib/__lib__.js, + // - Exposed to `useapi` by TemplateModule.js + workinprogress: 'workinprogress', + }; + + _construct () { + // Use this override to initialize instance variables. + } + + async _init () { + // This is where you initialize the service and prepare + // for the consolidation phase. + this.log.info('I am the template service.'); + } + + /** + * TemplateService listens to this event to provide an example endpoint + */ + ['__on_install.routes'] (_, { app }) { + this.log.info('TemplateService get the event for installing endpoint.'); + Endpoint({ + route: '/example-endpoint', + methods: ['GET'], + handler: async (req, res) => { + res.send(this.workinprogress.hello_world()); + }, + }).attach(app); + // ^ Don't forget to attach the endpoint to the app! + // it's very easy to forget this step. + } + + /** + * TemplateService listens to this event to provide an example event + */ + ['__on_boot.consolidation'] () { + // At this stage, all services have been initialized and it is + // safe to start emitting events. + this.log.info('TemplateService sees consolidation boot phase.'); + + const svc_event = this.services.get('event'); + + svc_event.on('template-service.hello', (_eventid, event_data) => { + this.log.info('template-service said hello to itself; this is expected', { + event_data, + }); + }); + + svc_event.emit('template-service.hello', { + message: 'Hello all you other services! I am the template service.', + }); + } + /** + * TemplateService listens to this event to show you that it's here + */ + ['__on_boot.activation'] () { + this.log.info('TemplateService sees activation boot phase.'); + } + + /** + * TemplateService listens to this event to show you that it's here + */ + ['__on_start.webserver'] () { + this.log.info("TemplateService sees it's time to start web servers."); + } +} + +module.exports = { + TemplateService, +}; diff --git a/src/backend/src/modules/template/lib/__lib__.js b/src/backend/src/modules/template/lib/__lib__.js new file mode 100644 index 0000000000000000000000000000000000000000..dd6111e6c4877f79b296598bbba5328d842d5b7d --- /dev/null +++ b/src/backend/src/modules/template/lib/__lib__.js @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +module.exports = { + hello_world: require('./hello_world.js'), +}; diff --git a/src/backend/src/modules/template/lib/hello_world.js b/src/backend/src/modules/template/lib/hello_world.js new file mode 100644 index 0000000000000000000000000000000000000000..40d4e794751c5254f3e56a11acf983501c3a1e33 --- /dev/null +++ b/src/backend/src/modules/template/lib/hello_world.js @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * This is a simple function that returns a string. + * You can probably guess what string it returns. + */ +const hello_world = () => { + return 'Hello, world!'; +}; + +module.exports = hello_world; diff --git a/src/backend/src/modules/test-config/TestConfigModule.js b/src/backend/src/modules/test-config/TestConfigModule.js new file mode 100644 index 0000000000000000000000000000000000000000..4906f221c152966f76e0488507511cf342631b8f --- /dev/null +++ b/src/backend/src/modules/test-config/TestConfigModule.js @@ -0,0 +1,15 @@ +const { AdvancedBase } = require('@heyputer/putility'); + +class TestConfigModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + const TestConfigUpdateService = require('./TestConfigUpdateService'); + services.registerService('__test-config-update', TestConfigUpdateService); + const TestConfigReadService = require('./TestConfigReadService'); + services.registerService('__test-config-read', TestConfigReadService); + } +} + +module.exports = { + TestConfigModule, +}; diff --git a/src/backend/src/modules/test-config/TestConfigReadService.js b/src/backend/src/modules/test-config/TestConfigReadService.js new file mode 100644 index 0000000000000000000000000000000000000000..5c14f5614956246149243d75f748426f87ce269e --- /dev/null +++ b/src/backend/src/modules/test-config/TestConfigReadService.js @@ -0,0 +1,10 @@ +const BaseService = require('../../services/BaseService'); + +class TestConfigReadService extends BaseService { + async _init () { + this.log.debug(`test config value (should be abcdefg) is: ${ + this.global_config.testConfigValue}`); + } +} + +module.exports = TestConfigReadService; diff --git a/src/backend/src/modules/test-config/TestConfigUpdateService.js b/src/backend/src/modules/test-config/TestConfigUpdateService.js new file mode 100644 index 0000000000000000000000000000000000000000..ca0be4fb8df1b1048b4cf2028c9847496c961ca4 --- /dev/null +++ b/src/backend/src/modules/test-config/TestConfigUpdateService.js @@ -0,0 +1,12 @@ +const BaseService = require('../../services/BaseService'); + +class TestConfigUpdateService extends BaseService { + async _run_as_early_as_possible () { + const config = this.global_config; + config.__set_config_object__({ + testConfigValue: 'abcdefg', + }); + } +} + +module.exports = TestConfigUpdateService; diff --git a/src/backend/src/modules/test-core/TestCoreModule.js b/src/backend/src/modules/test-core/TestCoreModule.js new file mode 100644 index 0000000000000000000000000000000000000000..5e42cbc7669b857d606fafcd2216607551e59820 --- /dev/null +++ b/src/backend/src/modules/test-core/TestCoreModule.js @@ -0,0 +1,56 @@ +import { FilesystemService } from '../../filesystem/FilesystemService.js'; +import { AnomalyService } from '../../services/AnomalyService.js'; +import { AuthService } from '../../services/auth/AuthService.js'; +import { GroupService } from '../../services/auth/GroupService.js'; +import { PermissionService } from '../../services/auth/PermissionService.js'; +import { TokenService } from '../../services/auth/TokenService.js'; +import { CommandService } from '../../services/CommandService.js'; +import { SqliteDatabaseAccessService } from '../../services/database/SqliteDatabaseAccessService.js'; +import { DetailProviderService } from '../../services/DetailProviderService.js'; +import { EventService } from '../../services/EventService.js'; +import { FeatureFlagService } from '../../services/FeatureFlagService.js'; +import { GetUserService } from '../../services/GetUserService.js'; +import { InformationService } from '../../services/information/InformationService.js'; +import { MeteringServiceWrapper } from '../../services/MeteringService/MeteringServiceWrapper.mjs'; +import { NotificationService } from '../../services/NotificationService.js'; +import { RegistrantService } from '../../services/RegistrantService.js'; +import { RegistryService } from '../../services/RegistryService.js'; +import { DBKVServiceWrapper } from '../../services/repositories/DBKVStore/index.mjs'; +import { ScriptService } from '../../services/ScriptService.js'; +import { SessionService } from '../../services/SessionService.js'; +import { SUService } from '../../services/SUService.js'; +import { SystemValidationService } from '../../services/SystemValidationService.js'; +import { TraceService } from '../../services/TraceService.js'; +import { AlarmService } from '../core/AlarmService.js'; +import APIErrorService from '../web/APIErrorService.js'; + +export class TestCoreModule { + async install (context) { + const services = context.get('services'); + services.registerService('whoami', DetailProviderService); + services.registerService('get-user', GetUserService); + services.registerService('database', SqliteDatabaseAccessService); + services.registerService('traceService', TraceService); + services.registerService('su', SUService); + services.registerService('alarm', AlarmService); + services.registerService('event', EventService); + services.registerService('commands', CommandService); + services.registerService('meteringService', MeteringServiceWrapper); + services.registerService('puter-kvstore', DBKVServiceWrapper); + services.registerService('permission', PermissionService); + services.registerService('group', GroupService); + services.registerService('anomaly', AnomalyService); + services.registerService('api-error', APIErrorService); + services.registerService('system-validation', SystemValidationService); + services.registerService('registry', RegistryService); + services.registerService('__registrant', RegistrantService); + services.registerService('feature-flag', FeatureFlagService); + services.registerService('token', TokenService); + services.registerService('information', InformationService); + services.registerService('auth', AuthService); + services.registerService('session', SessionService); + services.registerService('notification', NotificationService); + services.registerService('script', ScriptService); + services.registerService('filesystem', FilesystemService); + } +} diff --git a/src/backend/src/modules/test-drivers/TestAssetHostService.js b/src/backend/src/modules/test-drivers/TestAssetHostService.js new file mode 100644 index 0000000000000000000000000000000000000000..d3a0f85a6813fcc790557be8951d3662f692b440 --- /dev/null +++ b/src/backend/src/modules/test-drivers/TestAssetHostService.js @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); + +class TestAssetHostService extends BaseService { + async ['__on_install.routes'] () { + const { app } = this.services.get('web-server'); + const path_ = require('node:path'); + + app.use('/test-assets', require('express').static( + path_.join(__dirname, 'assets'))); + } +} + +module.exports = { + TestAssetHostService, +}; diff --git a/src/backend/src/modules/test-drivers/TestDriversModule.js b/src/backend/src/modules/test-drivers/TestDriversModule.js new file mode 100644 index 0000000000000000000000000000000000000000..f199a5f33c07e48d14eea3af665b2ba413a36ff3 --- /dev/null +++ b/src/backend/src/modules/test-drivers/TestDriversModule.js @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); + +class TestDriversModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + const { TestAssetHostService } = require('./TestAssetHostService'); + services.registerService('__test-assets', TestAssetHostService); + + const { TestImageService } = require('./TestImageService'); + services.registerService('test-image', TestImageService); + } +} + +module.exports = { + TestDriversModule, +}; diff --git a/src/backend/src/modules/test-drivers/TestImageService.js b/src/backend/src/modules/test-drivers/TestImageService.js new file mode 100644 index 0000000000000000000000000000000000000000..5d08f971780955a957735a5e637e19c6aaa90f28 --- /dev/null +++ b/src/backend/src/modules/test-drivers/TestImageService.js @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const config = require('../../config'); +const BaseService = require('../../services/BaseService'); +const { TypedValue } = require('../../services/drivers/meta/Runtime'); +const { buffer_to_stream } = require('../../util/streamutil'); + +const PUBLIC_DOMAIN_IMAGES = [ + { + name: 'starry-night', + url: 'https://upload.wikimedia.org/wikipedia/commons/e/ea/Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg', + file: 'starry.jpg', + }, +]; + +class TestImageService extends BaseService { + async ['__on_driver.register.interfaces'] () { + const svc_registry = this.services.get('registry'); + const col_interfaces = svc_registry.get('interfaces'); + + col_interfaces.set('test-image', { + methods: { + echo_image: { + parameters: { + source: { + type: 'file', + }, + }, + result: { + type: { + $: 'stream', + content_type: 'image', + }, + }, + }, + get_image: { + parameters: { + source_type: { + type: 'string', + }, + }, + result: { + type: { + $: 'stream', + content_type: 'image', + }, + }, + }, + }, + }); + } + + static IMPLEMENTS = { + ['version']: { + get_version () { + return 'v1.0.0'; + }, + }, + ['test-image']: { + async echo_image ({ + source, + }) { + const stream = await source.get('stream'); + return new TypedValue({ + $: 'stream', + content_type: 'image/jpeg', + }, stream); + }, + async get_image ({ + source_type, + }) { + const image = PUBLIC_DOMAIN_IMAGES[0]; + if ( source_type === 'string:url:web' ) { + return new TypedValue({ + $: 'string:url:web', + content_type: 'image', + }, `${config.origin}/test-assets/${image.file}`); + } + throw new Error('not implemented yet'); + }, + }, + }; +} + +module.exports = { + TestImageService, +}; diff --git a/src/backend/src/modules/test-drivers/assets/starry.jpg b/src/backend/src/modules/test-drivers/assets/starry.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ba8d3d2a0f1d897372f4bcb604d97ecd862cc172 --- /dev/null +++ b/src/backend/src/modules/test-drivers/assets/starry.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa1dec0a7c09e31432d2267e65cbdde6170b544ad91286f51d921a1b90399352 +size 1391462 diff --git a/src/backend/src/modules/test-drivers/assets/wave.jpg b/src/backend/src/modules/test-drivers/assets/wave.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fe2fbed0fc5a5776c2a0638e6d42ca6b0cf5d440 --- /dev/null +++ b/src/backend/src/modules/test-drivers/assets/wave.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c8cf3fd80816f5c09332f41f149070473276b115849065e28afec60b56824f9 +size 973284 diff --git a/src/backend/src/modules/test-drivers/doc/requests.md b/src/backend/src/modules/test-drivers/doc/requests.md new file mode 100644 index 0000000000000000000000000000000000000000..1a915c62adbda3e45050252036dadd35e9f18ba4 --- /dev/null +++ b/src/backend/src/modules/test-drivers/doc/requests.md @@ -0,0 +1,98 @@ +```javascript +blob = await (await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'test-image', + method: 'get_image', + args: { + source_type: 'string:url:web' + } + }), + "method": "POST", +})).blob(); +dataurl = await new Promise((y, n) => { + a = new FileReader(); + a.onload = _ => y(a.result); + a.onerror = _ => n(a.error); + a.readAsDataURL(blob) +}); +URL.createObjectURL(await (await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'test-image', + method: 'echo_image', + args: { + source: dataurl, + } + }), + "method": "POST", +})).blob()); +``` + +```javascript +await(async () => { + + blob = await (await fetch("http://api.puter.localhost:4100/drivers/call", { + "headers": { + "Content-Type": "application/json", + "Authorization": `Bearer ${puter.authToken}`, + }, + "body": JSON.stringify({ + interface: 'test-image', + method: 'get_image', + args: { + source_type: 'string:url:web' + } + }), + "method": "POST", + })).blob(); + + const endpoint = 'http://api.puter.localhost:4100/drivers/call'; + + const body = { + object: { + interface: 'test-image', + method: 'echo_image', + ['args.source']: { + $: 'file', + size: blob.size, + type: blob.type, + }, + }, + file: [ + blob, + ] + }; + + const formData = new FormData(); + for ( const k in body ) { + console.log('k', k); + const append = v => { + if ( v instanceof Blob ) { + formData.append(k, v, 'filename'); + } else { + formData.append(k, JSON.stringify(v)); + } + }; + if ( Array.isArray(body[k]) ) { + for ( const v of body[k] ) append(v); + } else { + append(body[k]); + } + } + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Authorization': `Bearer ${puter.authToken}` }, + body: formData + }); + const echo_blob = await response.blob(); + const echo_url = URL.createObjectURL(echo_blob); + return echo_url; +})(); +``` \ No newline at end of file diff --git a/src/backend/src/modules/web/APIErrorService.js b/src/backend/src/modules/web/APIErrorService.js new file mode 100644 index 0000000000000000000000000000000000000000..73b68e7e029b3055221725dab2200f422c17db8c --- /dev/null +++ b/src/backend/src/modules/web/APIErrorService.js @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const APIError = require('../../api/APIError'); +const BaseService = require('../../services/BaseService'); + +/** + * @typedef {Object} ErrorSpec + * @property {string} code - The error code + * @property {string} status - HTTP status code + * @property {function} message - A function that generates an error message + */ + +/** + * The APIErrorService class provides a mechanism for registering and managing + * error codes and messages which may be sent to clients. + * + * This allows for a single source-of-truth for error codes and messages that + * are used by multiple services. + */ +class APIErrorService extends BaseService { + _construct () { + this.codes = { + ...this.constructor.codes, + }; + } + + // Hardcoded error codes from before this service was created + static codes = APIError.codes; + + /** + * Registers API error codes. + * + * @param {Object.} codes - A map of error codes to error specifications + */ + register (codes) { + for ( const code in codes ) { + this.codes[code] = codes[code]; + } + } + + create (code, fields) { + const error_spec = this.codes[code]; + if ( ! error_spec ) { + return new APIError(500, 'Missing error message.', null, { + code, + }); + } + + return new APIError(error_spec.status, error_spec.message, null, { + ...fields, + code, + }); + } +} + +module.exports = APIErrorService; diff --git a/src/backend/src/modules/web/README.md b/src/backend/src/modules/web/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5ce7250b760e95ac5186520fb755d00af1671216 --- /dev/null +++ b/src/backend/src/modules/web/README.md @@ -0,0 +1,65 @@ +# WebModule + +This module initializes a pre-configured web server and socket.io server. +The main service, WebServerService, emits 'install.routes' and provides +the server instance to the callback. + +## Services + +### SocketioService + +SocketioService provides a service for sending messages to clients. +socket.io is used behind the scenes. This service provides a simpler +interface for sending messages to rooms or socket ids. + +#### Listeners + +##### `install.socketio` + +Initializes socket.io + +###### Parameters + +- **server:** The server to attach socket.io to. + +### WebServerService + +This class, WebServerService, is responsible for starting and managing the Puter web server. +It initializes the Express app, sets up middlewares, routes, and handles authentication and web sockets. +It also validates the host header and IP addresses to prevent security vulnerabilities. + +#### Listeners + +##### `boot.consolidation` + +This method initializes the backend web server for Puter. It sets up the Express app, configures middleware, and starts the HTTP server. + +##### `boot.activation` + +Starts the web server and listens for incoming connections. +This method sets up the Express app, sets up middleware, and starts the server on the specified port. +It also sets up the Socket.io server for real-time communication. + +##### `start.webserver` + +This method starts the web server by listening on the specified port. It tries multiple ports if the first one is in use. +If the `config.http_port` is set to 'auto', it will try to find an available port in a range of 4100 to 4299. +Once the server is up and running, it emits the 'start.webserver' and 'ready.webserver' events. +If the `config.env` is set to 'dev' and `config.no_browser_launch` is false, it will open the Puter URL in the default browser. + +## Notes + +### Outside Imports + +This module has external relative imports. When these are +removed it may become possible to move this module to an +extension. + +**Imports:** +- `../../services/BaseService` (use.BaseService) +- `../../util/context.js` +- `../../services/BaseService.js` +- `../../config.js` +- `../../middleware/auth.js` +- `../../util/strutil.js` +- `../../helpers.js` diff --git a/src/backend/src/modules/web/SocketioService.js b/src/backend/src/modules/web/SocketioService.js new file mode 100644 index 0000000000000000000000000000000000000000..2e0cdb818bc75e49f146b8e996d555233ee6b085 --- /dev/null +++ b/src/backend/src/modules/web/SocketioService.js @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const BaseService = require('../../services/BaseService'); +const socketio = require('socket.io'); + +/** + * SocketioService provides a service for sending messages to clients. + * socket.io is used behind the scenes. This service provides a simpler + * interface for sending messages to rooms or socket ids. + */ +class SocketioService extends BaseService { + /** + * Initializes socket.io + * + * @evtparam server The server to attach socket.io to. + */ + ['__on_install.socketio'] (_, { server }) { + /** + * @type {import('socket.io').Server} + */ + this.io = socketio(server, { + cors: { + origin: '*', + }, + }); + } + + /** + * Sends a message to specified socket(s) or room(s) + * + * @param {Array|Object} socket_specifiers - Single or array of objects specifying target sockets/rooms + * @param {string} key - The event key/name to emit + * @param {*} data - The data payload to send + * @returns {Promise} + */ + async send (socket_specifiers, key, data) { + if ( ! Array.isArray(socket_specifiers) ) { + socket_specifiers = [socket_specifiers]; + } + + for ( const socket_specifier of socket_specifiers ) { + if ( socket_specifier.room ) { + this.io.to(socket_specifier.room).emit(key, data); + } else if ( socket_specifier.socket ) { + const io = this.io.sockets.sockets.get(socket_specifier.socket); + if ( ! io ) continue; + io.emit(key, data); + } + } + } + + /** + * Checks if the specified socket or room exists + * + * @param {Object} socket_specifier - The socket specifier object + * @returns {boolean} True if the socket exists, false otherwise + */ + has (socket_specifier) { + if ( socket_specifier.room ) { + const room = this.io?.sockets.adapter.rooms.get(socket_specifier.room); + return (!!room) && room.size > 0; + } + if ( socket_specifier.socket ) { + return this.io?.sockets.sockets.has(socket_specifier.socket); + } + } +} + +module.exports = SocketioService; diff --git a/src/backend/src/modules/web/WebModule.js b/src/backend/src/modules/web/WebModule.js new file mode 100644 index 0000000000000000000000000000000000000000..7028b733dd1329d79911eca4c17a87c483dd1e1f --- /dev/null +++ b/src/backend/src/modules/web/WebModule.js @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { AdvancedBase } = require('@heyputer/putility'); +const { RuntimeModule } = require('../../extension/RuntimeModule.js'); + +/** + * This module initializes a pre-configured web server and socket.io server. + * The main service, WebServerService, emits 'install.routes' and provides + * the server instance to the callback. + */ +class WebModule extends AdvancedBase { + async install (context) { + // === LIBS === // + const useapi = context.get('useapi'); + useapi.def('web', require('./lib/__lib__.js'), { assign: true }); + + // Prevent extensions from loading incompatible versions of express + useapi.def('web.express', require('express')); + + // Extension compatibility + const runtimeModule = new RuntimeModule({ name: 'web' }); + context.get('runtime-modules').register(runtimeModule); + runtimeModule.exports = useapi.use('web'); + + // === SERVICES === // + const services = context.get('services'); + + const SocketioService = require('./SocketioService'); + services.registerService('socketio', SocketioService); + + const WebServerService = require('./WebServerService'); + services.registerService('web-server', WebServerService); + + const APIErrorService = require('./APIErrorService'); + services.registerService('api-error', APIErrorService); + } +} + +module.exports = { + WebModule, +}; diff --git a/src/backend/src/modules/web/WebServerService.d.ts b/src/backend/src/modules/web/WebServerService.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbe12bab1926cff26820d153dd27aa5680aca2a3 --- /dev/null +++ b/src/backend/src/modules/web/WebServerService.d.ts @@ -0,0 +1,20 @@ +import { Server } from 'http'; +import BaseService from '../../services/BaseService'; + +/** + * WebServerService is responsible for starting and managing the Puter web server. + */ +export class WebServerService extends BaseService { + /** + * Allow requests with undefined Origin header for a specific route. + * @param route The route (string or RegExp) to allow. + */ + allow_undefined_origin (route: string | RegExp): void; + + /** + * Returns the underlying HTTP server instance. + */ + get_server (): Server; +} + +export = WebServerService; \ No newline at end of file diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js new file mode 100644 index 0000000000000000000000000000000000000000..51e4c529e0eca916d14edc36717e75dbbf9eebfb --- /dev/null +++ b/src/backend/src/modules/web/WebServerService.js @@ -0,0 +1,658 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const express = require('express'); +const eggspress = require('./lib/eggspress.js'); +const { Context, ContextExpressMiddleware } = require('../../util/context.js'); +const BaseService = require('../../services/BaseService.js'); + +const config = require('../../config.js'); +var http = require('http'); +const auth = require('../../middleware/auth.js'); +const measure = require('../../middleware/measure.js'); + +const relative_require = require; + +/** +* This class, WebServerService, is responsible for starting and managing the Puter web server. +* It initializes the Express app, sets up middlewares, routes, and handles authentication and web sockets. +* It also validates the host header and IP addresses to prevent security vulnerabilities. +*/ +class WebServerService extends BaseService { + static CONCERN = 'web'; + + static MODULES = { + https: require('https'), + http: require('http'), + fs: require('fs'), + express: require('express'), + helmet: require('helmet'), + cookieParser: require('cookie-parser'), + compression: require('compression'), + ['on-finished']: require('on-finished'), + morgan: require('morgan'), + }; + + allowedRoutesWithUndefinedOrigins = []; + + allow_undefined_origin (route) { + this.allowedRoutesWithUndefinedOrigins.push(route); + } + + /** + * This method initializes the backend web server for Puter. It sets up the Express app, configures middleware, and starts the HTTP server. + * + * @param {Express} app - The Express app instance to configure. + * @returns {void} + * @private + */ + // comment above line 44 in WebServerService.js + async ['__on_boot.consolidation'] () { + const app = this.app; + const services = this.services; + await services.emit('install.middlewares.early', { app }); + await services.emit('install.middlewares.context-aware', { app }); + this.install_post_middlewares_({ app }); + await services.emit('install.routes', { + app, + router_webhooks: this.router_webhooks, + }); + await services.emit('install.routes-gui', { app }); + + // Register after other services registers theirs: Options for all requests (for CORS) + app.options('/*', (_req, res) => { + return res.sendStatus(200); + }); + + this.log.debug('web server setup done'); + } + + install_post_middlewares_ ({ app }) { + app.use(async (req, res, next) => { + const svc_event = this.services.get('event'); + + const event = { + req, + res, + end_: false, + end () { + this.end_ = true; + }, + }; + await svc_event.emit('request.will-be-handled', event); + if ( ! event.end_ ) next(); + }); + } + + /** + * Starts the web server and listens for incoming connections. + * This method sets up the Express app, sets up middleware, and starts the server on the specified port. + * It also sets up the Socket.io server for real-time communication. + * + * @returns {Promise} A promise that resolves once the server is started. + */ + async ['__on_boot.activation'] () { + const services = this.services; + await services.emit('start.webserver'); + await services.emit('ready.webserver'); + console.log('in case you care, ready.webserver hooks are done'); + } + + /** + * This method starts the web server by listening on the specified port. It tries multiple ports if the first one is in use. + * If the `config.http_port` is set to 'auto', it will try to find an available port in a range of 4100 to 4299. + * Once the server is up and running, it emits the 'start.webserver' and 'ready.webserver' events. + * If the `config.env` is set to 'dev' and `config.no_browser_launch` is false, it will open the Puter URL in the default browser. + * + * @return {Promise} A promise that resolves when the server is up and running. + */ + async ['__on_start.webserver'] () { + // error handling middleware goes last, as per the + // expressjs documentation: + // https://expressjs.com/en/guide/error-handling.html + this.app.use(require('./lib/api_error_handler.js')); + + const { jwt_auth } = require('../../helpers.js'); + + config.http_port = process.env.PORT ?? config.http_port; + + globalThis.deployment_type = + config.http_port === 5101 ? 'green' : + config.http_port === 5102 ? 'blue' : + 'not production'; + + let server; + + const auto_port = config.http_port === 'auto'; + let ports_to_try = auto_port ? (() => { + const ports = []; + for ( let i = 0 ; i < 20 ; i++ ) { + ports.push(4100 + i); + } + return ports; + })() : [Number.parseInt(config.http_port)]; + + for ( let i = 0 ; i < ports_to_try.length ; i++ ) { + const port = ports_to_try[i]; + const is_last_port = i === ports_to_try.length - 1; + if ( auto_port ) this.log.debug(`trying port: ${ port}`); + try { + server = http.createServer(this.app).listen(port); + server.timeout = 1000 * 60 * 60 * 2; // 2 hours + let should_continue = false; + await new Promise((rslv, rjct) => { + server.on('error', e => { + if ( e.code === 'EADDRINUSE' ) { + if ( !is_last_port && e.code === 'EADDRINUSE' ) { + this.log.info(`port in use: ${ port}`); + should_continue = true; + } + rslv(); + } else { + rjct(e); + } + }); + /** + * Starts the web server. + * + * This method is responsible for creating the HTTP server, setting up middleware, and starting the server on the specified port. If the specified port is "auto", it will attempt to find an available port within a range. + * + * @returns {Promise} + */ + // Add this comment above line 110 + // (line 110 of the provided code) + server.on('listening', () => { + rslv(); + }); + }); + if ( should_continue ) continue; + } catch (e) { + if ( !is_last_port && e.code === 'EADDRINUSE' ) { + this.log.info(`port in use:${ port}`); + continue; + } + throw e; + } + config.http_port = port; + break; + } + ports_to_try = null; // GC + + const url = config.origin; + + // Open the browser to the URL of Puter + // (if we are in development mode only) + if ( config.env === 'dev' && !config.no_browser_launch ) { + try { + const openModule = await import('open'); + openModule.default(url); + } catch (e) { + console.log('Error opening browser', e); + } + } + + const link = `\x1B[34;1m${url}\x1B[0m`; + const lines = [ + `Puter is now live at: ${link}`, + ]; + const realConsole = globalThis.original_console_object ?? console; + lines.forEach(line => realConsole.log(line)); + + realConsole.log('\n************************************************************'); + realConsole.log(`* Puter is now live at: ${url}`); + realConsole.log('************************************************************'); + + server.timeout = 1000 * 60 * 60 * 2; // 2 hours + server.requestTimeout = 1000 * 60 * 60 * 2; // 2 hours + server.headersTimeout = 1000 * 60 * 60 * 2; // 2 hours + // server.keepAliveTimeout = 1000 * 60 * 60 * 2; // 2 hours + + // Socket.io server instance + // const socketio = require('../../socketio.js').init(server); + + // TODO: ^ Replace above line with the following code: + await this.services.emit('install.socketio', { server }); + const socketio = this.services.get('socketio').io; + + // Socket.io middleware for authentication + socketio.use(async (socket, next) => { + if ( socket.handshake.auth.auth_token ) { + try { + let auth_res = await jwt_auth(socket); + // successful auth + socket.actor = auth_res.actor; + socket.user = auth_res.user; + socket.token = auth_res.token; + // join user room + socket.join(socket.user.id); + + // setTimeout 0 is needed because we need to send + // the notifications after this handler is done + // setTimeout(() => { + // }, 1000); + next(); + } catch (e) { + console.warn('socket auth err', e); + } + } + }); + + const context = Context.get(); + socketio.on('connection', (socket) => { + socket.on('disconnect', () => { + }); + socket.on('trash.is_empty', (msg) => { + socket.broadcast.to(socket.user.id).emit('trash.is_empty', msg); + }); + const svc_event = this.services.get('event'); + svc_event.emit('web.socket.connected', { + socket, + user: socket.user, + }); + socket.on('puter_is_actually_open', async (_msg) => { + await context.sub({ + actor: socket.actor, + }).arun(async () => { + await svc_event.emit('web.socket.user-connected', { + socket, + user: socket.user, + }); + }); + }); + }); + + this.server_ = server; + await this.services.emit('install.websockets'); + } + + /** + * Starts the Puter web server and sets up routes, middleware, and error handling. + * + * @param {object} services - An object containing all services available to the web server. + * @returns {Promise} A promise that resolves when the web server is fully started. + */ + get_server () { + return this.server_; + } + + /** + * Handles starting and managing the Puter web server. + * + * @param {Object} services - An object containing all services. + */ + async _init () { + const app = express(); + this.app = app; + + app.set('services', this.services); + + this.middlewares = { auth }; + + const require = this.require; + + const config = this.global_config; + new ContextExpressMiddleware({ + parent: globalThis.root_context.sub({ + puter_environment: Context.create({ + env: config.env, + version: relative_require('../../../package.json').version, + }), + }, 'mw'), + }).install(app); + + app.use(async (req, res, next) => { + req.services = this.services; + next(); + }); + + // Measure data transfer amounts + app.use(measure()); + + // Instrument logging to use our log service + { + // Switch log function at config time; info log is configurable + const logfn = (config.logging ?? []).includes('http') + ? (log, { message, fields }) => { + log.info(message); + log.debug(message, fields); + } + : (log, { message, fields }) => { + log.debug(message, fields); + }; + + const morgan = require('morgan'); + const stream = { + write: (message) => { + const [method, url, status, responseTime] = message.split(' '); + const fields = { + method, + url, + status: parseInt(status, 10), + responseTime: parseFloat(responseTime), + }; + if ( url.includes('android-icon') ) return; + + // remove `puter.auth.*` query params + const safe_url = (u => { + // We need to prepend an arbitrary domain to the URL + const url = new URL(`https://example.com${ u}`); + const search = url.searchParams; + for ( const key of search.keys() ) { + if ( key.startsWith('puter.auth.') ) search.delete(key); + } + return `${url.pathname }?${ search.toString()}`; + })(fields.url); + fields.url = safe_url; + // re-write message + message = [ + fields.method, fields.url, + fields.status, fields.responseTime, + ].join(' '); + + const log = this.services.get('log-service').create('morgan'); + try { + this.context.arun(() => { + logfn(log, { message, fields }); + }); + } catch (e) { + console.log('failed to log this message properly:', message, fields); + console.error(e); + } + }, + }; + + app.use(morgan(':method :url :status :response-time', { stream })); + } + + /** + * Initialize the web server, start it, and handle any related logic. + * + * This method is responsible for creating the server and listening on the + * appropriate port. It also sets up middleware, routes, and other necessary + * configurations. + * + * @returns {Promise} A promise that resolves once the server is up and running. + */ + app.use((() => { + // const router = express.Router(); + // router.get('/wut', express.json(), (req, res, next) => { + // return res.status(500).send('Internal Error'); + // }); + // return router; + + return eggspress('/wut', { + allowedMethods: ['GET'], + }, async (req, res, _next) => { + // throw new Error('throwy error'); + return res.status(200).send('test endpoint'); + }); + })()); + + (() => { + const onFinished = require('on-finished'); + app.use((req, res, next) => { + onFinished(res, () => { + if ( res.statusCode !== 500 ) return; + if ( req.__error_handled ) return; + const alarm = this.services.get('alarm'); + alarm.create('responded-500', 'server sent a 500 response', { + error: req.__error_source, + url: req.url, + method: req.method, + body: req.body, + headers: req.headers, + }); + }); + next(); + }); + })(); + + app.use(async function (req, res, next) { + // Express does not document that this can be undefined. + // The browser likely doesn't follow the HTTP/1.1 spec + // (bot client?) and express is handling this badly by + // not setting the header at all. (that's my theory) + if ( req.hostname === undefined ) { + res.status(400).send( + 'Please verify your browser is up-to-date.'); + return; + } + + return next(); + }); + + // Validate host header against allowed domains to prevent host header injection + // https://www.owasp.org/index.php/Host_Header_Injection + app.use((req, res, next) => { + const allowedDomains = [ + config.domain.toLowerCase(), + config.static_hosting_domain.toLowerCase(), + `at.${ config.static_hosting_domain.toLowerCase()}`, + ]; + + if ( config.allow_nipio_domains ) { + allowedDomains.push('nip.io'); + } + + // Retrieve the Host header and ensure it's in a valid format + const hostHeader = req.headers.host; + + if ( !config.allow_no_host_header && !hostHeader ) { + return res.status(400).send('Missing Host header.'); + } + + if ( config.allow_all_host_values ) { + next(); + return; + } + + // Parse the Host header to isolate the hostname (strip out port if present) + const hostName = hostHeader.split(':')[0].trim().toLowerCase(); + + // Check if the hostname matches any of the allowed domains or is a subdomain of an allowed domain + if ( allowedDomains.some(allowedDomain => hostName === allowedDomain || hostName.endsWith(`.${ allowedDomain}`)) ) { + next(); // Proceed if the host is valid + } else { + if ( ! config.custom_domains_enabled ) { + return res.status(400).send('Invalid Host header.'); + } + req.is_custom_domain = true; + next(); + } + }); + + // Validate IP with any IP checkers + app.use(async (req, res, next) => { + const svc_event = this.services.get('event'); + const event = { + allow: true, + ip: req.headers?.['x-forwarded-for'] || + req.connection?.remoteAddress, + }; + + if ( ! this.config.disable_ip_validate_event ) { + await svc_event.emit('ip.validate', event); + } + + // rules that don't apply to notification endpoints + const undefined_origin_allowed = config.undefined_origin_allowed || this.allowedRoutesWithUndefinedOrigins.some(rule => { + if ( typeof rule === 'string' ) return rule === req.path; + return rule.test(req.path); + }); + if ( ! undefined_origin_allowed ) { + // check if no origin + if ( req.method === 'POST' && req.headers.origin === undefined ) { + event.allow = false; + } + } + if ( ! event.allow ) { + return res.status(403).send('Forbidden'); + } + next(); + }); + + // Web hooks need a router that occurs before JSON parse middleware + // so that signatures of the raw JSON can be verified + this.router_webhooks = express.Router(); + app.use(this.router_webhooks); + + app.use((req, res, next) => { + if ( req.get('x-amz-sns-message-type') ) { + req.headers['content-type'] = 'application/json'; + } + next(); + }); + + const rawBodyBuffer = (req, res, buf, encoding) => { + req.rawBody = buf.toString(encoding || 'utf8'); + }; + + app.use(express.json({ limit: '50mb', verify: rawBodyBuffer })); + app.use((req, res, next) => { + if ( req.headers['content-type']?.startsWith('application/json') + && req.body + && Buffer.isBuffer(req.body) + ) { + try { + req.rawBody = req.body; + req.body = JSON.parse(req.body.toString('utf8')); + } catch { + return res.status(400).send({ + error: { + message: 'Invalid JSON body', + }, + }); + } + } + next(); + }); + + const cookieParser = require('cookie-parser'); + app.use(cookieParser({ limit: '50mb' })); + + // gzip compression for all requests + const compression = require('compression'); + app.use(compression()); + + // Helmet and other security + const helmet = require('helmet'); + app.use(helmet.noSniff()); + app.use(helmet.hsts()); + app.use(helmet.ieNoOpen()); + app.use(helmet.permittedCrossDomainPolicies()); + app.use(helmet.xssFilter()); + // app.use(helmet.referrerPolicy()); + app.disable('x-powered-by'); + + // remove object and array query parameters + app.use(function (req, res, next) { + for ( let k in req.query ) { + if ( req.query[k] === undefined || req.query[k] === null ) { + continue; + } + + const allowed_types = ['string', 'number', 'boolean']; + if ( ! allowed_types.includes(typeof req.query[k]) ) { + req.query[k] = undefined; + } + } + next(); + }); + + const uaParser = require('ua-parser-js'); + app.use(function (req, res, next) { + const ua_header = req.headers['user-agent']; + const ua = uaParser(ua_header); + req.ua = ua; + next(); + }); + + app.use(function (req, res, next) { + req.co_isolation_enabled = + ['Chrome', 'Edge'].includes(req.ua.browser.name) + && (Number(req.ua.browser.major) >= 110); + next(); + }); + + app.use(function (req, res, next) { + const origin = req.headers.origin; + + const is_site = + req.hostname.endsWith(config.static_hosting_domain) || + req.hostname === 'docs.puter.com' + ; + const is_popup = !!req.query.embedded_in_popup; + const is_parent_co = !!req.query.cross_origin_isolated; + const is_app = !!req.query['puter.app_instance_id']; + + const co_isolation_okay = + (!is_popup || is_parent_co) && + (is_app || !is_site) && + req.co_isolation_enabled + ; + + if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') ) { + res.setHeader('Access-Control-Allow-Origin', origin ?? '*'); + } + // Website(s) to allow to connect + if ( + config.experimental_no_subdomain || + req.subdomains[req.subdomains.length - 1] === 'api' + ) { + res.setHeader('Access-Control-Allow-Origin', origin ?? '*'); + } + + // Request methods to allow + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'); + + const allowed_headers = [ + 'Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'sentry-trace', 'baggage', + 'Depth', 'Destination', 'Overwrite', 'If', 'Lock-Token', 'DAV', 'stripe-signature', + ]; + + // Request headers to allow + res.header('Access-Control-Allow-Headers', allowed_headers.join(', ')); + + // Set to true if you need the website to include cookies in the requests sent + // to the API (e.g. in case you use sessions) + // res.setHeader('Access-Control-Allow-Credentials', true); + + // Needed for SharedArrayBuffer + // NOTE: This is put behind a configuration flag because we + // need some experimentation to ensure the interface + // between apps and Puter doesn't break. + if ( config.cross_origin_isolation && co_isolation_okay ) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + } + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + + // Pass to next layer of middleware + + // disable iframes on the main domain + if ( req.hostname === config.domain ) { + // disable iframes + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + } + + next(); + }); + } +} + +module.exports = WebServerService; diff --git a/src/backend/src/modules/web/lib/__lib__.js b/src/backend/src/modules/web/lib/__lib__.js new file mode 100644 index 0000000000000000000000000000000000000000..874ae9b78db4b50672f823379b16573ea77374cb --- /dev/null +++ b/src/backend/src/modules/web/lib/__lib__.js @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +module.exports = { + eggspress: require('./eggspress'), + api_error_handler: require('./api_error_handler'), +}; diff --git a/src/backend/src/modules/web/lib/api_error_handler.js b/src/backend/src/modules/web/lib/api_error_handler.js new file mode 100644 index 0000000000000000000000000000000000000000..50c6d2dfe3f05a6dcc0c0f691d58e48af1cf3b6c --- /dev/null +++ b/src/backend/src/modules/web/lib/api_error_handler.js @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../../api/APIError.js'); + +/** + * api_error_handler() is an express error handler for API errors. + * It adheres to the express error handler signature and should be + * used as the last middleware in an express app. + * + * Since Express 5 is not yet released, this function is used by + * eggspress() to handle errors instead of as a middleware. + * + * @param {*} err + * @param {*} req + * @param {*} res + * @param {*} next + * @returns + */ +module.exports = function api_error_handler (err, req, res, next) { + if ( res.headersSent ) { + console.error('error after headers were sent:', err); + return next(err); + } + + // API errors might have a response to help the + // developer resolve the issue. + if ( err instanceof APIError ) { + return err.write(res); + } + + if ( + typeof err === 'object' && + !(err instanceof Error) && + err.hasOwnProperty('message') + ) { + const apiError = APIError.create(400, err); + return apiError.write(res); + } + + console.error('internal server error:', err); + + const services = globalThis.services; + if ( services && services.has('alarm') ) { + const alarm = services.get('alarm'); + alarm.create('api_error_handler', err.message, { + error: err, + url: req.url, + method: req.method, + body: req.body, + headers: req.headers, + }); + } + + req.__error_handled = true; + + // Other errors should provide as little information + // to the client as possible for security reasons. + return res.send(500, 'Internal Server Error'); +}; diff --git a/src/backend/src/modules/web/lib/eggspress.js b/src/backend/src/modules/web/lib/eggspress.js new file mode 100644 index 0000000000000000000000000000000000000000..cf09a3c68655908d1ce7c44a7fb8370aa4586c44 --- /dev/null +++ b/src/backend/src/modules/web/lib/eggspress.js @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const express = require('express'); +const multer = require('multer'); +const multest = require('@heyputer/multest'); +const api_error_handler = require('./api_error_handler.js'); + +const APIError = require('../../../api/APIError.js'); +const { Context } = require('../../../util/context.js'); +const { subdomain } = require('../../../helpers.js'); +const config = require('../../../config.js'); + +/** + * eggspress() is a factory function for creating express routers. + * + * @param {*} route the route to the router + * @param {*} settings the settings for the router. The following + * properties are supported: + * - auth: whether or not to use the auth middleware + * - fs: whether or not to use the fs middleware + * - json: whether or not to use the json middleware + * - customArgs: custom arguments to pass to the router + * - allowedMethods: the allowed HTTP methods + * @param {*} handler the handler for the router + * @returns {express.Router} the router + */ +module.exports = function eggspress (route, settings, handler) { + const router = express.Router(); + const mw = []; + const afterMW = []; + + const _defaultJsonOptions = {}; + if ( settings.jsonCanBeLarge ) { + _defaultJsonOptions.limit = '10mb'; + } + + // These flags enable specific middleware. + if ( settings.abuse ) mw.push(require('../../../middleware/abuse')(settings.abuse)); + if ( settings.verified ) mw.push(require('../../../middleware/verified')); + + // if json explicitly set false, don't use it + if ( settings.json !== false ) { + if ( settings.json ) mw.push(express.json(_defaultJsonOptions)); + // A hack so plain text is parsed as JSON in methods which need to be lower latency/avoid the cors roundtrip + if ( settings.noReallyItsJson ) mw.push(express.json({ ..._defaultJsonOptions, type: '*/*' })); + + mw.push(express.json({ + ..._defaultJsonOptions, + type: (req) => req.headers['content-type'] === 'text/plain;actually=json', + })); + } + + if ( settings.auth ) mw.push(require('../../../middleware/auth')); + if ( settings.auth2 ) mw.push(require('../../../middleware/auth2')); + + // The `files` setting is an array of strings. Each string is the name + // of a multipart field that contains files. `multer` is used to parse + // the multipart request and store the files in `req.files`. + if ( settings.files ) { + for ( const key of settings.files ) { + mw.push(multer().array(key)); + } + } + + if ( settings.multest ) { + mw.push(multest()); + } + + // The `multipart_jsons` setting is an array of strings. Each string + // is the name of a multipart field that contains JSON. This middleware + // parses the JSON in each field and stores the result in `req.body`. + if ( settings.multipart_jsons ) { + for ( const key of settings.multipart_jsons ) { + mw.push((req, res, next) => { + try { + if ( ! Array.isArray(req.body[key]) ) { + req.body[key] = [JSON.parse(req.body[key])]; + } else { + req.body[key] = req.body[key].map(JSON.parse); + } + } catch ( _e ) { + return res.status(400).send({ + error: { + message: `Invalid JSON in multipart field ${key}`, + }, + }); + } + next(); + }); + } + } + + // The `alias` setting is an object. Each key is the name of a + // parameter. Each value is the name of a parameter that should + // be aliased to the key. + if ( settings.alias ) { + for ( const alias in settings.alias ) { + const target = settings.alias[alias]; + mw.push((req, res, next) => { + const values = req.method === 'GET' ? req.query : req.body; + if ( values[alias] ) { + values[target] = values[alias]; + } + next(); + }); + } + } + + // The `parameters` setting is an object. Each key is the name of a + // parameter. Each value is a `Param` object. The `Param` object + // specifies how to validate the parameter. + if ( settings.parameters ) { + for ( const key in settings.parameters ) { + const param = settings.parameters[key]; + mw.push(async (req, res, next) => { + if ( ! req.values ) req.values = {}; + + const values = req.method === 'GET' ? req.query : req.body; + const getParam = (key) => values[key]; + try { + const result = await param.consolidate({ req, getParam }); + req.values[key] = result; + } catch (e) { + api_error_handler(e, req, res, next); + return; + } + next(); + }); + } + } + + // what if I wanted to pass arguments to, for example, `json`? + if ( settings.customArgs ) mw.push(settings.customArgs); + + if ( settings.alarm_timeout ) { + mw.push((req, res, next) => { + setTimeout(() => { + if ( ! res.headersSent ) { + const log = req.services.get('log-service').create('eggspress:timeout'); + const errors = req.services.get('error-service').create(log); + let id = Array.isArray(route) ? route[0] : route; + id = id.replace(/\//g, '_'); + errors.report(id, { + source: new Error('Response timed out.'), + message: 'Response timed out.', + trace: true, + alarm: true, + }); + } + }, settings.alarm_timeout); + next(); + }); + } + + if ( settings.response_timeout ) { + mw.push((req, res, next) => { + setTimeout(() => { + if ( ! res.headersSent ) { + api_error_handler(APIError.create('response_timeout'), req, res, next); + } + }, settings.response_timeout); + next(); + }); + } + + if ( settings.mw ) { + mw.push(...settings.mw); + } + + const errorHandledHandler = async function (req, res, next) { + if ( settings.subdomain ) { + if ( subdomain(req) !== settings.subdomain ) { + return next(); + } + } + if ( config.env === 'dev' && process.env.DEBUG ) { + console.log(`request url: ${req.url}, body: ${JSON.stringify(req.body)}`); + } + try { + const expected_ctx = res.locals.ctx; + const received_ctx = Context.get(undefined, { allow_fallback: true }); + + if ( expected_ctx != received_ctx ) { + await expected_ctx.arun(async () => { + await handler(req, res, next); + }); + } else await handler(req, res, next); + } catch (e) { + if ( config.env === 'dev' ) { + if ( ! (e instanceof APIError) ) { + // Any non-APIError indicates an unhandled error (i.e. a bug) from the backend. + // We add a dedicated branch to facilitate debugging. + console.error(e); + } + } + api_error_handler(e, req, res, next); + } + }; + if ( settings.allowedMethods.includes('GET') ) { + router.get(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('HEAD') ) { + router.head(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('POST') ) { + router.post(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('PUT') ) { + router.put(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('DELETE') ) { + router.delete(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('PROPFIND') ) { + router.propfind(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('PROPPATCH') ) { + router.proppatch(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('MKCOL') ) { + router.mkcol(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('COPY') ) { + router.copy(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('MOVE') ) { + router.move(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('LOCK') ) { + router.lock(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('UNLOCK') ) { + router.unlock(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('OPTIONS') ) { + router.options(route, ...mw, errorHandledHandler, ...afterMW); + } + + return router; +}; \ No newline at end of file diff --git a/src/backend/src/om/IdentifierUtil.js b/src/backend/src/om/IdentifierUtil.js new file mode 100644 index 0000000000000000000000000000000000000000..387803949d397112bcd2584ab2883aea308fd365 --- /dev/null +++ b/src/backend/src/om/IdentifierUtil.js @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../traits/WeakConstructorFeature'); +const { Eq, And } = require('./query/query'); +const { Entity } = require('./entitystorage/Entity'); + +class IdentifierUtil extends AdvancedBase { + static FEATURES = [ + new WeakConstructorFeature(), + ]; + + async detect_identifier (object, allow_mutation = false) { + const redundant_identifiers = this.om.redundant_identifiers ?? []; + + let match_found = null; + for ( let key_set of redundant_identifiers ) { + key_set = Array.isArray(key_set) ? key_set : [key_set]; + key_set.sort(); + + for ( let i = 0 ; i < key_set.length ; i++ ) { + const key = key_set[i]; + const has_key = object instanceof Entity ? + await object.has(key) : object[key] !== undefined; + if ( ! has_key ) { + break; + } + if ( i === key_set.length - 1 ) { + match_found = key_set; + break; + } + } + } + + if ( ! match_found ) return; + + // Construct a query predicate based on the keys + const key_eqs = []; + for ( const key of match_found ) { + key_eqs.push(new Eq({ + key, + value: object instanceof Entity ? + await object.get(key) : object[key], + })); + if ( object instanceof Entity ) { + if ( allow_mutation ) await object.del(key); + } else { + if ( allow_mutation ) delete object[key]; + } + } + let predicate = new And({ children: key_eqs }); + + return predicate; + } +} + +module.exports = { + IdentifierUtil, +}; diff --git a/src/backend/src/om/definitions/Mapping.js b/src/backend/src/om/definitions/Mapping.js new file mode 100644 index 0000000000000000000000000000000000000000..667f84ca44a3ef58d775144275c0ced4ab3e1124 --- /dev/null +++ b/src/backend/src/om/definitions/Mapping.js @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); +const { Property } = require('./Property'); +const { Entity } = require('../entitystorage/Entity'); +const FSNodeContext = require('../../filesystem/FSNodeContext'); + +/** + * An instance of Mapping wraps every definition in ../mappings before + * it is registered in the 'om' collection in RegistryService. + * Both wrapping and registering are done by RegistrantService. + */ +class Mapping extends AdvancedBase { + static FEATURES = [ + // Whenever you can override something, it's reasonable to want + // to pull the desired implementation from somewhere else to + // avoid repeating yourself. Class constructors are one of a few + // examples where this is typically not possible. + // However, javascript is magic, and we do what we want. + new WeakConstructorFeature(), + ]; + + static create (context, data) { + const properties = {}; + + // NEXT + for ( const k in data.properties ) { + properties[k] = Property.create(context, k, data.properties[k]); + } + + return new Mapping({ + ...data, + properties, + sql: data.sql, + }); + } + + async get_client_safe (data) { + const client_safe = {}; + + for ( const k in this.properties ) { + const prop = this.properties[k]; + let value = data[k]; + + if ( prop.descriptor.protected ) { + continue; + } + + if ( value === undefined ) { + continue; + } + + let sanitized = false; + + if ( value instanceof Entity ) { + value = await value.get_client_safe(); + sanitized = true; + } + + if ( value instanceof FSNodeContext ) { + if ( ! await value.exists() ) { + value = undefined; + continue; + } + value = await value.getSafeEntry(); + sanitized = true; + } + + // This is for reference properties to remove sensitive + // information in case a decorator added the real object. + if ( + ( !sanitized ) && + typeof value === 'object' && value !== null && + prop.descriptor.permissible_subproperties + ) { + const old_value = value; + value = {}; + for ( const subprop_name of prop.descriptor.permissible_subproperties ) { + if ( ! old_value.hasOwnProperty(subprop_name) ) { + continue; + } + value[subprop_name] = old_value[subprop_name]; + } + } + + // client_safe[k] = await prop.typ.get_client_safe(value); + client_safe[k] = value; + } + + return client_safe; + } +} + +module.exports = { + Mapping, +}; diff --git a/src/backend/src/om/definitions/PropType.js b/src/backend/src/om/definitions/PropType.js new file mode 100644 index 0000000000000000000000000000000000000000..407c22e881fe6ffb7db16caeb3da43becdb72b26 --- /dev/null +++ b/src/backend/src/om/definitions/PropType.js @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); + +class PropType extends AdvancedBase { + static FEATURES = [ + new WeakConstructorFeature(), + ]; + + static create (context, data, k) { + const chains = {}; + const super_type = data.from && (() => { + const registry = context.get('registry'); + const types = registry.get('om:proptype'); + const super_type = types.get(data.from); + if ( ! super_type ) { + throw new Error(`Failed to find super type "${data.from}"`); + } + return super_type; + })(); + + data = { ...data }; + delete data.from; + + if ( super_type ) { + super_type.populate_subtype_(chains); + } + + for ( const k in data ) { + if ( ! chains.hasOwnProperty(k) ) { + chains[k] = []; + } + chains[k].push(data[k]); + } + + return new PropType({ + chains, name: k, + }); + } + + populate_subtype_ (chains) { + for ( const k in this.chains ) { + if ( ! chains.hasOwnProperty(k) ) { + chains[k] = []; + } + chains[k].push(...this.chains[k]); + } + } + + async adapt (value, extra) { + const adapters = this.chains.adapt || []; + adapters.reverse(); + + for ( const adapter of adapters ) { + value = await adapter(value, extra); + } + + return value; + } + + async sql_dereference (value, extra) { + const sql_dereferences = this.chains.sql_dereference || []; + + for ( const sql_dereference of sql_dereferences ) { + value = await sql_dereference(value, extra); + } + + return value; + } + + async sql_reference (value, extra) { + const sql_references = this.chains.sql_reference || []; + + for ( const sql_reference of sql_references ) { + value = await sql_reference(value, extra); + } + + return value; + } + + async validate (value, extra) { + const validators = this.chains.validate || []; + + for ( const validator of validators ) { + const result = await validator(value, extra); + if ( result !== true && result !== undefined ) { + return result; + } + } + + return true; + } + + async factory (extra) { + const factories = ( + this.chains.factory && [...this.chains.factory].reverse() + ) || []; + + if ( process.env.DEBUG ) { + console.log('FACTORIES', factories); + } + + for ( const factory of factories ) { + const result = await factory(extra); + if ( result !== undefined ) { + return result; + } + } + + return undefined; + } + + async is_set (value) { + const is_setters = this.chains.is_set || []; + + for ( const is_setter of is_setters ) { + const result = await is_setter(value); + if ( ! result ) { + return false; + } + } + + return true; + } +} + +module.exports = { + PropType, +}; diff --git a/src/backend/src/om/definitions/Property.js b/src/backend/src/om/definitions/Property.js new file mode 100644 index 0000000000000000000000000000000000000000..ed8413bdb2d1a74b8d51269f41fae830e8764469 --- /dev/null +++ b/src/backend/src/om/definitions/Property.js @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); + +class Property extends AdvancedBase { + static FEATURES = [ + new WeakConstructorFeature(), + ]; + + static create (context, name, descriptor) { + // Adapt descriptor + if ( typeof descriptor === 'string' ) { + descriptor = { type: descriptor }; + } + + const registry = context.get('registry'); + const types = registry.get('om:proptype'); + const typ = types.get(descriptor['type']); + + if ( ! typ ) { + throw new Error(`Failed to find type "${descriptor['type']}"`); + } + + // NEXT + + return new Property({ name, descriptor, typ }); + } + + constructor (...a) { + super(...a); + } + + async adapt (value) { + const { name, descriptor } = this; + try { + value = await this.typ.adapt(value, { name, descriptor }); + if ( descriptor.adapt && typeof descriptor.adapt === 'function' ) { + value = await descriptor.adapt(value, { name, descriptor }); + } + } catch ( e ) { + throw new Error(`Failed to adapt ${name} to ${descriptor.type}: ${e.message}`); + } + return value; + } + + async sql_dereference (value) { + const { name, descriptor } = this; + return await this.typ.sql_dereference(value, { name, descriptor }); + } + + async sql_reference (value) { + const { name, descriptor } = this; + return await this.typ.sql_reference(value, { name, descriptor }); + } + + async validate (value) { + const { name, descriptor } = this; + if ( this.descriptor.validate ) { + let result = await this.descriptor.validate(value); + if ( result && result !== true ) return result; + } + return await this.typ.validate(value, { name, descriptor }); + } + + async factory () { + const { name, descriptor } = this; + if ( this.descriptor.factory ) { + let value = await this.descriptor.factory(); + if ( value ) return value; + } + return await this.typ.factory({ name, descriptor }); + } + + async is_set (value) { + return await this.typ.is_set(value); + } +} + +module.exports = { + Property, +}; diff --git a/src/backend/src/om/docs/DESIGN.md b/src/backend/src/om/docs/DESIGN.md new file mode 100644 index 0000000000000000000000000000000000000000..35c5044bb94f93dcb9d4fd94467a59ae60fc13a2 --- /dev/null +++ b/src/backend/src/om/docs/DESIGN.md @@ -0,0 +1,19 @@ +## Entity Storage + +### Chain of events + +When `create` is called on an OM/ES driver: +1. The request is handled by `src/routers/drivers/call.js` +2. DriverService's `call` method is called +3. An instance of `EntityStoreImplementation` is called +4. `EntityStoreImplementation` calls the corresponding service, + such as `es:app`, which is an instance of `EntityStoreService` +5. `EntityStoreService` calls the upstream implementation of `BaseES` +6. `BaseES` has a public method which calls the implementor method +7. The implementor method (ex: `SQLES`) handles the operation + +``` +/call -> DriverService + -> EntityStoreImplementation -> EntityStoreService -> BaseES + -> ...(storage decorators) -> SQLES +``` diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js new file mode 100644 index 0000000000000000000000000000000000000000..a8e2a43d8109a6d283b4a976464726ddcc669ec1 --- /dev/null +++ b/src/backend/src/om/entitystorage/AppES.js @@ -0,0 +1,404 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const config = require('../../config'); +const { app_name_exists, refresh_apps_cache } = require('../../helpers'); + +const { AppUnderUserActorType } = require('../../services/auth/Actor'); +const { DB_WRITE } = require('../../services/database/consts'); +const { Context } = require('../../util/context'); +const { origin_from_url } = require('../../util/urlutil'); +const { Eq, Like, Or, And } = require('../query/query'); +const { BaseES } = require('./BaseES'); + +const uuidv4 = require('uuid').v4; + +class AppES extends BaseES { + static METHODS = { + async _on_context_provided () { + const services = this.context.get('services'); + this.db = services.get('database').get(DB_WRITE, 'apps'); + }, + + /** + * Creates query predicates for filtering apps + * @param {string} id - Predicate identifier + * @param {...any} args - Additional arguments for predicate creation + * @returns {Promise} Query predicate object + */ + async create_predicate (id, ...args) { + if ( id === 'user-can-edit' ) { + return new Eq({ + key: 'owner', + value: Context.get('user').id, + }); + } + if ( id === 'name-like' ) { + return new Like({ + key: 'name', + value: args[0], + }); + } + }, + async delete (uid, extra) { + const svc_appInformation = this.context.get('services').get('app-information'); + await svc_appInformation.delete_app(uid); + }, + + /** + * Filters app selection based on user permissions and visibility settings + * @param {Object} options - Selection options including predicates + * @returns {Promise} Filtered selection results + */ + async select (options) { + const actor = Context.get('actor'); + const user = actor.type.user; + + const additional = []; + + // An app is also allowed to read itself + if ( actor.type instanceof AppUnderUserActorType ) { + additional.push(new Eq({ + key: 'uid', + value: actor.type.app.uid, + })); + } + + options.predicate = options.predicate.and(new Or({ + children: [ + new Eq({ + key: 'approved_for_listing', + value: 1, + }), + new Eq({ + key: 'owner', + value: user.id, + }), + ...additional, + ], + })); + + return await this.upstream.select(options); + }, + + /** + * Creates or updates an application with proper name handling and associations + * @param {Object} entity - Application entity to upsert + * @param {Object} extra - Additional upsert parameters + * @returns {Promise} Upsert operation results + */ + async upsert (entity, extra) { + const actor = Context.get('actor'); + const user = actor?.type?.user; + + const full_entity = extra.old_entity + ? await (await extra.old_entity.clone()).apply(entity) + : entity + ; + + await this.ensure_puter_site_subdomain_is_owned_(full_entity, extra, user); + + if ( await app_name_exists(await entity.get('name')) ) { + const { old_entity } = extra; + const is_name_change = ( !old_entity ) || + ( await old_entity.get('name') !== await entity.get('name') ); + if ( is_name_change && extra?.options?.dedupe_name ) { + const base = await entity.get('name'); + let number = 1; + while ( await app_name_exists(`${base}-${number}`) ) { + number++; + } + await entity.set('name', `${base}-${number}`); + } + else if ( is_name_change ) { + // The name might be taken because it's the old name + // of this same app. If it is, the app takes it back. + const svc_oldAppName = this.context.get('services').get('old-app-name'); + const name_info = await svc_oldAppName.check_app_name(await entity.get('name')); + if ( !name_info || name_info.app_uid !== await entity.get('uid') ) { + // Throw error because the name really is taken + throw APIError.create('app_name_already_in_use', null, { + name: await entity.get('name'), + }); + } + + // Remove the old name from the old-app-name service + await svc_oldAppName.remove_name(name_info.id); + } else { + entity.del('name'); + } + } + + const subdomain_id = await this.maybe_insert_subdomain_(entity); + const result = await this.upstream.upsert(entity, extra); + const { insert_id } = result; + + // Remove old file associations (if applicable) + if ( extra.old_entity ) { + await this.db.write('DELETE FROM app_filetype_association WHERE app_id = ?', + [insert_id]); + } + + // Add file associations (if applicable) + const filetype_associations = await entity.get('filetype_associations'); + if ( (a => a && a.length > 0)(filetype_associations) ) { + const stmt = + 'INSERT INTO app_filetype_association ' + + `(app_id, type) VALUES ${ + filetype_associations.map(() => '(?, ?)').join(', ')}`; + const rows = filetype_associations.map(a => [insert_id, a.toLowerCase()]); + await this.db.write(stmt, rows.flat()); + } + + const has_new_icon = + ( !extra.old_entity ) || ( + await entity.get('icon') !== await extra.old_entity.get('icon') + ); + + if ( has_new_icon ) { + const svc_event = this.context.get('services').get('event'); + const event = { + app_uid: await entity.get('uid'), + data_url: await entity.get('icon'), + }; + await svc_event.emit('app.new-icon', event); + if ( event.url ) { + await entity.set('icon'); + } + } + + const has_new_name = + extra.old_entity && ( + await entity.get('name') !== await extra.old_entity.get('name') + ); + + if ( has_new_name ) { + const svc_event = this.context.get('services').get('event'); + const event = { + app_uid: await entity.get('uid'), + new_name: await entity.get('name'), + old_name: await extra.old_entity.get('name'), + }; + await svc_event.emit('app.rename', event); + } + + // Associate app with subdomain (if applicable) + if ( subdomain_id ) { + await this.db.write('UPDATE subdomains SET associated_app_id = ? WHERE id = ?', + [insert_id, subdomain_id]); + } + + const owner = extra.old_entity + ? await extra.old_entity.get('owner') + : await entity.get('owner'); + + { + // Update app cache + const raw_app = { + // These map to different names + uuid: await full_entity.get('uid'), + owner_user_id: owner.id, + + // These map to the same names + name: await full_entity.get('name'), + title: await full_entity.get('title'), + description: await full_entity.get('description'), + icon: await full_entity.get('icon'), + index_url: await full_entity.get('index_url'), + maximize_on_start: await full_entity.get('maximize_on_start'), + }; + + refresh_apps_cache({ uid: raw_app.uuid }, raw_app); + } + + return result; + }, + async retry_predicate_rewrite ({ predicate }) { + const recurse = async (predicate) => { + if ( predicate instanceof Or ) { + return new Or({ + children: await Promise.all(predicate.children.map(recurse)), + }); + } + if ( predicate instanceof And ) { + return new And({ + children: await Promise.all(predicate.children.map(recurse)), + }); + } + if ( predicate instanceof Eq ) { + if ( predicate.key === 'name' ) { + const svc_oldAppName = this.context.get('services').get('old-app-name'); + const name_info = await svc_oldAppName.check_app_name(predicate.value); + return new Eq({ + key: 'uid', + value: name_info?.app_uid, + }); + } + } + }; + return await recurse(predicate); + }, + + /** + * Transforms app data before reading by adding associations and handling permissions + * @param {Object} entity - App entity to transform + */ + async read_transform (entity) { + // Add file associations + const rows = await this.db.read('SELECT type FROM app_filetype_association WHERE app_id = ?', + [entity.private_meta.mysql_id]); + entity.set('filetype_associations', rows.map(row => row.type)); + + const svc_appInformation = this.context.get('services').get('app-information'); + const stats = await svc_appInformation.get_stats(await entity.get('uid'), { period: Context.get('es_params')?.stats_period, grouping: Context.get('es_params')?.stats_grouping, created_at: await entity.get('created_at') }); + entity.set('stats', stats); + + entity.set('created_from_origin', await (async () => { + const svc_auth = this.context.get('services').get('auth'); + try { + const origin = origin_from_url(await entity.get('index_url')); + const expected_uid = await svc_auth.app_uid_from_origin(origin); + return expected_uid === await entity.get('uid') + ? origin : null ; + } catch (e) { + // This happens when the index_url is not a valid URL + return null; + } + })()); + + // Check if the user is the owner + const is_owner = await (async () => { + let owner = await entity.get('owner'); + + // TODO: why does this happen? + if ( typeof owner === 'number' ) { + owner = { id: owner }; + } + + if ( ! owner ) return false; + const actor = Context.get('actor'); + return actor.type.user.id === owner.id; + })(); + + // Remove fields that are not allowed for non-owners + if ( ! is_owner ) { + entity.del('approved_for_listing'); + entity.del('approved_for_opening_items'); + entity.del('approved_for_incentive_program'); + } + + // Replace icon if an icon size is specified + const icon_size = Context.get('es_params')?.icon_size; + if ( icon_size ) { + const svc_appIcon = this.context.get('services').get('app-icon'); + try { + const icon_result = await svc_appIcon.get_icon_stream({ + app_uid: await entity.get('uid'), + app_icon: await entity.get('icon'), + size: icon_size, + }); + await entity.set('icon', await icon_result.get_data_url()); + } catch (e) { + const svc_error = this.context.get('services').get('error-service'); + svc_error.report('AppES:read_transform', { source: e }); + } + } + }, + + /** + * Creates a subdomain entry for the app if required + * @param {Object} entity - App entity + * @returns {Promise} Subdomain ID if created + * @private + */ + async maybe_insert_subdomain_ (entity) { + // Create and update is a situation where we might create a subdomain + + let subdomain_id; + if ( await entity.get('source_directory') ) { + await (await entity.get('source_directory') + ).fetchEntry(); + const subdomain = await entity.get('subdomain'); + const user = Context.get('user'); + let subdomain_res = await this.db.write(`INSERT ${this.db.case({ + mysql: 'IGNORE', + sqlite: 'OR IGNORE', + })} INTO subdomains + (subdomain, user_id, root_dir_id, uuid) VALUES + ( ?, ?, ?, ?)`, + [ + //subdomain + subdomain, + //user_id + user.id, + //root_dir_id + (await entity.get('source_directory')).mysql_id, + //uuid, `sd` stands for subdomain + `sd-${ uuidv4()}`, + ]); + subdomain_id = subdomain_res.insertId; + } + + return subdomain_id; + }, + + /** + * Ensures that when an app uses a puter.site subdomain as its index_url, + * the subdomain belongs to the user creating/updating the app. + */ + async ensure_puter_site_subdomain_is_owned_ (entity, extra, user) { + if ( ! user ) return; + + // Only enforce when the index_url is being set or changed + const new_index_url = await entity.get('index_url'); + if ( ! new_index_url ) return; + if ( extra.old_entity ) { + const old_index_url = await extra.old_entity.get('index_url'); + if ( old_index_url === new_index_url ) { + return; + } + } + + let hostname; + try { + hostname = (new URL(new_index_url)).hostname.toLowerCase(); + } catch { + return; + } + + const hosting_domain = config.static_hosting_domain?.toLowerCase(); + if ( ! hosting_domain ) return; + + const suffix = `.${hosting_domain}`; + if ( ! hostname.endsWith(suffix) ) return; + + const subdomain = hostname.slice(0, hostname.length - suffix.length); + if ( ! subdomain ) return; + + const svc_puterSite = this.context.get('services').get('puter-site'); + const site = await svc_puterSite.get_subdomain(subdomain, { is_custom_domain: false }); + + if ( ! site || site.user_id !== user.id ) { + throw APIError.create('subdomain_not_owned', null, { subdomain }); + } + }, + }; +} + +module.exports = AppES; diff --git a/src/backend/src/om/entitystorage/AppLimitedES.js b/src/backend/src/om/entitystorage/AppLimitedES.js new file mode 100644 index 0000000000000000000000000000000000000000..0fdbf2dc66912eb76b0d96b11f692c6a8820b361 --- /dev/null +++ b/src/backend/src/om/entitystorage/AppLimitedES.js @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { AppUnderUserActorType } = require('../../services/auth/Actor'); +const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs'); +const { Context } = require('../../util/context'); +const { Eq, Or } = require('../query/query'); +const { BaseES } = require('./BaseES'); +const { Entity } = require('./Entity'); + +class AppLimitedES extends BaseES { + + // #region read operations + + // Limit selection to entities owned by the app of the current actor. + async select (options) { + const actor = Context.get('actor'); + + app_under_user_check: + if ( actor.type instanceof AppUnderUserActorType ) { + const svc_permission = Context.get('services').get('permission'); + const perm = PermissionUtil.join(this.permission_prefix, actor.type.user.uuid, 'read'); + const can_read_any = await svc_permission.check(actor, perm); + + if ( can_read_any ) break app_under_user_check; + + if ( this.exception && typeof this.exception === 'function' ) { + this.exception = await this.exception(); + } + + let condition = new Eq({ + key: 'app_owner', + value: actor.type.app, + }); + if ( this.exception ) { + condition = new Or({ + children: [ + condition, + this.exception, + ], + }); + } + options.predicate = options.predicate.and(condition); + } + + return await this.upstream.select(options); + } + + // Limit read to entities owned by the app of the current actor. + async read (uid) { + const entity = await this.upstream.read(uid); + if ( ! entity ) return null; + + const actor = Context.get('actor'); + + if ( actor.type instanceof AppUnderUserActorType ) { + if ( this.exception && typeof this.exception === 'function' ) { + this.exception = await this.exception(); + } + + // On the exception, we don't have to check app_owner + // (for `es:apps` this is `approved_for_listing == 1`) + if ( this.exception && await entity.check(this.exception) ) { + return entity; + } + + const app = actor.type.app; + const app_owner = await entity.get('app_owner'); + let app_owner_id = app_owner?.id; + if ( app_owner instanceof Entity ) { + app_owner_id = app_owner.private_meta.mysql_id; + } + if ( ( !app_owner ) || app_owner_id !== app.id ) { + return null; + } + } + + return entity; + } + + // #endregion + + // #region write operations + + // Limit edit to entities owned by the app of the current actor + async upsert (entity, extra) { + const actor = Context.get('actor'); + if ( actor.type instanceof AppUnderUserActorType ) { + const { old_entity } = extra; + if ( old_entity ) { + await this._check_edit_allowed({ old_entity }); + } + } + return await this.upstream.upsert(entity, extra); + } + async delete (uid, extra) { + const actor = Context.get('actor'); + if ( actor.type instanceof AppUnderUserActorType ) { + const { old_entity } = extra; + await this._check_edit_allowed({ old_entity }); + } + return await this.upstream.delete(uid, extra); + } + async _check_edit_allowed ({ old_entity }) { + const actor = Context.get('actor'); + + // Maybe the app has been granted write access to all the user's apps + // (in which case we return early) + { + const svc_permission = Context.get('services').get('permission'); + const perm = PermissionUtil.join(this.permission_prefix, actor.type.user.uuid, 'write'); + const can_write_any = await svc_permission.check(actor, perm); + if ( can_write_any ) return; + } + + // Otherwise, verify the app owner + // (or we throw an APIError) + { + const app = actor.type.app; + const app_owner = await old_entity.get('app_owner'); + let app_owner_id = app_owner?.id; + if ( app_owner instanceof Entity ) { + app_owner_id = app_owner.private_meta.mysql_id; + } + if ( ( !app_owner ) || app_owner_id !== app.id ) { + throw APIError.create('forbidden'); + } + } + } + // #endregion +} + +module.exports = { + AppLimitedES, +}; diff --git a/src/backend/src/om/entitystorage/BaseES.js b/src/backend/src/om/entitystorage/BaseES.js new file mode 100644 index 0000000000000000000000000000000000000000..806a53c16fb5079f490de42cde5e62e4afe110a3 --- /dev/null +++ b/src/backend/src/om/entitystorage/BaseES.js @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); + +/** + * BaseES is a base class for Entity Store classes. + */ +class BaseES extends AdvancedBase { + static FEATURES = [ + new WeakConstructorFeature(), + ]; + + // Default implementations + static METHODS = { + async upsert (entity, extra) { + if ( ! this.upstream ) { + throw Error('Missing terminal operation'); + } + return await this.upstream.upsert(entity, extra); + }, + async read (uid) { + if ( ! this.upstream ) { + throw Error('Missing terminal operation'); + } + return await this.upstream.read(uid); + }, + async delete (uid, extra) { + if ( ! this.upstream ) { + throw Error('Missing terminal operation'); + } + return await this.upstream.delete(uid, extra); + }, + async select (options) { + if ( ! this.upstream ) { + throw Error('Missing terminal operation'); + } + return await this.upstream.select(options); + }, + async create_predicate (id, ...args) { + if ( ! this.upstream ) { + throw Error('Missing terminal operation'); + } + return await this.upstream.create_predicate(id, ...args); + }, + }; + + constructor (...a) { + super(...a); + + const public_wrappers = [ + 'upsert', 'read', 'delete', 'select', + 'read_transform', + 'retry_predicate_rewrite', + ]; + + this.impl_methods = this._get_merged_static_object('METHODS'); + + for ( const k in this.impl_methods ) { + // Some methods are part of the implicit EntityStorage interface. + // We won't let the implementor override these; instead we + // provide a delegating implementation where they override a + // lower-level method of the same name. + if ( public_wrappers.includes(k) ) continue; + + this[k] = this.impl_methods[k]; + } + } + + async provide_context ( args ) { + for ( const k in args ) this[k] = args[k]; + if ( this.upstream ) { + await this.upstream.provide_context(args); + } + if ( this._on_context_provided ) { + await this._on_context_provided(args); + } + } + async read (uid) { + let entity = await this.call_on_impl_('read', uid); + if ( ! entity ) { + const retry_predicate = await this.retry_predicate_rewrite(uid); + if ( retry_predicate ) { + entity = await this.call_on_impl_('read', + { predicate: retry_predicate }); + } + } + if ( ! this.impl_methods.read_transform ) return entity; + return await this.read_transform(entity); + } + async upsert (entity, extra) { + return await this.call_on_impl_('upsert', entity, extra ?? {}); + } + async delete (uid, extra) { + return await this.call_on_impl_('delete', uid, extra ?? {}); + } + + async select (options) { + + const results = await this.call_on_impl_('select', options); + if ( ! this.impl_methods.read_transform ) return results; + + // Promises "solved callback hell" but like... + return await Promise.all(results.map(async entity => { + return await this.read_transform(entity); + })); + } + + async retry_predicate_rewrite ({ predicate }) { + if ( ! this.impl_methods.retry_predicate_rewrite ) return; + return await this.call_on_impl_('retry_predicate_rewrite', { predicate }); + } + + async read_transform (entity) { + if ( ! entity ) return entity; + if ( ! this.impl_methods.read_transform ) return entity; + const maybe_entity = await this.call_on_impl_('read_transform', entity); + if ( ! maybe_entity ) return entity; + return maybe_entity; + } + + call_on_impl_ (method_name, ...args) { + // const pseudo_this = { ...this }; + // pseudo_this.next = this.upstream?.call_on_impl?.bind(this.upstream, method_name); + return this.impl_methods[method_name].call(this, ...args); + } +} + +module.exports = { + BaseES, +}; diff --git a/src/backend/src/om/entitystorage/ESBuilder.js b/src/backend/src/om/entitystorage/ESBuilder.js new file mode 100644 index 0000000000000000000000000000000000000000..d96bb6ce420dbbdbe1e8f40933e6c0bd705bf39c --- /dev/null +++ b/src/backend/src/om/entitystorage/ESBuilder.js @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +class ESBuilder { + static create (list) { + let stack = []; + let head = null; + const apply_next = () => { + const args = []; + let last_was_cons = false; + while ( !last_was_cons ) { + const item = stack.pop(); + if ( typeof item === 'function' ) { + last_was_cons = true; + } + args.unshift(item); + } + + const cls = args.shift(); + head = new cls({ + ...(args[0] ?? {}), + ...(head ? { upstream: head } : {}), + }); + }; + for ( const item of list ) { + const is_cons = typeof item === 'function'; + + if ( is_cons ) { + if ( stack.length > 0 ) apply_next(); + } + + stack.push(item); + } + + if ( stack.length > 0 ) apply_next(); + + // Print the classes in order + let current = head; + while ( current ) { + current = current.upstream; + } + + return head; + } +} + +module.exports = { + ESBuilder, +}; diff --git a/src/backend/src/om/entitystorage/Entity.js b/src/backend/src/om/entitystorage/Entity.js new file mode 100644 index 0000000000000000000000000000000000000000..c1f6a8afe97a8f8aff59ddf940d0ebc2e98bd482 --- /dev/null +++ b/src/backend/src/om/entitystorage/Entity.js @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); + +class Entity extends AdvancedBase { + static FEATURES = [ + new WeakConstructorFeature(), + ]; + + constructor (args) { + super(args); + this.init_arg_keys_ = Object.keys(args); + + this.found = undefined; + this.private_meta = {}; + + this.values_ = {}; + } + + static async create (args, data) { + const entity = new Entity(args); + + for ( const prop of Object.values(args.om.properties) ) { + if ( ! data.hasOwnProperty(prop.name) ) continue; + + await entity.set(prop.name, data[prop.name]); + } + + return entity; + } + + async clone () { + const args = {}; + for ( const k of this.init_arg_keys_ ) { + args[k] = this[k]; + } + const entity = new Entity(args); + + const BEHAVIOUR = 'A'; + + if ( BEHAVIOUR === 'A' ) { + entity.found = this.found; + entity.private_meta = { ...this.private_meta }; + entity.values_ = { ...this.values_ }; + } + if ( BEHAVIOUR === 'B' ) { + for ( const prop of Object.values(this.om.properties) ) { + if ( ! this.has(prop.name) ) continue; + + await entity.set(prop.name, await this.get(prop.name)); + } + } + + return entity; + } + + async apply (other) { + for ( const prop of Object.values(this.om.properties) ) { + if ( ! await other.has(prop.name) ) continue; + await this.set(prop.name, await other.get(prop.name)); + } + + return this; + } + + async set (key, value) { + const prop = this.om.properties[key]; + if ( ! prop ) { + throw Error(`property ${key} unrecognized`); + } + this.values_[key] = await prop.adapt(value); + } + + async get (key) { + const prop = this.om.properties[key]; + if ( ! prop ) { + throw Error(`property ${key} unrecognized`); + } + let value = this.values_[key]; + let is_set = await prop.is_set(value); + + // If value is not set but we have a factory, use it. + if ( ! is_set ) { + value = await prop.factory(); + value = await prop.adapt(value); + is_set = await prop.is_set(value); + if ( is_set ) this.values_[key] = value; + } + + // If value is not set but we have an implicator, use it. + if ( !is_set && prop.descriptor.imply ) { + const { given, make } = prop.descriptor.imply; + let imply_available = true; + for ( const g of given ) { + if ( ! await this.has(g) ) { + imply_available = false; + break; + } + } + if ( imply_available ) { + value = await make(this.values_); + value = await prop.adapt(value); + is_set = await prop.is_set(value); + } + if ( is_set ) this.values_[key] = value; + } + + return value; + } + + async del (key) { + const prop = this.om.properties[key]; + if ( ! prop ) { + throw Error(`property ${key} unrecognized`); + } + delete this.values_[key]; + } + + async has (key) { + const prop = this.om.properties[key]; + if ( ! prop ) { + throw Error(`property ${key} unrecognized`); + } + return await prop.is_set(await this.get(key)); + } + + async check (condition) { + return await condition.check(this); + } + + om_has_property (key) { + return this.om.properties.hasOwnProperty(key); + } + + // alias for `has` + async is_set (key) { + return await this.has(key); + } + + async get_client_safe () { + return await this.om.get_client_safe(this.values_); + } +} + +module.exports = { + Entity, +}; diff --git a/src/backend/src/om/entitystorage/MaxLimitES.js b/src/backend/src/om/entitystorage/MaxLimitES.js new file mode 100644 index 0000000000000000000000000000000000000000..9da90c01f269a2b051052bbdb3187a43a4ead1c9 --- /dev/null +++ b/src/backend/src/om/entitystorage/MaxLimitES.js @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { BaseES } = require('./BaseES'); + +class MaxLimitES extends BaseES { + static METHODS = { + async select (options) { + let limit = options.limit; + + // `limit` is numeric but a value of 0 doesn't make sense, + // so we can treat 0 and undefined as the same case. + if ( ! limit ) { + limit = this.max; + } + + if ( limit > this.max ) { + limit = this.max; + } + + options.limit = limit; + + return await this.upstream.select(options); + }, + }; +} + +module.exports = { + MaxLimitES, +}; diff --git a/src/backend/src/om/entitystorage/NotificationES.js b/src/backend/src/om/entitystorage/NotificationES.js new file mode 100644 index 0000000000000000000000000000000000000000..922dbaca2c4d334869a157826a85345129daa1ca --- /dev/null +++ b/src/backend/src/om/entitystorage/NotificationES.js @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { Eq, IsNotNull } = require('../query/query'); +const { BaseES } = require('./BaseES'); + +class NotificationES extends BaseES { + static METHODS = { + async create_predicate (id) { + if ( id === 'unseen' ) { + return new Eq({ + key: 'shown', + value: null, + }).and(new Eq({ + key: 'acknowledge', + value: null, + })); + } + if ( id === 'unacknowledge' ) { + return new Eq({ + key: 'acknowledge', + value: null, + }); + } + if ( id === 'acknowledge' ) { + return new IsNotNull({ + key: 'acknowledge', + }); + } + }, + async read_transform (entity) { + let value = await entity.get('value'); + if ( typeof value === 'string' ) { + value = JSON.parse(value); + } + if ( ! value ) { + value = {}; + } + await entity.set('value', value); + }, + }; +} + +module.exports = { NotificationES }; \ No newline at end of file diff --git a/src/backend/src/om/entitystorage/OwnerLimitedES.js b/src/backend/src/om/entitystorage/OwnerLimitedES.js new file mode 100644 index 0000000000000000000000000000000000000000..5d534c3b257d48bed9542b933970ba97e67990be --- /dev/null +++ b/src/backend/src/om/entitystorage/OwnerLimitedES.js @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { UserActorType } = require('../../services/auth/Actor'); +const { Context } = require('../../util/context'); +const { Eq } = require('../query/query'); +const { BaseES } = require('./BaseES'); + +class OwnerLimitedES extends BaseES { + // Limit selection to entities owned by the app of the current actor. + async select (options) { + const actor = Context.get('actor'); + + if ( ! (actor.type instanceof UserActorType) ) { + return []; + } + + let condition = new Eq({ + key: 'owner', + value: actor.type.user.id, + }); + + options.predicate = options.predicate?.and + ? options.predicate.and(condition) + : condition; + + return await this.upstream.select(options); + } + + // Limit read to entities owned by the app of the current actor. + async read (uid) { + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + return null; + } + + const entity = await this.upstream.read(uid); + if ( ! entity ) return null; + + const entity_owner = await entity.get('owner'); + let owner_id = entity_owner?.id; + if ( entity_owner.id !== actor.type.user.id ) { + return null; + } + + return entity; + } +} + +module.exports = { + OwnerLimitedES, +}; diff --git a/src/backend/src/om/entitystorage/ProtectedAppES.js b/src/backend/src/om/entitystorage/ProtectedAppES.js new file mode 100644 index 0000000000000000000000000000000000000000..c9fd59c28d728815fe524882ea817037464a60c9 --- /dev/null +++ b/src/backend/src/om/entitystorage/ProtectedAppES.js @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AppUnderUserActorType, UserActorType } = require('../../services/auth/Actor'); +const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs'); +const { Context } = require('../../util/context'); +const { BaseES } = require('./BaseES'); + +class ProtectedAppES extends BaseES { + async select (options) { + const results = await this.upstream.select(options); + + const actor = Context.get('actor'); + const services = Context.get('services'); + + for ( let i = 0 ; i < results.length ; i++ ) { + const entity = results[i]; + + if ( ! await this.check_({ actor, services }, entity) ) { + continue; + } + results[i] = undefined; + } + + return results.filter(e => e !== undefined); + } + + async read (uid) { + const entity = await this.upstream.read(uid); + if ( ! entity ) return null; + + const actor = Context.get('actor'); + const services = Context.get('services'); + + if ( await this.check_({ actor, services }, entity) ) { + return null; + } + + return entity; + } + + /** + * returns true if the entity should not be sent downstream + */ + async check_ ({ actor, services }, entity) { + // track: ruleset + { + // if it's not a protected app, no worries + if ( ! await entity.get('protected') ) return; + + // if actor is this app, no worries + if ( + actor.type instanceof AppUnderUserActorType && + await entity.get('uid') === actor.type.app.uid + ) return; + + // if actor is owner of this app, no worries + if ( + actor.type instanceof UserActorType && + (await entity.get('owner')).id === actor.type.user.id + ) return; + } + + // now we need to check for permission + const app_uid = await entity.get('uid'); + const svc_permission = services.get('permission'); + const permission_to_check = `app:uid#${app_uid}:access`; + const reading = await svc_permission.scan(actor, permission_to_check); + const options = PermissionUtil.reading_to_options(reading); + + if ( options.length > 0 ) return; + + // `true` here means "do not send downstream" + return true; + } +}; + +module.exports = { + ProtectedAppES, +}; diff --git a/src/backend/src/om/entitystorage/ReadOnlyES.js b/src/backend/src/om/entitystorage/ReadOnlyES.js new file mode 100644 index 0000000000000000000000000000000000000000..35bb2fdaf83b0aa3348e9858eaf05921364a395b --- /dev/null +++ b/src/backend/src/om/entitystorage/ReadOnlyES.js @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { BaseES } = require('./BaseES'); + +class ReadOnlyES extends BaseES { + async upsert () { + throw APIError.create('forbidden'); + } + async delete () { + throw APIError.create('forbidden'); + } +} + +module.exports = ReadOnlyES; diff --git a/src/backend/src/om/entitystorage/SQLES.js b/src/backend/src/om/entitystorage/SQLES.js new file mode 100644 index 0000000000000000000000000000000000000000..e0a7349b7c3850fe63c85cf8268f4b32338c12a7 --- /dev/null +++ b/src/backend/src/om/entitystorage/SQLES.js @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { BaseES } = require('./BaseES'); + +const APIError = require('../../api/APIError'); +const { Entity } = require('./Entity'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); +const { And, Or, Eq, Like, Null, Predicate, PredicateUtil, IsNotNull, StartsWith } = require('../query/query'); +const { DB_WRITE } = require('../../services/database/consts'); + +class RawCondition extends AdvancedBase { + // properties: sql:string, values:any[] + static FEATURES = [ + new WeakConstructorFeature(), + ]; +} + +class SQLES extends BaseES { + async _on_context_provided () { + const services = this.context.get('services'); + this.db = services.get('database').get(DB_WRITE, 'entity-storage'); + } + static METHODS = { + async create_predicate (id, args) { + if ( id === 'raw-sql-condition' ) { + return new RawCondition(args); + } + }, + async read (uid) { + + const [stmt_where, where_vals] = await (async () => { + if ( typeof uid !== 'object' ) { + const id_prop = + this.om.properties[this.om.primary_identifier]; + let id_col = + id_prop.descriptor.sql?.column_name ?? id_prop.name; + // Temporary hack until multiple identifiers are supported + // (allows us to query using an internal ID; users can't do this) + if ( typeof uid === 'number' ) { + id_col = 'id'; + } + return [` WHERE ${id_col} = ?`, [uid]]; + } + + if ( ! uid.hasOwnProperty('predicate') ) { + throw new Error('SQLES.read does not understand this input: ' + + 'object with no predicate property'); + } + let predicate = uid.predicate; // uid is actually a predicate + if ( predicate instanceof Predicate ) { + predicate = await this.om_to_sql_condition_(predicate); + } + const stmt_where = ` WHERE ${predicate.sql} LIMIT 1` ; + const where_vals = predicate.values; + return [stmt_where, where_vals]; + })(); + + const stmt = + `SELECT * FROM ${this.om.sql.table_name}${stmt_where}`; + + const rows = await this.db.read(stmt, where_vals); + + if ( rows.length === 0 ) { + return null; + } + + const data = rows[0]; + const entity = await this.sql_row_to_entity_(data); + + return entity; + }, + + async select ({ predicate, limit, offset }) { + if ( predicate instanceof Predicate ) { + predicate = await this.om_to_sql_condition_(predicate); + } + + const stmt_where = predicate ? ` WHERE ${predicate.sql}` : ''; + + let stmt = + `SELECT * FROM ${this.om.sql.table_name}${stmt_where}`; + + if ( offset !== undefined && limit === undefined ) { + throw new Error('Cannot use offset without limit'); + } + + if ( limit ) { + stmt += ` LIMIT ${limit}`; + } + if ( offset ) { + stmt += ` OFFSET ${offset}`; + } + + const values = []; + if ( predicate ) values.push(...(predicate.values || [])); + + const rows = await this.db.read(stmt, values); + + const entities = []; + for ( const data of rows ) { + const entity = await this.sql_row_to_entity_(data); + entities.push(entity); + } + + return entities; + }, + + async upsert (entity, extra) { + const { old_entity } = extra; + + // Check unique constraints + for ( const prop of Object.values(this.om.properties) ) { + const options = prop.descriptor.sql ?? {}; + if ( ! prop.descriptor.unique ) continue; + + const col_name = options.column_name ?? prop.name; + const value = await entity.get(prop.name); + + const values = []; + let stmt = + `SELECT COUNT(*) FROM ${this.om.sql.table_name} WHERE ${col_name} = ?`; + values.push(value); + + if ( old_entity ) { + stmt += ' AND id != ?'; + values.push(old_entity.private_meta.mysql_id); + } + + const rows = await this.db.read(stmt, values); + const count = rows[0]['COUNT(*)']; + + if ( count > 0 ) { + throw APIError.create('already_in_use', null, { + what: prop.name, + value, + }); + } + } + + // Update or create + if ( old_entity ) { + const result = await this.update_(entity, old_entity); + result.insert_id = old_entity.private_meta.mysql_id; + return result; + } else { + return await this.create_(entity); + } + }, + + async delete (uid, extra) { + const id_prop = this.om.properties[this.om.primary_identifier]; + let id_col = + id_prop.descriptor.sql?.column_name ?? id_prop.name; + + const stmt = + `DELETE FROM ${this.om.sql.table_name} WHERE ${id_col} = ?`; + + const res = await this.db.write(stmt, [uid]); + + if ( ! res.anyRowsAffected ) { + throw APIError.create('entity_not_found', null, { + 'identifier': uid, + }); + } + + return { + data: {}, + }; + }, + + async sql_row_to_entity_ (data) { + const entity_data = {}; + for ( const prop of Object.values(this.om.properties) ) { + const options = prop.descriptor.sql ?? {}; + + if ( options.ignore ) { + continue; + } + + const col_name = options.column_name ?? prop.name; + + if ( ! data.hasOwnProperty(col_name) ) { + continue; + } + + let value = data[col_name]; + value = await prop.sql_dereference(value); + + // TODO: This is not an ideal implementation, + // but this is only 6 lines of code so doing this + // "properly" is not sensible at this time. + // + // This is here because: + // - SQLES has access to the "db" object + // + // Writing this in `json`'s `sql_reference` method + // is also not ideal because that places the concern + // of supporting different database backends to + // property types. + // + // Best solution: SQLES has a SQLRefinements by + // composition. This SQLRefinements is applied + // to property types for the duration of this + // function. + if ( prop.typ.name === 'json' ) { + value = this.db.case({ + mysql: () => value, + otherwise: () => JSON.parse(value ?? '{}'), + })(); + } + + entity_data[prop.name] = value; + } + const entity = await Entity.create({ om: this.om }, entity_data); + entity.private_meta.mysql_id = data.id; + return entity; + }, + + async create_ (entity) { + const sql_data = await this.get_sql_data_(entity); + + const sql_cols = Object.keys(sql_data).join(', '); + const sql_placeholders = Object.keys(sql_data).map(() => '?').join(', '); + const execute_vals = Object.values(sql_data); + + const stmt = + `INSERT INTO ${this.om.sql.table_name} (${sql_cols}) VALUES (${sql_placeholders})`; + + // Very useful when debugging! Keep these here but commented out. + // console.log('SQL STMT', stmt); + // console.log('SQL VALS', execute_vals); + + const res = await this.db.write(stmt, execute_vals); + + return { + data: sql_data, + entity, + insert_id: res.insertId, + }; + }, + async update_ (entity, old_entity) { + const sql_data = await this.get_sql_data_(entity); + const id_value = await entity.get(this.om.primary_identifier); + delete sql_data[this.om.primary_identifier]; + + const sql_assignments = Object.keys(sql_data).map((col_name) => { + return `${col_name} = ?`; + }).join(', '); + const execute_vals = Object.values(sql_data); + + const id_prop = this.om.properties[this.om.primary_identifier]; + const id_col = + id_prop.descriptor.sql?.column_name ?? id_prop.name; + + const stmt = + `UPDATE ${this.om.sql.table_name} SET ${sql_assignments} WHERE ${id_col} = ?`; + + execute_vals.push(id_value); + + // Very useful when debugging! Keep these here but commented out. + // console.log('SQL STMT', stmt); + // console.log('SQL VALS', execute_vals); + + await this.db.write(stmt, execute_vals); + + const full_entity = await (await old_entity.clone()).apply(entity); + + return { + data: sql_data, + entity: full_entity, + }; + }, + + async get_sql_data_ (entity) { + const sql_data = {}; + + for ( const prop of Object.values(this.om.properties) ) { + const options = prop.descriptor.sql ?? {}; + + if ( ! await entity.has(prop.name) ) { + continue; + } + + if ( options.ignore ) { + continue; + } + + const col_name = options.column_name ?? prop.name; + let value = await entity.get(prop.name); + if ( value === undefined ) { + continue; + } + + value = await prop.sql_reference(value); + + // TODO: This is done here for consistency; + // see the larger comment in sql_row_to_entity_ + // which does the reverse operation. + if ( prop.typ.name === 'json' ) { + value = JSON.stringify(value); + } + + if ( value && options.use_id ) { + if ( value.hasOwnProperty('id') ) { + value = value.id; + } + } + + sql_data[col_name] = value; + } + + return sql_data; + }, + + async om_to_sql_condition_ (om_query) { + om_query = PredicateUtil.simplify(om_query); + + if ( om_query instanceof Null ) { + return undefined; + } + + if ( om_query instanceof And ) { + const child_raw_conditions = []; + const values = []; + for ( const child of om_query.children ) { + // if ( child instanceof Null ) continue; + const sql_condition = await this.om_to_sql_condition_(child); + child_raw_conditions.push(sql_condition.sql); + values.push(...(sql_condition.values || [])); + } + + const sql = child_raw_conditions.map((sql) => { + return `(${sql})`; + }).join(' AND '); + + return new RawCondition({ sql, values }); + } + + if ( om_query instanceof Or ) { + const child_raw_conditions = []; + const values = []; + for ( const child of om_query.children ) { + // if ( child instanceof Null ) continue; + const sql_condition = await this.om_to_sql_condition_(child); + child_raw_conditions.push(sql_condition.sql); + values.push(...(sql_condition.values || [])); + } + + const sql = child_raw_conditions.map((sql) => { + return `(${sql})`; + }).join(' OR '); + + return new RawCondition({ sql, values }); + } + + if ( om_query instanceof Eq ) { + const key = om_query.key; + let value = om_query.value; + const prop = this.om.properties[key]; + + value = await prop.sql_reference(value); + + const options = prop.descriptor.sql ?? {}; + const col_name = options.column_name ?? prop.name; + + const sql = value === null ? `${col_name} IS NULL` : `${col_name} = ?`; + const values = value === null ? [] : [value]; + + return new RawCondition({ sql, values }); + } + + if ( om_query instanceof StartsWith ) { + const key = om_query.key; + let value = om_query.value; + const prop = this.om.properties[key]; + + value = await prop.sql_reference(value); + + const options = prop.descriptor.sql ?? {}; + const col_name = options.column_name ?? prop.name; + + const sql = `${col_name} LIKE ${this.db.case({ + sqlite: '? || \'%\'', + otherwise: 'CONCAT(?, \'%\')', + })}`; + const values = value === null ? [] : [value]; + + return new RawCondition({ sql, values }); + } + + if ( om_query instanceof IsNotNull ) { + const key = om_query.key; + let value = om_query.value; + const prop = this.om.properties[key]; + + value = await prop.sql_reference(value); + + const options = prop.descriptor.sql ?? {}; + const col_name = options.column_name ?? prop.name; + + const sql = `${col_name} IS NOT NULL`; + const values = [value]; + + return new RawCondition({ sql, values }); + } + + if ( om_query instanceof Like ) { + const key = om_query.key; + let value = om_query.value; + const prop = this.om.properties[key]; + + value = await prop.sql_reference(value); + + const options = prop.descriptor.sql ?? {}; + const col_name = options.column_name ?? prop.name; + + const sql = `${col_name} LIKE ?`; + const values = [value]; + + return new RawCondition({ sql, values }); + } + }, + }; +} + +module.exports = SQLES; diff --git a/src/backend/src/om/entitystorage/SetOwnerES.js b/src/backend/src/om/entitystorage/SetOwnerES.js new file mode 100644 index 0000000000000000000000000000000000000000..c33472e8660b16b962b22f79b507d68c7b207a3e --- /dev/null +++ b/src/backend/src/om/entitystorage/SetOwnerES.js @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { get_user } = require('../../helpers'); +const { AppUnderUserActorType, UserActorType } = require('../../services/auth/Actor'); +const { Context } = require('../../util/context'); +const { BaseES } = require('./BaseES'); + +class SetOwnerES extends BaseES { + static METHODS = { + async upsert (entity, extra) { + const { old_entity } = extra; + if ( ! old_entity ) { + await entity.set('owner', Context.get('user')); + + if ( entity.om_has_property('app_owner') ) { + const actor = Context.get('actor'); + if ( actor.type instanceof AppUnderUserActorType ) { + const app = actor.type.app; + + // We need to escalate privileges to set the app owner + // because the app may not have permission to read + // its own entry from es:app. + const upgraded_actor = actor.get_related_actor(UserActorType); + await Context.get().sub({ + actor: upgraded_actor, + }).arun(async () => { + await entity.set('app_owner', app.uid); + }); + } + } + } + return await this.upstream.upsert(entity, extra); + }, + async read (uid) { + const entity = await this.upstream.read(uid); + if ( ! entity ) return null; + + await this._sanitize_owner(entity); + + return entity; + }, + async select (...args) { + const entities = await this.upstream.select(...args); + for ( const entity of entities ) { + await this._sanitize_owner(entity); + } + return entities; + }, + async _sanitize_owner (entity) { + let owner = await entity.get('owner'); + if ( ! owner ) return null; + owner = get_user({ id: owner }); + await entity.set('owner', owner); + }, + }; +} + +module.exports = { + SetOwnerES, +}; diff --git a/src/backend/src/om/entitystorage/SubdomainES.js b/src/backend/src/om/entitystorage/SubdomainES.js new file mode 100644 index 0000000000000000000000000000000000000000..a7b7ad2907586c5c2a614eb0d178ed4b7829ed49 --- /dev/null +++ b/src/backend/src/om/entitystorage/SubdomainES.js @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const config = require('../../config'); + +const { DB_READ } = require('../../services/database/consts'); +const { Context } = require('../../util/context'); +const { Eq } = require('../query/query'); +const { BaseES } = require('./BaseES'); + +const PERM_READ_ALL_SUBDOMAINS = 'read-all-subdomains'; + +class SubdomainES extends BaseES { + async _on_context_provided () { + const services = this.context.get('services'); + this.db = services.get('database').get(DB_READ, 'subdomains'); + } + async create_predicate (id) { + if ( id === 'user-can-edit' ) { + return new Eq({ + key: 'owner', + value: Context.get('user').id, + }); + } + } + async upsert (entity, extra) { + if ( ! extra.old_entity ) { + await this._check_max_subdomains(); + } + + return await this.upstream.upsert(entity, extra); + } + async select (options) { + const actor = Context.get('actor'); + const user = actor.type.user; + + // Note: we don't need to worry about read; + // non-owner users don't have permission to list + // but they still have permission to read. + const svc_permission = this.context.get('services').get('permission'); + const has_permission_to_read_all = await svc_permission.check(Context.get('actor'), PERM_READ_ALL_SUBDOMAINS); + + if ( ! has_permission_to_read_all ) { + options.predicate = options.predicate.and(new Eq({ + key: 'owner', + value: user.id, + })); + } + + return await this.upstream.select(options); + } + async _check_max_subdomains () { + const user = Context.get('user'); + + let cnt = await this.db.read('SELECT COUNT(id) AS subdomain_count FROM subdomains WHERE user_id = ?', + [user.id]); + + const max_subdomains = user.max_subdomains ?? config.max_subdomains_per_user; + + if ( max_subdomains && cnt[0].subdomain_count >= max_subdomains ) { + throw APIError.create('subdomain_limit_reached', null, { + limit: max_subdomains, + }); + } + }; +} + +module.exports = SubdomainES; \ No newline at end of file diff --git a/src/backend/src/om/entitystorage/ValidationES.js b/src/backend/src/om/entitystorage/ValidationES.js new file mode 100644 index 0000000000000000000000000000000000000000..77da6134d3b28929fdb4f1352cb89945f1f3e297 --- /dev/null +++ b/src/backend/src/om/entitystorage/ValidationES.js @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { BaseES } = require('./BaseES'); + +const APIError = require('../../api/APIError'); +const { Context } = require('../../util/context'); +const { SKIP_ES_VALIDATION } = require('./consts'); + +class ValidationES extends BaseES { + async _on_context_provided () { + // const services = this.context.get('services'); + // const svc_mysql = services.get('mysql'); + // this.dbrw = svc_mysql.get(DB_MODE_WRITE, `es:${this.entity_name}:rw`); + // this.dbrr = svc_mysql.get(DB_MODE_WRITE, `es:${this.entity_name}:rr`); + } + static METHODS = { + // async create (entity) { + // await this.validate_(entity); + // return await this.om.get_client_safe((await this.upstream.create(entity)).data); + // }, + // async update (entity) { + // await this.validate_(entity); + // return await this.om.get_client_safe((await this.upstream.update(entity)).data); + // }, + async upsert (entity, extra) { + for ( const prop of Object.values(this.om.properties) ) { + if ( + prop.descriptor.protected || + prop.descriptor.read_only + ) { + await entity.del(prop.name); + } + } + + const valid_entity = extra.old_entity + ? await (await extra.old_entity.clone()).apply(entity) + : entity + ; + await this.validate_(valid_entity, + extra.old_entity ? entity : undefined); + const { entity: out_entity } = await this.upstream.upsert(entity, extra); + return await out_entity.get_client_safe(); + }, + async validate_ (entity, diff) { + if ( Context.get(SKIP_ES_VALIDATION) ) return; + + for ( const prop of Object.values(this.om.properties) ) { + let value = await entity.get(prop.name); + + if ( prop.descriptor.required ) { + if ( ! await entity.is_set(prop.name) ) { + throw APIError.create('field_missing', null, { key: prop.name }); + } + } + + if ( ! await entity.is_set(prop.name) ) continue; + + if ( prop.descriptor.immutable && diff && await diff.has(prop.name) ) { + throw APIError.create('field_immutable', null, { key: prop.name }); + } + + try { + const validation_result = await prop.validate(value); + if ( validation_result !== true ) { + throw validation_result || APIError.create('field_invalid', null, { key: prop.name }); + } + } catch ( e ) { + if ( ! (e instanceof APIError) ) { + // eslint-disable-next-line no-ex-assign + e = APIError.create('field_invalid', null, { + key: prop.name, + converted_from_another_error: true, + }); + } + throw e; + } + } + + }, + }; +} + +module.exports = ValidationES; diff --git a/src/backend/src/om/entitystorage/WriteByOwnerOnlyES.js b/src/backend/src/om/entitystorage/WriteByOwnerOnlyES.js new file mode 100644 index 0000000000000000000000000000000000000000..1cabf86820f741c52c66cfe932bd44b819c7a2a6 --- /dev/null +++ b/src/backend/src/om/entitystorage/WriteByOwnerOnlyES.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { Context } = require('../../util/context'); +const { BaseES } = require('./BaseES'); + +const WRITE_ALL_OWNER_ES = 'system:es:write-all-owners'; + +/** + * Entity storage layer that restricts write operations to entity owners only. + * Extends BaseES to add ownership-based access control for upsert and delete operations. + */ +class WriteByOwnerOnlyES extends BaseES { + /** + * Static methods object containing the access-controlled entity storage operations. + */ + static METHODS = { + /** + * Updates or inserts an entity after verifying ownership permissions. + * @param {Object} entity - The entity to upsert + * @param {Object} extra - Additional parameters including old_entity + * @returns {Promise} Result of the upstream upsert operation + */ + async upsert (entity, extra) { + const { old_entity } = extra; + + if ( old_entity ) { + await this._check_allowed({ old_entity }); + } + + return await this.upstream.upsert(entity, extra); + }, + + /** + * Deletes an entity after verifying the current user owns it. + * @param {string} uid - The unique identifier of the entity to delete + * @param {Object} extra - Additional parameters including old_entity + * @returns {Promise} Result of the upstream delete operation + */ + async delete (uid, extra) { + const { old_entity } = extra; + + // Owner check is required first + await this._check_allowed({ old_entity: extra.old_entity }); + return await this.upstream.delete(uid, extra); + }, + + /** + * Verifies that the current user has permission to modify the entity. + * Allows access if user has system-wide write permission or owns the entity. + * @param {Object} params - Parameters object + * @param {Object} params.old_entity - The existing entity to check ownership for + * @throws {APIError} Throws forbidden error if user lacks permission + */ + async _check_allowed ({ old_entity }) { + const svc_permission = this.context.get('services').get('permission'); + const has_permission_to_write_all = await svc_permission.check(Context.get('actor'), WRITE_ALL_OWNER_ES); + if ( has_permission_to_write_all ) { + return; + } + + const owner = await old_entity.get('owner'); + if ( ! owner ) { + throw APIError.create('forbidden'); + } + const user = Context.get('user'); + + if ( user.id !== owner.id ) { + throw APIError.create('forbidden'); + } + }, + + }; +} + +module.exports = WriteByOwnerOnlyES; diff --git a/src/backend/src/om/entitystorage/consts.js b/src/backend/src/om/entitystorage/consts.js new file mode 100644 index 0000000000000000000000000000000000000000..561cba45b1cb8143c24a204021d7dda4e6635a67 --- /dev/null +++ b/src/backend/src/om/entitystorage/consts.js @@ -0,0 +1,3 @@ +module.exports = { + SKIP_ES_VALIDATION: Symbol('SKIP_ES_VALIDATION'), +}; diff --git a/src/backend/src/om/mappings/__all__.js b/src/backend/src/om/mappings/__all__.js new file mode 100644 index 0000000000000000000000000000000000000000..2b31fbc2d445ca61ae7036f7628fe8543cf9181a --- /dev/null +++ b/src/backend/src/om/mappings/__all__.js @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = { + app: require('./app'), + subdomain: require('./subdomain'), + notification: require('./notification'), +}; diff --git a/src/backend/src/om/mappings/access-token.js b/src/backend/src/om/mappings/access-token.js new file mode 100644 index 0000000000000000000000000000000000000000..2d3220c3de3b47bcd57a3cd844f94b2cf94ee119 --- /dev/null +++ b/src/backend/src/om/mappings/access-token.js @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = { + sql: { + table_name: 'access_token_permissions', + }, + primary_identifier: 'token', +}; \ No newline at end of file diff --git a/src/backend/src/om/mappings/app.js b/src/backend/src/om/mappings/app.js new file mode 100644 index 0000000000000000000000000000000000000000..e071251b54b50679130bd4e34573ae423d6bbb6d --- /dev/null +++ b/src/backend/src/om/mappings/app.js @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const config = require('../../config'); + +module.exports = { + sql: { + table_name: 'apps', + }, + primary_identifier: 'uid', + redundant_identifiers: ['name'], + properties: { + // INHERENT + uid: { + type: 'puter-uuid', + prefix: 'app', + }, + + // DOMAIN + icon: 'image-base64', + name: { + type: 'string', + required: true, + maxlen: config.app_name_max_length, + regex: config.app_name_regex, + }, + title: { + type: 'string', + required: true, + maxlen: config.app_title_max_length, + }, + description: { + type: 'string', + // longest description in prod is currently 3444, + // so I've doubled that and rounded up + maxlen: 7000, + }, + metadata: { + type: 'json', + }, + maximize_on_start: 'flag', + background: 'flag', + subdomain: { + type: 'string', + transient: true, + factory: () => `app-${ require('uuid').v4()}`, + sql: { ignore: true }, + }, + index_url: { + type: 'url', + required: true, + maxlen: 3000, + imply: { + given: ['subdomain', 'source_directory'], + make: async ({ subdomain }) => { + return `${config.protocol }://${ subdomain }.puter.site`; + }, + }, + }, + source_directory: { + type: 'puter-node', + node_type: 'directory', + sql: { ignore: true }, + }, + created_at: { + type: 'datetime', + aliases: ['timestamp'], + sql: { + column_name: 'timestamp', + }, + }, + + filetype_associations: { + type: 'array', + of: 'string', + sql: { ignore: true }, + }, + + // DOMAIN :: CALCULATED + stats: { + type: 'json', + sql: { ignore: true }, + }, + created_from_origin: { + type: 'string', + sql: { ignore: true }, + }, + + // ACCESS + owner: { + type: 'reference', + to: 'user', + permissions: ['write'], // write = update,delete,create + permissible_subproperties: ['username', 'uuid'], + sql: { + use_id: true, + column_name: 'owner_user_id', + }, + }, + app_owner: { + type: 'reference', + service: 'es:app', + to: 'app', + sql: { use_id: true }, + }, + protected: { + type: 'flag', + }, + + // OPERATIONS + last_review: { + type: 'datetime', + protected: true, + }, + approved_for_listing: { + type: 'flag', + read_only: true, + }, + approved_for_opening_items: { + type: 'flag', + read_only: true, + }, + approved_for_incentive_program: { + type: 'flag', + read_only: true, + }, + + // SYSTEM + godmode: { + type: 'flag', + read_only: true, + }, + }, +}; diff --git a/src/backend/src/om/mappings/notification.js b/src/backend/src/om/mappings/notification.js new file mode 100644 index 0000000000000000000000000000000000000000..d25c01922e61bf952179fdb93d4a9f4245cc937b --- /dev/null +++ b/src/backend/src/om/mappings/notification.js @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +module.exports = { + sql: { + table_name: 'notification', + }, + primary_identifier: 'uid', + properties: { + uid: { type: 'uuid' }, + value: { type: 'json' }, + read: { type: 'flag' }, + owner: { + type: 'reference', + to: 'user', + permissions: ['read'], + permissible_subproperties: ['username', 'uuid'], + sql: { + use_id: true, + column_name: 'user_id', + }, + }, + }, +}; diff --git a/src/backend/src/om/mappings/subdomain.js b/src/backend/src/om/mappings/subdomain.js new file mode 100644 index 0000000000000000000000000000000000000000..73c32f601b35ff265d7004a224d7b9d14638e281 --- /dev/null +++ b/src/backend/src/om/mappings/subdomain.js @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const config = require('../../config'); + +module.exports = { + sql: { + table_name: 'subdomains', + }, + primary_identifier: 'uid', + redundant_identifiers: ['subdomain'], + properties: { + // INHERENT + uid: { + type: 'puter-uuid', + prefix: 'sd', + sql: { column_name: 'uuid' }, + }, + + // DOMAIN + subdomain: { + type: 'string', + required: true, + immutable: true, + unique: true, + maxlen: config.subdomain_max_length, + regex: config.subdomain_regex, + // TODO: can this 'adapt' be data instead? + async adapt (value) { + return value.toLowerCase(); + }, + async validate (value) { + if ( config.reserved_words.includes(value) ) { + return APIError.create('subdomain_reserved', null, { + subdomain: value, + }); + } + }, + }, + domain: { + type: 'string', + maxlen: 253, + + // It turns out validating domain names kind of sucks + // source: https://stackoverflow.com/questions/10306690 + regex: '^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?([a-z0-9][a-z0-9\-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$', + + // TODO: can this 'adapt' be data instead? + async adapt (value) { + if ( value !== null ) + { + return value.toLowerCase(); + } + return null; + }, + }, + root_dir: { + type: 'puter-node', + fs_permission: 'read', + sql: { + column_name: 'root_dir_id', + }, + }, + associated_app: { + type: 'reference', + service: 'es:app', + to: 'app', + sql: { + use_id: true, + column_name: 'associated_app_id', + }, + }, + created_at: { + type: 'datetime', + aliases: ['timestamp'], + sql: { + column_name: 'ts', + }, + }, + + // ACCESS + owner: { + type: 'reference', + to: 'user', + permissions: ['write'], + permissible_subproperties: ['username', 'uuid'], + sql: { + use_id: true, + column_name: 'user_id', + }, + }, + app_owner: { + type: 'reference', + service: 'es:app', + to: 'app', + sql: { use_id: true }, + }, + protected: { + type: 'flag', + }, + }, +}; diff --git a/src/backend/src/om/proptypes/__all__.js b/src/backend/src/om/proptypes/__all__.js new file mode 100644 index 0000000000000000000000000000000000000000..068822b3db267bbe60fd46ac7afb70146815ab57 --- /dev/null +++ b/src/backend/src/om/proptypes/__all__.js @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../api/APIError'); +const { NodeUIDSelector, NodeInternalIDSelector, NodePathSelector } = require('../../filesystem/node/selectors'); +const { is_valid_uuid4, is_valid_uuid } = require('../../helpers'); +const validator = require('validator'); +const { Context } = require('../../util/context'); +const { is_valid_path } = require('../../filesystem/validation'); +const FSNodeContext = require('../../filesystem/FSNodeContext'); +const { Entity } = require('../entitystorage/Entity'); +const NULL = Symbol('NULL'); + +class OMTypeError extends Error { + constructor ({ expected, got }) { + const message = `expected ${expected}, got ${got}`; + super(message); + this.name = 'OMTypeError'; + } +} + +module.exports = { + base: { + is_set (value) { + return !!value; + }, + }, + json: { + from: 'base', + }, + string: { + is_set (value) { + return (!!value) || value === null; + }, + async adapt (value) { + if ( value === undefined ) return ''; + + // SQL stores strings as null. If one-way adapt from db is supported + // then this should become an sql-to-entity adapt only. + if ( value === null ) return ''; + + if ( value === NULL ) { + return null; + } + + if ( typeof value !== 'string' ) { + throw new OMTypeError({ expected: 'string', got: typeof value }); + } + return value; + }, + validate (value, { name, descriptor }) { + if ( typeof value !== 'string' ) { + return new OMTypeError({ expected: 'string', got: typeof value }); + } + if ( descriptor.hasOwnProperty('maxlen') && value.length > descriptor.maxlen ) { + throw APIError.create('field_too_long', null, { key: name, max_length: descriptor.maxlen }); + } + if ( descriptor.hasOwnProperty('minlen') && value.length > descriptor.minlen ) { + throw APIError.create('field_too_short', null, { key: name, min_length: descriptor.maxlen }); + } + if ( descriptor.hasOwnProperty('regex') && !value.match(descriptor.regex) ) { + return new Error(`string does not match regex ${descriptor.regex}`); + } + return true; + }, + }, + array: { + from: 'base', + validate (value, { name, descriptor }) { + if ( ! Array.isArray(value) ) { + return new OMTypeError({ expected: 'array', got: typeof value }); + } + if ( descriptor.hasOwnProperty('maxlen') && value.length > descriptor.maxlen ) { + throw APIError.create('field_too_long', null, { key: name, max_length: descriptor.maxlen }); + } + if ( descriptor.hasOwnProperty('minlen') && value.length > descriptor.minlen ) { + throw APIError.create('field_too_short', null, { key: name, min_length: descriptor.maxlen }); + } + if ( descriptor.hasOwnProperty('mod') && value.length % descriptor.mod !== 0 ) { + throw APIError.create('field_invalid', null, { key: name, mod: descriptor.mod }); + } + return true; + }, + }, + flag: { + adapt: value => { + if ( value === undefined ) return false; + if ( value === 0 ) value = false; + if ( value === 1 ) value = true; + if ( value === '0' ) value = false; + if ( value === '1' ) value = true; + if ( typeof value !== 'boolean' ) { + throw new OMTypeError({ expected: 'boolean', got: typeof value }); + } + return value; + }, + }, + uuid: { + from: 'string', + validate (value) { + return is_valid_uuid4(value); + }, + }, + ['puter-uuid']: { + from: 'string', + validate (value, { descriptor }) { + const prefix = `${descriptor.prefix }-`; + if ( ! value.startsWith(prefix) ) { + return new Error(`UUID does not start with prefix ${prefix}`); + } + return is_valid_uuid(value.slice(prefix.length)); + }, + factory ({ descriptor }) { + const prefix = `${descriptor.prefix }-`; + const uuid = require('uuid').v4(); + return prefix + uuid; + }, + }, + ['image-base64']: { + from: 'string', + validate (value) { + if ( ! value.startsWith('data:image/') ) { + return new Error('image must be base64 encoded'); + } + // XSS characters + const chars = ['<', '>', '&', '"', "'", '`']; + if ( chars.some(char => value.includes(char)) ) { + return new Error('icon is not an image'); + } + }, + }, + url: { + from: 'string', + validate (value) { + let valid = validator.isURL(value); + if ( ! valid ) { + valid = validator.isURL(value, { host_whitelist: ['localhost'] }); + } + return valid; + }, + }, + reference: { + from: 'base', + async sql_reference (value, { descriptor }) { + if ( ! descriptor.service ) return value; + if ( ! value ) return null; + if ( value instanceof Entity ) { + return value.private_meta.mysql_id; + } + return value.id; + }, + async sql_dereference (value, { descriptor }) { + if ( ! descriptor.service ) return value; + if ( ! value ) return null; + const svc = Context.get().get('services').get(descriptor.service); + const entity = await svc.read(value); + return entity; + }, + async adapt (value, { descriptor }) { + if ( descriptor.debug ) { + debugger; // eslint-disable-line no-debugger + } + if ( ! descriptor.service ) return value; + if ( ! value ) return null; + if ( value instanceof Entity ) return value; + const svc = Context.get().get('services').get(descriptor.service); + const entity = await svc.read(value); + return entity; + }, + }, + datetime: { + from: 'base', + }, + ['puter-node']: { + // from: 'base', + async sql_reference (value) { + if ( value === null ) return null; + if ( ! (value instanceof FSNodeContext) ) { + throw new Error('Cannot reference non-FSNodeContext'); + } + await value.fetchEntry(); + return value.mysql_id ?? null; + }, + async is_set (value) { + return ( !!value ) || value === null; + }, + async sql_dereference (value) { + if ( value === null ) return null; + if ( typeof value !== 'number' ) { + throw new Error(`Cannot dereference non-number: ${value}`); + } + const svc_fs = Context.get().get('services').get('filesystem'); + return svc_fs.node(new NodeInternalIDSelector('mysql', value)); + }, + async adapt (value, { name }) { + if ( value === null ) return null; + + if ( value instanceof FSNodeContext ) { + return value; + } + const ctx = Context.get(); + + if ( typeof value !== 'string' ) return; + + let selector; + if ( ! ['/', '.', '~'].includes(value[0]) ) { + if ( is_valid_uuid4(value) ) { + selector = new NodeUIDSelector(value); + } + } else { + if ( value.startsWith('~') ) { + const user = ctx.get('user'); + if ( ! user ) { + throw new Error('Cannot use ~ without a user'); + } + const homedir = `/${user.username}`; + value = homedir + value.slice(1); + } + + if ( ! is_valid_path(value) ) { + throw APIError.create('field_invalid', null, { + key: name, + expected: 'unix-style path or UUID', + }); + } + + selector = new NodePathSelector(value); + } + + const svc_fs = ctx.get('services').get('filesystem'); + const node = await svc_fs.node(selector); + return node; + }, + async validate (value, { name, descriptor }) { + if ( value === null ) return; + const actor = Context.get('actor'); + const permission = descriptor.fs_permission ?? 'see'; + + const svc_acl = Context.get('services').get('acl'); + if ( await value.get('path') === '/' ) { + return APIError.create('forbidden'); + } + if ( ! await svc_acl.check(actor, value, permission) ) { + return await svc_acl.get_safe_acl_error(actor, value, permission); + } + }, + }, + NULL, +}; diff --git a/src/backend/src/om/query/query.js b/src/backend/src/om/query/query.js new file mode 100644 index 0000000000000000000000000000000000000000..6a846039109eb8a28a23a2dba5553e35166f7789 --- /dev/null +++ b/src/backend/src/om/query/query.js @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const { AdvancedBase } = require('@heyputer/putility'); +const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature'); + +class Predicate extends AdvancedBase { + static FEATURES = [ + new WeakConstructorFeature(), + ]; +} + +class Null extends Predicate { + // +} + +class And extends Predicate { + // +} + +class Or extends Predicate { + async check (entity) { + for ( const child of this.children ) { + if ( await entity.check(child) ) { + return true; + } + } + return false; + } +} + +class Eq extends Predicate { + async check (entity) { + return (await entity.get(this.key)) == this.value; + } +} + +class StartsWith extends Predicate { + async check (entity) { + return (await entity.get(this.key)).startsWith(this.value); + } +} + +class IsNotNull extends Predicate { + async check (entity) { + return (await entity.get(this.key)) !== null; + } +} + +class Like extends Predicate { + async check (entity) { + // Convert SQL LIKE pattern to RegExp + // TODO: Support escaping the pattern characters + const regex = new RegExp(this.value.replaceAll('%', '.*').replaceAll('_', '.'), 'i'); + return regex.test(await entity.get(this.key)); + } +} + +Predicate.prototype.and = function (other) { + return new And({ children: [this, other] }); +}; + +class PredicateUtil { + static simplify (predicate) { + if ( predicate instanceof And ) { + const simplified = []; + for ( const p of predicate.children ) { + const s = PredicateUtil.simplify(p); + if ( s instanceof And ) { + simplified.push(...s.children); + } else if ( ! (s instanceof Null) ) { + simplified.push(s); + } + } + if ( simplified.length === 0 ) { + return new Null(); + } + if ( simplified.length === 1 ) { + return simplified[0]; + } + return new And({ children: simplified }); + } + + if ( predicate instanceof Or ) { + const simplified = []; + for ( const p of predicate.children ) { + const s = PredicateUtil.simplify(p); + if ( s instanceof Or ) { + simplified.push(...s.children); + } else if ( ! (s instanceof Null) ) { + simplified.push(s); + } + } + if ( simplified.length === 0 ) { + return new Null(); + } + if ( simplified.length === 1 ) { + return simplified[0]; + } + return new Or({ children: simplified }); + } + + return predicate; + } + + static write_human_readable (predicate) { + if ( predicate instanceof Eq ) { + return `${predicate.key}=${predicate.value}`; + } + + if ( predicate instanceof And ) { + const parts = predicate.children.map(child => + PredicateUtil.write_human_readable(child)); + return parts.join(' and '); + } + + if ( predicate instanceof Or ) { + const parts = predicate.children.map(child => + PredicateUtil.write_human_readable(child)); + return parts.join(' or '); + } + + if ( predicate instanceof StartsWith ) { + return `${predicate.key} starts with "${predicate.value}"`; + } + + if ( predicate instanceof IsNotNull ) { + return `${predicate.key} is not null`; + } + + if ( predicate instanceof Like ) { + return `${predicate.key} like "${predicate.value}"`; + } + + if ( predicate instanceof Null ) { + return ''; + } + + return String(predicate); + } +} + +module.exports = { + Predicate, + PredicateUtil, + Null, + And, + Or, + Eq, + IsNotNull, + Like, + StartsWith, +}; diff --git a/src/backend/src/om/query/query.test.js b/src/backend/src/om/query/query.test.js new file mode 100644 index 0000000000000000000000000000000000000000..085ad45f7a166536c2409dd61ef736c31eec3a7b --- /dev/null +++ b/src/backend/src/om/query/query.test.js @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest'; + +const { + Eq, + And, + Or, + Null, + IsNotNull, + Like, + StartsWith, + PredicateUtil, +} = require('./query'); + +describe('PredicateUtil', () => { + describe('write_human_readable', () => { + it('writes Eq predicate as key=value', () => { + const predicate = new Eq({ key: 'name', value: 'John' }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('name=John'); + }); + + it('writes And predicate with "and" separator', () => { + const predicate = new And({ + children: [ + new Eq({ key: 'name', value: 'John' }), + new Eq({ key: 'age', value: 25 }), + ], + }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('name=John and age=25'); + }); + + it('writes nested And predicates', () => { + const predicate = new And({ + children: [ + new Eq({ key: 'name', value: 'John' }), + new Eq({ key: 'age', value: 25 }), + new Eq({ key: 'city', value: 'NYC' }), + ], + }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('name=John and age=25 and city=NYC'); + }); + + it('writes Or predicate with "or" separator', () => { + const predicate = new Or({ + children: [ + new Eq({ key: 'status', value: 'active' }), + new Eq({ key: 'status', value: 'pending' }), + ], + }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('status=active or status=pending'); + }); + + it('writes StartsWith predicate', () => { + const predicate = new StartsWith({ key: 'email', value: 'admin' }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('email starts with "admin"'); + }); + + it('writes IsNotNull predicate', () => { + const predicate = new IsNotNull({ key: 'verified_at' }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('verified_at is not null'); + }); + + it('writes Like predicate', () => { + const predicate = new Like({ key: 'name', value: '%John%' }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('name like "%John%"'); + }); + + it('writes Null predicate as empty string', () => { + const predicate = new Null(); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe(''); + }); + + it('writes complex nested predicates', () => { + const predicate = new And({ + children: [ + new Eq({ key: 'status', value: 'active' }), + new Or({ + children: [ + new Eq({ key: 'role', value: 'admin' }), + new Eq({ key: 'role', value: 'moderator' }), + ], + }), + ], + }); + const result = PredicateUtil.write_human_readable(predicate); + expect(result).toBe('status=active and role=admin or role=moderator'); + }); + }); + + describe('simplify', () => { + it('simplifies nested And predicates', () => { + const predicate = new And({ + children: [ + new And({ + children: [ + new Eq({ key: 'a', value: 1 }), + new Eq({ key: 'b', value: 2 }), + ], + }), + new Eq({ key: 'c', value: 3 }), + ], + }); + const result = PredicateUtil.simplify(predicate); + expect(result).toBeInstanceOf(And); + expect(result.children.length).toBe(3); + expect(result.children[0]).toBeInstanceOf(Eq); + expect(result.children[1]).toBeInstanceOf(Eq); + expect(result.children[2]).toBeInstanceOf(Eq); + }); + + it('simplifies And with single child', () => { + const predicate = new And({ + children: [ + new Eq({ key: 'a', value: 1 }), + ], + }); + const result = PredicateUtil.simplify(predicate); + expect(result).toBeInstanceOf(Eq); + expect(result.key).toBe('a'); + }); + + it('simplifies And with Null children', () => { + const predicate = new And({ + children: [ + new Eq({ key: 'a', value: 1 }), + new Null(), + new Eq({ key: 'b', value: 2 }), + ], + }); + const result = PredicateUtil.simplify(predicate); + expect(result).toBeInstanceOf(And); + expect(result.children.length).toBe(2); + }); + + it('simplifies And with all Null children to Null', () => { + const predicate = new And({ + children: [ + new Null(), + new Null(), + ], + }); + const result = PredicateUtil.simplify(predicate); + expect(result).toBeInstanceOf(Null); + }); + + it('simplifies nested Or predicates', () => { + const predicate = new Or({ + children: [ + new Or({ + children: [ + new Eq({ key: 'a', value: 1 }), + new Eq({ key: 'b', value: 2 }), + ], + }), + new Eq({ key: 'c', value: 3 }), + ], + }); + const result = PredicateUtil.simplify(predicate); + expect(result).toBeInstanceOf(Or); + expect(result.children.length).toBe(3); + }); + + it('returns non-composite predicates unchanged', () => { + const predicate = new Eq({ key: 'a', value: 1 }); + const result = PredicateUtil.simplify(predicate); + expect(result).toBe(predicate); + }); + }); +}); + +describe('Predicate classes', () => { + describe('Eq', () => { + it('checks equality', async () => { + const predicate = new Eq({ key: 'status', value: 'active' }); + const entity = { + get: async (key) => key === 'status' ? 'active' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(true); + }); + + it('fails when not equal', async () => { + const predicate = new Eq({ key: 'status', value: 'active' }); + const entity = { + get: async (key) => key === 'status' ? 'inactive' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(false); + }); + }); + + describe('StartsWith', () => { + it('checks if string starts with value', async () => { + const predicate = new StartsWith({ key: 'email', value: 'admin' }); + const entity = { + get: async (key) => key === 'email' ? 'admin@example.com' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(true); + }); + + it('fails when string does not start with value', async () => { + const predicate = new StartsWith({ key: 'email', value: 'admin' }); + const entity = { + get: async (key) => key === 'email' ? 'user@example.com' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(false); + }); + }); + + describe('IsNotNull', () => { + it('checks if value is not null', async () => { + const predicate = new IsNotNull({ key: 'verified_at' }); + const entity = { + get: async (key) => key === 'verified_at' ? '2025-01-01' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(true); + }); + + it('fails when value is null', async () => { + const predicate = new IsNotNull({ key: 'verified_at' }); + const entity = { + get: async (key) => null, + }; + const result = await predicate.check(entity); + expect(result).toBe(false); + }); + }); + + describe('Like', () => { + it('matches pattern with wildcards', async () => { + const predicate = new Like({ key: 'name', value: '%John%' }); + const entity = { + get: async (key) => key === 'name' ? 'John Doe' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(true); + }); + + it('fails when pattern does not match', async () => { + const predicate = new Like({ key: 'name', value: '%Jane%' }); + const entity = { + get: async (key) => key === 'name' ? 'John Doe' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(false); + }); + + it('is case insensitive', async () => { + const predicate = new Like({ key: 'name', value: '%john%' }); + const entity = { + get: async (key) => key === 'name' ? 'JOHN DOE' : null, + }; + const result = await predicate.check(entity); + expect(result).toBe(true); + }); + }); + + describe('Or', () => { + it('returns true if any child matches', async () => { + const predicate = new Or({ + children: [ + new Eq({ key: 'status', value: 'active' }), + new Eq({ key: 'status', value: 'pending' }), + ], + }); + const entity = { + get: async (key) => key === 'status' ? 'pending' : null, + check: async (pred) => await pred.check(entity), + }; + const result = await predicate.check(entity); + expect(result).toBe(true); + }); + + it('returns false if no children match', async () => { + const predicate = new Or({ + children: [ + new Eq({ key: 'status', value: 'active' }), + new Eq({ key: 'status', value: 'pending' }), + ], + }); + const entity = { + get: async (key) => key === 'status' ? 'inactive' : null, + check: async (pred) => await pred.check(entity), + }; + const result = await predicate.check(entity); + expect(result).toBe(false); + }); + }); + + describe('Predicate.and', () => { + it('creates an And predicate', () => { + const pred1 = new Eq({ key: 'a', value: 1 }); + const pred2 = new Eq({ key: 'b', value: 2 }); + const result = pred1.and(pred2); + expect(result).toBeInstanceOf(And); + expect(result.children).toEqual([pred1, pred2]); + }); + }); +}); diff --git a/src/backend/src/polyfill/to-string-higher-radix.js b/src/backend/src/polyfill/to-string-higher-radix.js new file mode 100644 index 0000000000000000000000000000000000000000..162c93ba43f5326c387a34031ce6ee0af1394e86 --- /dev/null +++ b/src/backend/src/polyfill/to-string-higher-radix.js @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/** + * Polyfill written by Chat GPT that increases the highest suppored + * radix on Number.prototype.toString from 36 to 62. + */ +(function () { + const originalToString = Number.prototype.toString; + + const characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const base = characters.length; // 62 + + Number.prototype.toString = function (radix) { + // Use the original toString for bases 36 or lower + if ( !radix || radix <= 36 ) { + return originalToString.call(this, radix); + } + + // Custom implementation for base 62 + let value = this; + let result = ''; + while ( value > 0 ) { + result = characters[value % base] + result; + value = Math.floor(value / base); + } + return result || '0'; + }; +})(); diff --git a/src/backend/src/public/assets/bootstrap-5.1.3/css/bootstrap.min.css b/src/backend/src/public/assets/bootstrap-5.1.3/css/bootstrap.min.css new file mode 100644 index 0000000000000000000000000000000000000000..59cf50f673ae4399f0751cc1c482a580d3e26337 --- /dev/null +++ b/src/backend/src/public/assets/bootstrap-5.1.3/css/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.3 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:first-child){border-top:2px solid currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.2rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.3rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/src/backend/src/public/assets/bootstrap-5.1.3/css/bootstrap.min.css.map b/src/backend/src/public/assets/bootstrap-5.1.3/css/bootstrap.min.css.map new file mode 100644 index 0000000000000000000000000000000000000000..c84afa43c6ff447231df79c21ad80a99191446a0 --- /dev/null +++ b/src/backend/src/public/assets/bootstrap-5.1.3/css/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBAAA;;;;;ACAA,MAQI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EACA,oBAAA,EAAA,CAAA,EAAA,CAAA,GACA,iBAAA,GAAA,CAAA,GAAA,CAAA,IAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAQA,sBAAA,0BACA,oBAAA,KACA,sBAAA,IACA,sBAAA,IACA,gBAAA,QAIA,aAAA,KCnCF,ECgDA,QADA,SD5CE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BEmPI,UAAA,yBFjPJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YAUF,GACE,OAAA,KAAA,EACA,MAAA,QACA,iBAAA,aACA,OAAA,EACA,QAAA,IAGF,eACE,OAAA,IAUF,IAAA,IAAA,IAAA,IAAA,IAAA,IAAA,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IAIF,IAAA,GEwMQ,UAAA,uBAlKJ,0BFtCJ,IAAA,GE+MQ,UAAA,QF1MR,IAAA,GEmMQ,UAAA,sBAlKJ,0BFjCJ,IAAA,GE0MQ,UAAA,MFrMR,IAAA,GE8LQ,UAAA,oBAlKJ,0BF5BJ,IAAA,GEqMQ,UAAA,SFhMR,IAAA,GEyLQ,UAAA,sBAlKJ,0BFvBJ,IAAA,GEgMQ,UAAA,QF3LR,IAAA,GEgLM,UAAA,QF3KN,IAAA,GE2KM,UAAA,KFhKN,EACE,WAAA,EACA,cAAA,KCoBF,6BDTA,YAEE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GCKA,GDHE,aAAA,KCSF,GDNA,GCKA,GDFE,WAAA,EACA,cAAA,KAGF,MCMA,MACA,MAFA,MDDE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,ECLA,ODOE,YAAA,OAQF,OAAA,ME4EM,UAAA,OFrEN,MAAA,KACE,QAAA,KACA,iBAAA,QASF,ICnBA,IDqBE,SAAA,SEwDI,UAAA,MFtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,QACA,gBAAA,UAEA,QACE,MAAA,QAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KCvBJ,KACA,ID6BA,IC5BA,KDgCE,YAAA,yBEcI,UAAA,IFZJ,UAAA,IACA,aAAA,cAOF,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KEAI,UAAA,OFKJ,SELI,UAAA,QFOF,MAAA,QACA,WAAA,OAIJ,KEZM,UAAA,OFcJ,MAAA,QACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,MAAA,MExBI,UAAA,OF0BJ,MAAA,KACA,iBAAA,QG7SE,cAAA,MHgTF,QACE,QAAA,EE/BE,UAAA,IFiCF,YAAA,IASJ,OACE,OAAA,EAAA,EAAA,KAMF,IChDA,IDkDE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,QACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBCvDF,MAGA,GAFA,MAGA,GDsDA,MCxDA,GD8DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,ECrEF,OD0EA,MCxEA,SADA,OAEA,SD4EE,OAAA,EACA,YAAA,QE9HI,UAAA,QFgIJ,YAAA,QAIF,OC3EA,OD6EE,eAAA,KAKF,cACE,OAAA,QAGF,OAGE,UAAA,OAGA,gBACE,QAAA,EAOJ,0CACE,QAAA,KCjFF,cACA,aACA,cDuFA,OAIE,mBAAA,OCvFF,6BACA,4BACA,6BDwFI,sBACE,OAAA,QAON,mBACE,QAAA,EACA,aAAA,KAKF,SACE,OAAA,SAUF,SACE,UAAA,EACA,QAAA,EACA,OAAA,EACA,OAAA,EAQF,OACE,MAAA,KACA,MAAA,KACA,QAAA,EACA,cAAA,MEnNM,UAAA,sBFsNN,YAAA,QExXE,0BFiXJ,OExMQ,UAAA,QFiNN,SACE,MAAA,KC/FJ,kCDsGA,uCCvGA,mCADA,+BAGA,oCAJA,6BAKA,mCD2GE,QAAA,EAGF,4BACE,OAAA,KASF,cACE,eAAA,KACA,mBAAA,UAmBF,4BACE,mBAAA,KAKF,+BACE,QAAA,EAMF,6BACE,KAAA,QADF,uBACE,KAAA,QAMF,6BACE,KAAA,QACA,mBAAA,OAKF,OACE,QAAA,aAKF,OACE,OAAA,EAOF,QACE,QAAA,UACA,OAAA,QAQF,SACE,eAAA,SAQF,SACE,QAAA,eInlBF,MFyQM,UAAA,QEvQJ,YAAA,IAKA,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QE7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QE7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QEvPR,eCrDE,aAAA,EACA,WAAA,KDyDF,aC1DE,aAAA,EACA,WAAA,KD4DF,kBACE,QAAA,aAEA,mCACE,aAAA,MAUJ,YFsNM,UAAA,OEpNJ,eAAA,UAIF,YACE,cAAA,KF+MI,UAAA,QE5MJ,wBACE,cAAA,EAIJ,mBACE,WAAA,MACA,cAAA,KFqMI,UAAA,OEnMJ,MAAA,QAEA,2BACE,QAAA,KE9FJ,WCIE,UAAA,KAGA,OAAA,KDDF,eACE,QAAA,OACA,iBAAA,KACA,OAAA,IAAA,MAAA,QHGE,cAAA,OIRF,UAAA,KAGA,OAAA,KDcF,QAEE,QAAA,aAGF,YACE,cAAA,MACA,YAAA,EAGF,gBJ+PM,UAAA,OI7PJ,MAAA,QElCA,WP0mBF,iBAGA,cACA,cACA,cAHA,cADA,eQ9mBE,MAAA,KACA,cAAA,0BACA,aAAA,0BACA,aAAA,KACA,YAAA,KCwDE,yBF5CE,WAAA,cACE,UAAA,OE2CJ,yBF5CE,WAAA,cAAA,cACE,UAAA,OE2CJ,yBF5CE,WAAA,cAAA,cAAA,cACE,UAAA,OE2CJ,0BF5CE,WAAA,cAAA,cAAA,cAAA,cACE,UAAA,QE2CJ,0BF5CE,WAAA,cAAA,cAAA,cAAA,cAAA,eACE,UAAA,QGfN,KCAA,cAAA,OACA,cAAA,EACA,QAAA,KACA,UAAA,KAEA,WAAA,8BACA,aAAA,+BACA,YAAA,+BDJE,OCaF,YAAA,EACA,MAAA,KACA,UAAA,KACA,cAAA,8BACA,aAAA,8BACA,WAAA,mBA+CI,KACE,KAAA,EAAA,EAAA,GAGF,iBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,cACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,UAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,UAxDV,YAAA,YAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,WAxDV,YAAA,aAwDU,WAxDV,YAAA,aAmEM,KX2sBR,MWzsBU,cAAA,EAGF,KX2sBR,MWzsBU,cAAA,EAPF,KXqtBR,MWntBU,cAAA,QAGF,KXqtBR,MWntBU,cAAA,QAPF,KX+tBR,MW7tBU,cAAA,OAGF,KX+tBR,MW7tBU,cAAA,OAPF,KXyuBR,MWvuBU,cAAA,KAGF,KXyuBR,MWvuBU,cAAA,KAPF,KXmvBR,MWjvBU,cAAA,OAGF,KXmvBR,MWjvBU,cAAA,OAPF,KX6vBR,MW3vBU,cAAA,KAGF,KX6vBR,MW3vBU,cAAA,KF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QXg6BR,SW95BU,cAAA,EAGF,QXg6BR,SW95BU,cAAA,EAPF,QX06BR,SWx6BU,cAAA,QAGF,QX06BR,SWx6BU,cAAA,QAPF,QXo7BR,SWl7BU,cAAA,OAGF,QXo7BR,SWl7BU,cAAA,OAPF,QX87BR,SW57BU,cAAA,KAGF,QX87BR,SW57BU,cAAA,KAPF,QXw8BR,SWt8BU,cAAA,OAGF,QXw8BR,SWt8BU,cAAA,OAPF,QXk9BR,SWh9BU,cAAA,KAGF,QXk9BR,SWh9BU,cAAA,MF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QXqnCR,SWnnCU,cAAA,EAGF,QXqnCR,SWnnCU,cAAA,EAPF,QX+nCR,SW7nCU,cAAA,QAGF,QX+nCR,SW7nCU,cAAA,QAPF,QXyoCR,SWvoCU,cAAA,OAGF,QXyoCR,SWvoCU,cAAA,OAPF,QXmpCR,SWjpCU,cAAA,KAGF,QXmpCR,SWjpCU,cAAA,KAPF,QX6pCR,SW3pCU,cAAA,OAGF,QX6pCR,SW3pCU,cAAA,OAPF,QXuqCR,SWrqCU,cAAA,KAGF,QXuqCR,SWrqCU,cAAA,MF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QX00CR,SWx0CU,cAAA,EAGF,QX00CR,SWx0CU,cAAA,EAPF,QXo1CR,SWl1CU,cAAA,QAGF,QXo1CR,SWl1CU,cAAA,QAPF,QX81CR,SW51CU,cAAA,OAGF,QX81CR,SW51CU,cAAA,OAPF,QXw2CR,SWt2CU,cAAA,KAGF,QXw2CR,SWt2CU,cAAA,KAPF,QXk3CR,SWh3CU,cAAA,OAGF,QXk3CR,SWh3CU,cAAA,OAPF,QX43CR,SW13CU,cAAA,KAGF,QX43CR,SW13CU,cAAA,MF1DN,0BEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QX+hDR,SW7hDU,cAAA,EAGF,QX+hDR,SW7hDU,cAAA,EAPF,QXyiDR,SWviDU,cAAA,QAGF,QXyiDR,SWviDU,cAAA,QAPF,QXmjDR,SWjjDU,cAAA,OAGF,QXmjDR,SWjjDU,cAAA,OAPF,QX6jDR,SW3jDU,cAAA,KAGF,QX6jDR,SW3jDU,cAAA,KAPF,QXukDR,SWrkDU,cAAA,OAGF,QXukDR,SWrkDU,cAAA,OAPF,QXilDR,SW/kDU,cAAA,KAGF,QXilDR,SW/kDU,cAAA,MF1DN,0BEUE,SACE,KAAA,EAAA,EAAA,GAGF,qBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,cAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,cAxDV,YAAA,EAwDU,cAxDV,YAAA,YAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,eAxDV,YAAA,aAwDU,eAxDV,YAAA,aAmEM,SXovDR,UWlvDU,cAAA,EAGF,SXovDR,UWlvDU,cAAA,EAPF,SX8vDR,UW5vDU,cAAA,QAGF,SX8vDR,UW5vDU,cAAA,QAPF,SXwwDR,UWtwDU,cAAA,OAGF,SXwwDR,UWtwDU,cAAA,OAPF,SXkxDR,UWhxDU,cAAA,KAGF,SXkxDR,UWhxDU,cAAA,KAPF,SX4xDR,UW1xDU,cAAA,OAGF,SX4xDR,UW1xDU,cAAA,OAPF,SXsyDR,UWpyDU,cAAA,KAGF,SXsyDR,UWpyDU,cAAA,MCrHV,OACE,cAAA,YACA,qBAAA,YACA,yBAAA,QACA,sBAAA,oBACA,wBAAA,QACA,qBAAA,mBACA,uBAAA,QACA,oBAAA,qBAEA,MAAA,KACA,cAAA,KACA,MAAA,QACA,eAAA,IACA,aAAA,QAOA,yBACE,QAAA,MAAA,MACA,iBAAA,mBACA,oBAAA,IACA,WAAA,MAAA,EAAA,EAAA,EAAA,OAAA,0BAGF,aACE,eAAA,QAGF,aACE,eAAA,OAIF,0BACE,WAAA,IAAA,MAAA,aASJ,aACE,aAAA,IAUA,4BACE,QAAA,OAAA,OAeF,gCACE,aAAA,IAAA,EAGA,kCACE,aAAA,EAAA,IAOJ,oCACE,oBAAA,EAGF,qCACE,iBAAA,EASF,2CACE,qBAAA,2BACA,MAAA,8BAQJ,cACE,qBAAA,0BACA,MAAA,6BAQA,8BACE,qBAAA,yBACA,MAAA,4BC5HF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,iBAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,YAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,cAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,aAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,YAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QDoIA,kBACE,WAAA,KACA,2BAAA,MH3EF,4BGyEA,qBACE,WAAA,KACA,2BAAA,OH3EF,4BGyEA,qBACE,WAAA,KACA,2BAAA,OH3EF,4BGyEA,qBACE,WAAA,KACA,2BAAA,OH3EF,6BGyEA,qBACE,WAAA,KACA,2BAAA,OH3EF,6BGyEA,sBACE,WAAA,KACA,2BAAA,OEnJN,YACE,cAAA,MASF,gBACE,YAAA,oBACA,eAAA,oBACA,cAAA,EboRI,UAAA,QahRJ,YAAA,IAIF,mBACE,YAAA,kBACA,eAAA,kBb0QI,UAAA,QatQN,mBACE,YAAA,mBACA,eAAA,mBboQI,UAAA,QcjSN,WACE,WAAA,OdgSI,UAAA,Oc5RJ,MAAA,QCLF,cACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,Of8RI,UAAA,Ke3RJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,QACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KdGE,cAAA,OeHE,WAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCDhBN,cCiBQ,WAAA,MDGN,yBACE,SAAA,OAEA,wDACE,OAAA,QAKJ,oBACE,MAAA,QACA,iBAAA,KACA,aAAA,QACA,QAAA,EAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAOJ,2CAEE,OAAA,MAIF,gCACE,MAAA,QAEA,QAAA,EAHF,2BACE,MAAA,QAEA,QAAA,EAQF,uBAAA,wBAEE,iBAAA,QAGA,QAAA,EAIF,0CACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE3EF,iBAAA,QF6EE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECtEE,mBAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YD2DJ,oCACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE3EF,iBAAA,QF6EE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECtEE,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCDuDJ,0CCtDM,mBAAA,KAAA,WAAA,KDsDN,oCCtDM,WAAA,MDqEN,+EACE,iBAAA,QADF,yEACE,iBAAA,QAGF,0CACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE9FF,iBAAA,QFgGE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECzFE,mBAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCD0EJ,0CCzEM,mBAAA,KAAA,WAAA,MDwFN,+EACE,iBAAA,QASJ,wBACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,EACA,cAAA,EACA,YAAA,IACA,MAAA,QACA,iBAAA,YACA,OAAA,MAAA,YACA,aAAA,IAAA,EAEA,wCAAA,wCAEE,cAAA,EACA,aAAA,EAWJ,iBACE,WAAA,0BACA,QAAA,OAAA,MfmJI,UAAA,QClRF,cAAA,McmIF,6CACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAHF,uCACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAGF,6CACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAIJ,iBACE,WAAA,yBACA,QAAA,MAAA,KfgII,UAAA,QClRF,cAAA,McsJF,6CACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAHF,uCACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAGF,6CACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAQF,sBACE,WAAA,2BAGF,yBACE,WAAA,0BAGF,yBACE,WAAA,yBAKJ,oBACE,MAAA,KACA,OAAA,KACA,QAAA,QAEA,mDACE,OAAA,QAGF,uCACE,OAAA,Md/LA,cAAA,OcmMF,0CACE,OAAA,MdpMA,cAAA,OiBdJ,aACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,QAAA,QAAA,OAEA,mBAAA,oBlB2RI,UAAA,KkBxRJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,iBAAA,KACA,iBAAA,gOACA,kBAAA,UACA,oBAAA,MAAA,OAAA,OACA,gBAAA,KAAA,KACA,OAAA,IAAA,MAAA,QjBFE,cAAA,OeHE,WAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YESJ,mBAAA,KAAA,gBAAA,KAAA,WAAA,KFLI,uCEfN,aFgBQ,WAAA,MEMN,mBACE,aAAA,QACA,QAAA,EAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,uBAAA,mCAEE,cAAA,OACA,iBAAA,KAGF,sBAEE,iBAAA,QAKF,4BACE,MAAA,YACA,YAAA,EAAA,EAAA,EAAA,QAIJ,gBACE,YAAA,OACA,eAAA,OACA,aAAA,MlByOI,UAAA,QClRF,cAAA,MiB8CJ,gBACE,YAAA,MACA,eAAA,MACA,aAAA,KlBiOI,UAAA,QClRF,cAAA,MkBfJ,YACE,QAAA,MACA,WAAA,OACA,aAAA,MACA,cAAA,QAEA,8BACE,MAAA,KACA,YAAA,OAIJ,kBACE,MAAA,IACA,OAAA,IACA,WAAA,MACA,eAAA,IACA,iBAAA,KACA,kBAAA,UACA,oBAAA,OACA,gBAAA,QACA,OAAA,IAAA,MAAA,gBACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KACA,2BAAA,MAAA,aAAA,MAGA,iClBXE,cAAA,MkBeF,8BAEE,cAAA,IAGF,yBACE,OAAA,gBAGF,wBACE,aAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,0BACE,iBAAA,QACA,aAAA,QAEA,yCAII,iBAAA,8NAIJ,sCAII,iBAAA,sIAKN,+CACE,iBAAA,QACA,aAAA,QAKE,iBAAA,wNAIJ,2BACE,eAAA,KACA,OAAA,KACA,QAAA,GAOA,6CAAA,8CACE,QAAA,GAcN,aACE,aAAA,MAEA,+BACE,MAAA,IACA,YAAA,OACA,iBAAA,uJACA,oBAAA,KAAA,OlB9FA,cAAA,IeHE,WAAA,oBAAA,KAAA,YAIA,uCGyFJ,+BHxFM,WAAA,MGgGJ,qCACE,iBAAA,yIAGF,uCACE,oBAAA,MAAA,OAKE,iBAAA,sIAMR,mBACE,QAAA,aACA,aAAA,KAGF,WACE,SAAA,SACA,KAAA,cACA,eAAA,KAIE,yBAAA,0BACE,eAAA,KACA,OAAA,KACA,QAAA,IC9IN,YACE,MAAA,KACA,OAAA,OACA,QAAA,EACA,iBAAA,YACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KAEA,kBACE,QAAA,EAIA,wCAA0B,WAAA,EAAA,EAAA,EAAA,IAAA,IAAA,CAAA,EAAA,EAAA,EAAA,OAAA,qBAC1B,oCAA0B,WAAA,EAAA,EAAA,EAAA,IAAA,IAAA,CAAA,EAAA,EAAA,EAAA,OAAA,qBAG5B,8BACE,OAAA,EAGF,kCACE,MAAA,KACA,OAAA,KACA,WAAA,QHzBF,iBAAA,QG2BE,OAAA,EnBZA,cAAA,KeHE,mBAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YImBF,mBAAA,KAAA,WAAA,KJfE,uCIMJ,kCJLM,mBAAA,KAAA,WAAA,MIgBJ,yCHjCF,iBAAA,QGsCA,2CACE,MAAA,KACA,OAAA,MACA,MAAA,YACA,OAAA,QACA,iBAAA,QACA,aAAA,YnB7BA,cAAA,KmBkCF,8BACE,MAAA,KACA,OAAA,KHnDF,iBAAA,QGqDE,OAAA,EnBtCA,cAAA,KeHE,gBAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YI6CF,gBAAA,KAAA,WAAA,KJzCE,uCIiCJ,8BJhCM,gBAAA,KAAA,WAAA,MI0CJ,qCH3DF,iBAAA,QGgEA,8BACE,MAAA,KACA,OAAA,MACA,MAAA,YACA,OAAA,QACA,iBAAA,QACA,aAAA,YnBvDA,cAAA,KmB4DF,qBACE,eAAA,KAEA,2CACE,iBAAA,QAGF,uCACE,iBAAA,QCvFN,eACE,SAAA,SAEA,6BtB4lFF,4BsB1lFI,OAAA,mBACA,YAAA,KAGF,qBACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,OAAA,KACA,QAAA,KAAA,OACA,eAAA,KACA,OAAA,IAAA,MAAA,YACA,iBAAA,EAAA,ELDE,WAAA,QAAA,IAAA,WAAA,CAAA,UAAA,IAAA,YAIA,uCKXJ,qBLYM,WAAA,MKCN,6BACE,QAAA,KAAA,OAEA,+CACE,MAAA,YADF,0CACE,MAAA,YAGF,0DAEE,YAAA,SACA,eAAA,QAHF,mCAAA,qDAEE,YAAA,SACA,eAAA,QAGF,8CACE,YAAA,SACA,eAAA,QAIJ,4BACE,YAAA,SACA,eAAA,QAMA,gEACE,QAAA,IACA,UAAA,WAAA,mBAAA,mBAFF,yCtBgmFJ,2DACA,kCsBhmFM,QAAA,IACA,UAAA,WAAA,mBAAA,mBAKF,oDACE,QAAA,IACA,UAAA,WAAA,mBAAA,mBCtDN,aACE,SAAA,SACA,QAAA,KACA,UAAA,KACA,YAAA,QACA,MAAA,KAEA,2BvBwpFF,0BuBtpFI,SAAA,SACA,KAAA,EAAA,EAAA,KACA,MAAA,GACA,UAAA,EAIF,iCvBspFF,gCuBppFI,QAAA,EAMF,kBACE,SAAA,SACA,QAAA,EAEA,wBACE,QAAA,EAWN,kBACE,QAAA,KACA,YAAA,OACA,QAAA,QAAA,OtBsPI,UAAA,KsBpPJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,OACA,YAAA,OACA,iBAAA,QACA,OAAA,IAAA,MAAA,QrBpCE,cAAA,OForFJ,qBuBtoFA,8BvBooFA,6BACA,kCuBjoFE,QAAA,MAAA,KtBgOI,UAAA,QClRF,cAAA,MF6rFJ,qBuBtoFA,8BvBooFA,6BACA,kCuBjoFE,QAAA,OAAA,MtBuNI,UAAA,QClRF,cAAA,MqBgEJ,6BvBooFA,6BuBloFE,cAAA,KvBuoFF,uEuB1nFI,8FrB/DA,wBAAA,EACA,2BAAA,EF6rFJ,iEuBxnFI,2FrBtEA,wBAAA,EACA,2BAAA,EqBgFF,0IACE,YAAA,KrBpEA,uBAAA,EACA,0BAAA,EsBzBF,gBACE,QAAA,KACA,MAAA,KACA,WAAA,OvByQE,UAAA,OuBtQF,MAAA,QAGF,eACE,SAAA,SACA,IAAA,KACA,QAAA,EACA,QAAA,KACA,UAAA,KACA,QAAA,OAAA,MACA,WAAA,MvB4PE,UAAA,QuBzPF,MAAA,KACA,iBAAA,mBtB1BA,cAAA,OFgvFJ,0BACA,yBwBltFI,sCxBgtFJ,qCwB9sFM,QAAA,MA9CF,uBAAA,mCAoDE,aAAA,QAGE,cAAA,qBACA,iBAAA,2OACA,kBAAA,UACA,oBAAA,MAAA,wBAAA,OACA,gBAAA,sBAAA,sBAGF,6BAAA,yCACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBAhEJ,2CAAA,+BAyEI,cAAA,qBACA,oBAAA,IAAA,wBAAA,MAAA,wBA1EJ,sBAAA,kCAiFE,aAAA,QAGE,kDAAA,gDAAA,8DAAA,4DAEE,cAAA,SACA,iBAAA,+NAAA,CAAA,2OACA,oBAAA,MAAA,OAAA,MAAA,CAAA,OAAA,MAAA,QACA,gBAAA,KAAA,IAAA,CAAA,sBAAA,sBAIJ,4BAAA,wCACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBA/FJ,2BAAA,uCAsGE,aAAA,QAEA,mCAAA,+CACE,iBAAA,QAGF,iCAAA,6CACE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,6CAAA,yDACE,MAAA,QAKJ,qDACE,YAAA,KAvHF,oCxBqzFJ,mCwBrzFI,gDxBozFJ,+CwBrrFQ,QAAA,EAIF,0CxBurFN,yCwBvrFM,sDxBsrFN,qDwBrrFQ,QAAA,EAjHN,kBACE,QAAA,KACA,MAAA,KACA,WAAA,OvByQE,UAAA,OuBtQF,MAAA,QAGF,iBACE,SAAA,SACA,IAAA,KACA,QAAA,EACA,QAAA,KACA,UAAA,KACA,QAAA,OAAA,MACA,WAAA,MvB4PE,UAAA,QuBzPF,MAAA,KACA,iBAAA,mBtB1BA,cAAA,OFy0FJ,8BACA,6BwB3yFI,0CxByyFJ,yCwBvyFM,QAAA,MA9CF,yBAAA,qCAoDE,aAAA,QAGE,cAAA,qBACA,iBAAA,2TACA,kBAAA,UACA,oBAAA,MAAA,wBAAA,OACA,gBAAA,sBAAA,sBAGF,+BAAA,2CACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBAhEJ,6CAAA,iCAyEI,cAAA,qBACA,oBAAA,IAAA,wBAAA,MAAA,wBA1EJ,wBAAA,oCAiFE,aAAA,QAGE,oDAAA,kDAAA,gEAAA,8DAEE,cAAA,SACA,iBAAA,+NAAA,CAAA,2TACA,oBAAA,MAAA,OAAA,MAAA,CAAA,OAAA,MAAA,QACA,gBAAA,KAAA,IAAA,CAAA,sBAAA,sBAIJ,8BAAA,0CACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBA/FJ,6BAAA,yCAsGE,aAAA,QAEA,qCAAA,iDACE,iBAAA,QAGF,mCAAA,+CACE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,+CAAA,2DACE,MAAA,QAKJ,uDACE,YAAA,KAvHF,sCxB84FJ,qCwB94FI,kDxB64FJ,iDwB5wFQ,QAAA,EAEF,4CxBgxFN,2CwBhxFM,wDxB+wFN,uDwB9wFQ,QAAA,ECtIR,KACE,QAAA,aAEA,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,OACA,gBAAA,KAEA,eAAA,OACA,OAAA,QACA,oBAAA,KAAA,iBAAA,KAAA,YAAA,KACA,iBAAA,YACA,OAAA,IAAA,MAAA,YC8GA,QAAA,QAAA,OzBsKI,UAAA,KClRF,cAAA,OeHE,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCQhBN,KRiBQ,WAAA,MQAN,WACE,MAAA,QAIF,sBAAA,WAEE,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAcF,cAAA,cAAA,uBAGE,eAAA,KACA,QAAA,IAYF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,eCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,qBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,gCAAA,qBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,iCAAA,kCAAA,sBAAA,sBAAA,qCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,uCAAA,wCAAA,4BAAA,4BAAA,2CAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,wBAAA,wBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,UCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,gBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,2BAAA,gBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,4BAAA,6BAAA,iBAAA,iBAAA,gCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,kCAAA,mCAAA,uBAAA,uBAAA,sCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,mBAAA,mBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,YCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,kBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,6BAAA,kBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAIJ,8BAAA,+BAAA,mBAAA,mBAAA,kCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,oCAAA,qCAAA,yBAAA,yBAAA,wCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,qBAAA,qBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,WCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,iBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,4BAAA,iBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,6BAAA,8BAAA,kBAAA,kBAAA,iCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,mCAAA,oCAAA,wBAAA,wBAAA,uCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,oBAAA,oBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,UCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,gBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,2BAAA,gBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,kBAIJ,4BAAA,6BAAA,iBAAA,iBAAA,gCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,kCAAA,mCAAA,uBAAA,uBAAA,sCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,kBAKN,mBAAA,mBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDNF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,uBCmBA,MAAA,QACA,aAAA,QAEA,6BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,wCAAA,6BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,yCAAA,0CAAA,8BAAA,4CAAA,8BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,+CAAA,gDAAA,oCAAA,kDAAA,oCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,gCAAA,gCAEE,MAAA,QACA,iBAAA,YDvDF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,kBCmBA,MAAA,QACA,aAAA,QAEA,wBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,mCAAA,wBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,oCAAA,qCAAA,yBAAA,uCAAA,yBAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,0CAAA,2CAAA,+BAAA,6CAAA,+BAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,2BAAA,2BAEE,MAAA,QACA,iBAAA,YDvDF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,oBCmBA,MAAA,QACA,aAAA,QAEA,0BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,qCAAA,0BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,sCAAA,uCAAA,2BAAA,yCAAA,2BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,4CAAA,6CAAA,iCAAA,+CAAA,iCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,6BAAA,6BAEE,MAAA,QACA,iBAAA,YDvDF,mBCmBA,MAAA,QACA,aAAA,QAEA,yBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,oCAAA,yBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,qCAAA,sCAAA,0BAAA,wCAAA,0BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,2CAAA,4CAAA,gCAAA,8CAAA,gCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,4BAAA,4BAEE,MAAA,QACA,iBAAA,YDvDF,kBCmBA,MAAA,QACA,aAAA,QAEA,wBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,mCAAA,wBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,kBAGF,oCAAA,qCAAA,yBAAA,uCAAA,yBAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,0CAAA,2CAAA,+BAAA,6CAAA,+BAKI,WAAA,EAAA,EAAA,EAAA,OAAA,kBAKN,2BAAA,2BAEE,MAAA,QACA,iBAAA,YD3CJ,UACE,YAAA,IACA,MAAA,QACA,gBAAA,UAEA,gBACE,MAAA,QAQF,mBAAA,mBAEE,MAAA,QAWJ,mBAAA,QCuBE,QAAA,MAAA,KzBsKI,UAAA,QClRF,cAAA,MuByFJ,mBAAA,QCmBE,QAAA,OAAA,MzBsKI,UAAA,QClRF,cAAA,MyBnBJ,MVgBM,WAAA,QAAA,KAAA,OAIA,uCUpBN,MVqBQ,WAAA,MUlBN,iBACE,QAAA,EAMF,qBACE,QAAA,KAIJ,YACE,OAAA,EACA,SAAA,OVDI,WAAA,OAAA,KAAA,KAIA,uCULN,YVMQ,WAAA,MUDN,gCACE,MAAA,EACA,OAAA,KVNE,WAAA,MAAA,KAAA,KAIA,uCUAJ,gCVCM,WAAA,MjBm6GR,UADA,SAEA,W4Bx7GA,QAIE,SAAA,SAGF,iBACE,YAAA,OCqBE,wBACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAhCJ,WAAA,KAAA,MACA,aAAA,KAAA,MAAA,YACA,cAAA,EACA,YAAA,KAAA,MAAA,YAqDE,8BACE,YAAA,ED3CN,eACE,SAAA,SACA,QAAA,KACA,QAAA,KACA,UAAA,MACA,QAAA,MAAA,EACA,OAAA,E3B+QI,UAAA,K2B7QJ,MAAA,QACA,WAAA,KACA,WAAA,KACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,gB1BVE,cAAA,O0BcF,+BACE,IAAA,KACA,KAAA,EACA,WAAA,QAYA,qBACE,cAAA,MAEA,qCACE,MAAA,KACA,KAAA,EAIJ,mBACE,cAAA,IAEA,mCACE,MAAA,EACA,KAAA,KnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,0BmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,0BmBfA,yBACE,cAAA,MAEA,yCACE,MAAA,KACA,KAAA,EAIJ,uBACE,cAAA,IAEA,uCACE,MAAA,EACA,KAAA,MAUN,uCACE,IAAA,KACA,OAAA,KACA,WAAA,EACA,cAAA,QC9CA,gCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAzBJ,WAAA,EACA,aAAA,KAAA,MAAA,YACA,cAAA,KAAA,MACA,YAAA,KAAA,MAAA,YA8CE,sCACE,YAAA,ED0BJ,wCACE,IAAA,EACA,MAAA,KACA,KAAA,KACA,WAAA,EACA,YAAA,QC5DA,iCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAlBJ,WAAA,KAAA,MAAA,YACA,aAAA,EACA,cAAA,KAAA,MAAA,YACA,YAAA,KAAA,MAuCE,uCACE,YAAA,EDoCF,iCACE,eAAA,EAMJ,0CACE,IAAA,EACA,MAAA,KACA,KAAA,KACA,WAAA,EACA,aAAA,QC7EA,mCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAWA,mCACE,QAAA,KAGF,oCACE,QAAA,aACA,aAAA,OACA,eAAA,OACA,QAAA,GA9BN,WAAA,KAAA,MAAA,YACA,aAAA,KAAA,MACA,cAAA,KAAA,MAAA,YAiCE,yCACE,YAAA,EDqDF,oCACE,eAAA,EAON,kBACE,OAAA,EACA,OAAA,MAAA,EACA,SAAA,OACA,WAAA,IAAA,MAAA,gBAMF,eACE,QAAA,MACA,MAAA,KACA,QAAA,OAAA,KACA,MAAA,KACA,YAAA,IACA,MAAA,QACA,WAAA,QACA,gBAAA,KACA,YAAA,OACA,iBAAA,YACA,OAAA,EAcA,qBAAA,qBAEE,MAAA,QVzJF,iBAAA,QU8JA,sBAAA,sBAEE,MAAA,KACA,gBAAA,KVjKF,iBAAA,QUqKA,wBAAA,wBAEE,MAAA,QACA,eAAA,KACA,iBAAA,YAMJ,oBACE,QAAA,MAIF,iBACE,QAAA,MACA,QAAA,MAAA,KACA,cAAA,E3B0GI,UAAA,Q2BxGJ,MAAA,QACA,YAAA,OAIF,oBACE,QAAA,MACA,QAAA,OAAA,KACA,MAAA,QAIF,oBACE,MAAA,QACA,iBAAA,QACA,aAAA,gBAGA,mCACE,MAAA,QAEA,yCAAA,yCAEE,MAAA,KVhNJ,iBAAA,sBUoNE,0CAAA,0CAEE,MAAA,KVtNJ,iBAAA,QU0NE,4CAAA,4CAEE,MAAA,QAIJ,sCACE,aAAA,gBAGF,wCACE,MAAA,QAGF,qCACE,MAAA,QE5OJ,W9BwuHA,oB8BtuHE,SAAA,SACA,QAAA,YACA,eAAA,O9B0uHF,yB8BxuHE,gBACE,SAAA,SACA,KAAA,EAAA,EAAA,K9BgvHJ,4CACA,0CAIA,gCADA,gCADA,+BADA,+B8B7uHE,mC9BsuHF,iCAIA,uBADA,uBADA,sBADA,sB8BjuHI,QAAA,EAKJ,aACE,QAAA,KACA,UAAA,KACA,gBAAA,WAEA,0BACE,MAAA,K9B6uHJ,wC8BvuHE,kCAEE,YAAA,K9ByuHJ,4C8BruHE,uD5BRE,wBAAA,EACA,2BAAA,EFkvHJ,6C8BluHE,+B9BiuHF,iCEpuHI,uBAAA,EACA,0BAAA,E4BqBJ,uBACE,cAAA,SACA,aAAA,SAEA,8BAAA,uCAAA,sCAGE,YAAA,EAGF,0CACE,aAAA,EAIJ,0CAAA,+BACE,cAAA,QACA,aAAA,QAGF,0CAAA,+BACE,cAAA,OACA,aAAA,OAoBF,oBACE,eAAA,OACA,YAAA,WACA,gBAAA,OAEA,yB9BgsHF,+B8B9rHI,MAAA,K9BksHJ,iD8B/rHE,2CAEE,WAAA,K9BisHJ,qD8B7rHE,gE5BvFE,2BAAA,EACA,0BAAA,EFwxHJ,sD8B7rHE,8B5B1GE,uBAAA,EACA,wBAAA,E6BxBJ,KACE,QAAA,KACA,UAAA,KACA,aAAA,EACA,cAAA,EACA,WAAA,KAGF,UACE,QAAA,MACA,QAAA,MAAA,KAGA,MAAA,QACA,gBAAA,KdHI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,YAIA,uCcPN,UdQQ,WAAA,McCN,gBAAA,gBAEE,MAAA,QAKF,mBACE,MAAA,QACA,eAAA,KACA,OAAA,QAQJ,UACE,cAAA,IAAA,MAAA,QAEA,oBACE,cAAA,KACA,WAAA,IACA,OAAA,IAAA,MAAA,Y7BlBA,uBAAA,OACA,wBAAA,O6BoBA,0BAAA,0BAEE,aAAA,QAAA,QAAA,QAEA,UAAA,QAGF,6BACE,MAAA,QACA,iBAAA,YACA,aAAA,Y/B8zHN,mC+B1zHE,2BAEE,MAAA,QACA,iBAAA,KACA,aAAA,QAAA,QAAA,KAGF,yBAEE,WAAA,K7B5CA,uBAAA,EACA,wBAAA,E6BuDF,qBACE,WAAA,IACA,OAAA,E7BnEA,cAAA,O6BuEF,4B/BgzHF,2B+B9yHI,MAAA,KbxFF,iBAAA,QlB44HF,oB+BzyHE,oBAEE,KAAA,EAAA,EAAA,KACA,WAAA,O/B4yHJ,yB+BvyHE,yBAEE,WAAA,EACA,UAAA,EACA,WAAA,OAMF,8B/BoyHF,mC+BnyHI,MAAA,KAUF,uBACE,QAAA,KAEF,qBACE,QAAA,MCxHJ,QACE,SAAA,SACA,QAAA,KACA,UAAA,KACA,YAAA,OACA,gBAAA,cACA,YAAA,MAEA,eAAA,MAOA,mBhCm5HF,yBAGA,sBADA,sBADA,sBAGA,sBACA,uBgCv5HI,QAAA,KACA,UAAA,QACA,YAAA,OACA,gBAAA,cAoBJ,cACE,YAAA,SACA,eAAA,SACA,aAAA,K/B2OI,UAAA,Q+BzOJ,gBAAA,KACA,YAAA,OAaF,YACE,QAAA,KACA,eAAA,OACA,aAAA,EACA,cAAA,EACA,WAAA,KAEA,sBACE,cAAA,EACA,aAAA,EAGF,2BACE,SAAA,OASJ,aACE,YAAA,MACA,eAAA,MAYF,iBACE,WAAA,KACA,UAAA,EAGA,YAAA,OAIF,gBACE,QAAA,OAAA,O/B6KI,UAAA,Q+B3KJ,YAAA,EACA,iBAAA,YACA,OAAA,IAAA,MAAA,Y9BzGE,cAAA,OeHE,WAAA,WAAA,KAAA,YAIA,uCemGN,gBflGQ,WAAA,Me2GN,sBACE,gBAAA,KAGF,sBACE,gBAAA,KACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAMJ,qBACE,QAAA,aACA,MAAA,MACA,OAAA,MACA,eAAA,OACA,kBAAA,UACA,oBAAA,OACA,gBAAA,KAGF,mBACE,WAAA,6BACA,WAAA,KvB1FE,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhC41HV,oCgC11HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCi5HV,oCgC/4HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCs8HV,oCgCp8HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,0BuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhC2/HV,oCgCz/HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,0BuBsGA,mBAEI,UAAA,OACA,gBAAA,WAEA,+BACE,eAAA,IAEA,8CACE,SAAA,SAGF,yCACE,cAAA,MACA,aAAA,MAIJ,sCACE,SAAA,QAGF,oCACE,QAAA,eACA,WAAA,KAGF,mCACE,QAAA,KAGF,qCACE,QAAA,KAGF,8BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCgjIV,qCgC9iIQ,kCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,mCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SA1DN,eAEI,UAAA,OACA,gBAAA,WAEA,2BACE,eAAA,IAEA,0CACE,SAAA,SAGF,qCACE,cAAA,MACA,aAAA,MAIJ,kCACE,SAAA,QAGF,gCACE,QAAA,eACA,WAAA,KAGF,+BACE,QAAA,KAGF,iCACE,QAAA,KAGF,0BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhComIV,iCgClmIQ,8BAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,+BACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,QAcR,4BACE,MAAA,eAEA,kCAAA,kCAEE,MAAA,eAKF,oCACE,MAAA,gBAEA,0CAAA,0CAEE,MAAA,eAGF,6CACE,MAAA,ehCklIR,2CgC9kII,0CAEE,MAAA,eAIJ,8BACE,MAAA,gBACA,aAAA,eAGF,mCACE,iBAAA,4OAGF,2BACE,MAAA,gBAEA,6BhC2kIJ,mCADA,mCgCvkIM,MAAA,eAOJ,2BACE,MAAA,KAEA,iCAAA,iCAEE,MAAA,KAKF,mCACE,MAAA,sBAEA,yCAAA,yCAEE,MAAA,sBAGF,4CACE,MAAA,sBhCkkIR,0CgC9jII,yCAEE,MAAA,KAIJ,6BACE,MAAA,sBACA,aAAA,qBAGF,kCACE,iBAAA,kPAGF,0BACE,MAAA,sBACA,4BhC4jIJ,kCADA,kCgCxjIM,MAAA,KCvUN,MACE,SAAA,SACA,QAAA,KACA,eAAA,OACA,UAAA,EAEA,UAAA,WACA,iBAAA,KACA,gBAAA,WACA,OAAA,IAAA,MAAA,iB/BME,cAAA,O+BFF,SACE,aAAA,EACA,YAAA,EAGF,kBACE,WAAA,QACA,cAAA,QAEA,8BACE,iBAAA,E/BCF,uBAAA,mBACA,wBAAA,mB+BEA,6BACE,oBAAA,E/BUF,2BAAA,mBACA,0BAAA,mB+BJF,+BjC+3IF,+BiC73II,WAAA,EAIJ,WAGE,KAAA,EAAA,EAAA,KACA,QAAA,KAAA,KAIF,YACE,cAAA,MAGF,eACE,WAAA,QACA,cAAA,EAGF,sBACE,cAAA,EAQA,sBACE,YAAA,KAQJ,aACE,QAAA,MAAA,KACA,cAAA,EAEA,iBAAA,gBACA,cAAA,IAAA,MAAA,iBAEA,yB/BpEE,cAAA,mBAAA,mBAAA,EAAA,E+ByEJ,aACE,QAAA,MAAA,KAEA,iBAAA,gBACA,WAAA,IAAA,MAAA,iBAEA,wB/B/EE,cAAA,EAAA,EAAA,mBAAA,mB+ByFJ,kBACE,aAAA,OACA,cAAA,OACA,YAAA,OACA,cAAA,EAUF,mBACE,aAAA,OACA,YAAA,OAIF,kBACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,K/BnHE,cAAA,mB+BuHJ,UjCi2IA,iBADA,ciC71IE,MAAA,KAGF,UjCg2IA,cEp9II,uBAAA,mBACA,wBAAA,mB+BwHJ,UjCi2IA,iBE58II,2BAAA,mBACA,0BAAA,mB+BuHF,kBACE,cAAA,OxBpGA,yBwBgGJ,YAQI,QAAA,KACA,UAAA,IAAA,KAGA,kBAEE,KAAA,EAAA,EAAA,GACA,cAAA,EAEA,wBACE,YAAA,EACA,YAAA,EAKA,mC/BpJJ,wBAAA,EACA,2BAAA,EF4+IJ,gDiCt1IU,iDAGE,wBAAA,EjCu1IZ,gDiCr1IU,oDAGE,2BAAA,EAIJ,oC/BrJJ,uBAAA,EACA,0BAAA,EF0+IJ,iDiCn1IU,kDAGE,uBAAA,EjCo1IZ,iDiCl1IU,qDAGE,0BAAA,GC7MZ,kBACE,SAAA,SACA,QAAA,KACA,YAAA,OACA,MAAA,KACA,QAAA,KAAA,QjC4RI,UAAA,KiC1RJ,MAAA,QACA,WAAA,KACA,iBAAA,KACA,OAAA,EhCKE,cAAA,EgCHF,gBAAA,KjBAI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,WAAA,CAAA,cAAA,KAAA,KAIA,uCiBhBN,kBjBiBQ,WAAA,MiBFN,kCACE,MAAA,QACA,iBAAA,QACA,WAAA,MAAA,EAAA,KAAA,EAAA,iBAEA,yCACE,iBAAA,gRACA,UAAA,gBAKJ,yBACE,YAAA,EACA,MAAA,QACA,OAAA,QACA,YAAA,KACA,QAAA,GACA,iBAAA,gRACA,kBAAA,UACA,gBAAA,QjBvBE,WAAA,UAAA,IAAA,YAIA,uCiBWJ,yBjBVM,WAAA,MiBsBN,wBACE,QAAA,EAGF,wBACE,QAAA,EACA,aAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,kBACE,cAAA,EAGF,gBACE,iBAAA,KACA,OAAA,IAAA,MAAA,iBAEA,8BhCnCE,uBAAA,OACA,wBAAA,OgCqCA,gDhCtCA,uBAAA,mBACA,wBAAA,mBgC0CF,oCACE,WAAA,EAIF,6BhClCE,2BAAA,OACA,0BAAA,OgCqCE,yDhCtCF,2BAAA,mBACA,0BAAA,mBgC0CA,iDhC3CA,2BAAA,OACA,0BAAA,OgCgDJ,gBACE,QAAA,KAAA,QASA,qCACE,aAAA,EAGF,iCACE,aAAA,EACA,YAAA,EhCxFA,cAAA,EgC2FA,6CAAgB,WAAA,EAChB,4CAAe,cAAA,EAEf,mDhC9FA,cAAA,EiCnBJ,YACE,QAAA,KACA,UAAA,KACA,QAAA,EAAA,EACA,cAAA,KAEA,WAAA,KAOA,kCACE,aAAA,MAEA,0CACE,MAAA,KACA,cAAA,MACA,MAAA,QACA,QAAA,kCAIJ,wBACE,MAAA,QCzBJ,YACE,QAAA,KhCGA,aAAA,EACA,WAAA,KgCAF,WACE,SAAA,SACA,QAAA,MACA,MAAA,QACA,gBAAA,KACA,iBAAA,KACA,OAAA,IAAA,MAAA,QnBKI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCmBfN,WnBgBQ,WAAA,MmBPN,iBACE,QAAA,EACA,MAAA,QAEA,iBAAA,QACA,aAAA,QAGF,iBACE,QAAA,EACA,MAAA,QACA,iBAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKF,wCACE,YAAA,KAGF,6BACE,QAAA,EACA,MAAA,KlBlCF,iBAAA,QkBoCE,aAAA,QAGF,+BACE,MAAA,QACA,eAAA,KACA,iBAAA,KACA,aAAA,QC3CF,WACE,QAAA,QAAA,OAOI,kCnCqCJ,uBAAA,OACA,0BAAA,OmChCI,iCnCiBJ,wBAAA,OACA,2BAAA,OmChCF,0BACE,QAAA,OAAA,OpCgSE,UAAA,QoCzRE,iDnCqCJ,uBAAA,MACA,0BAAA,MmChCI,gDnCiBJ,wBAAA,MACA,2BAAA,MmChCF,0BACE,QAAA,OAAA,MpCgSE,UAAA,QoCzRE,iDnCqCJ,uBAAA,MACA,0BAAA,MmChCI,gDnCiBJ,wBAAA,MACA,2BAAA,MoC/BJ,OACE,QAAA,aACA,QAAA,MAAA,MrC8RI,UAAA,MqC5RJ,YAAA,IACA,YAAA,EACA,MAAA,KACA,WAAA,OACA,YAAA,OACA,eAAA,SpCKE,cAAA,OoCAF,aACE,QAAA,KAKJ,YACE,SAAA,SACA,IAAA,KCvBF,OACE,SAAA,SACA,QAAA,KAAA,KACA,cAAA,KACA,OAAA,IAAA,MAAA,YrCWE,cAAA,OqCNJ,eAEE,MAAA,QAIF,YACE,YAAA,IAQF,mBACE,cAAA,KAGA,8BACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,QAAA,EACA,QAAA,QAAA,KAeF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,iBClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,6BACE,MAAA,QD6CF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,YClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,wBACE,MAAA,QD6CF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,cClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,0BACE,MAAA,QD6CF,aClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,yBACE,MAAA,QD6CF,YClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,wBACE,MAAA,QCHF,wCACE,GAAK,sBAAA,MADP,gCACE,GAAK,sBAAA,MAKT,UACE,QAAA,KACA,OAAA,KACA,SAAA,OxCwRI,UAAA,OwCtRJ,iBAAA,QvCIE,cAAA,OuCCJ,cACE,QAAA,KACA,eAAA,OACA,gBAAA,OACA,SAAA,OACA,MAAA,KACA,WAAA,OACA,YAAA,OACA,iBAAA,QxBZI,WAAA,MAAA,IAAA,KAIA,uCwBAN,cxBCQ,WAAA,MwBWR,sBvBYE,iBAAA,iKuBVA,gBAAA,KAAA,KAIA,uBACE,kBAAA,GAAA,OAAA,SAAA,qBAAA,UAAA,GAAA,OAAA,SAAA,qBAGE,uCAJJ,uBAKM,kBAAA,KAAA,UAAA,MCvCR,YACE,QAAA,KACA,eAAA,OAGA,aAAA,EACA,cAAA,ExCSE,cAAA,OwCLJ,qBACE,gBAAA,KACA,cAAA,QAEA,gCAEE,QAAA,uBAAA,KACA,kBAAA,QAUJ,wBACE,MAAA,KACA,MAAA,QACA,WAAA,QAGA,8BAAA,8BAEE,QAAA,EACA,MAAA,QACA,gBAAA,KACA,iBAAA,QAGF,+BACE,MAAA,QACA,iBAAA,QASJ,iBACE,SAAA,SACA,QAAA,MACA,QAAA,MAAA,KACA,MAAA,QACA,gBAAA,KACA,iBAAA,KACA,OAAA,IAAA,MAAA,iBAEA,6BxCrCE,uBAAA,QACA,wBAAA,QwCwCF,4BxC3BE,2BAAA,QACA,0BAAA,QwC8BF,0BAAA,0BAEE,MAAA,QACA,eAAA,KACA,iBAAA,KAIF,wBACE,QAAA,EACA,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,kCACE,iBAAA,EAEA,yCACE,WAAA,KACA,iBAAA,IAcF,uBACE,eAAA,IAGE,oDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,mDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,+CACE,WAAA,EAGF,yDACE,iBAAA,IACA,kBAAA,EAEA,gEACE,YAAA,KACA,kBAAA,IjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,0BiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,0BiC4CA,2BACE,eAAA,IAGE,wDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,uDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,mDACE,WAAA,EAGF,6DACE,iBAAA,IACA,kBAAA,EAEA,oEACE,YAAA,KACA,kBAAA,KAcZ,kBxC9HI,cAAA,EwCiIF,mCACE,aAAA,EAAA,EAAA,IAEA,8CACE,oBAAA,ECpJJ,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,2BACE,MAAA,QACA,iBAAA,QAGE,wDAAA,wDAEE,MAAA,QACA,iBAAA,QAGF,yDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,sBACE,MAAA,QACA,iBAAA,QAGE,mDAAA,mDAEE,MAAA,QACA,iBAAA,QAGF,oDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,wBACE,MAAA,QACA,iBAAA,QAGE,qDAAA,qDAEE,MAAA,QACA,iBAAA,QAGF,sDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,uBACE,MAAA,QACA,iBAAA,QAGE,oDAAA,oDAEE,MAAA,QACA,iBAAA,QAGF,qDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,sBACE,MAAA,QACA,iBAAA,QAGE,mDAAA,mDAEE,MAAA,QACA,iBAAA,QAGF,oDACE,MAAA,KACA,iBAAA,QACA,aAAA,QCbR,WACE,WAAA,YACA,MAAA,IACA,OAAA,IACA,QAAA,MAAA,MACA,MAAA,KACA,WAAA,YAAA,0TAAA,MAAA,CAAA,IAAA,KAAA,UACA,OAAA,E1COE,cAAA,O0CLF,QAAA,GAGA,iBACE,MAAA,KACA,gBAAA,KACA,QAAA,IAGF,iBACE,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBACA,QAAA,EAGF,oBAAA,oBAEE,eAAA,KACA,oBAAA,KAAA,iBAAA,KAAA,YAAA,KACA,QAAA,IAIJ,iBACE,OAAA,UAAA,gBAAA,iBCtCF,OACE,MAAA,MACA,UAAA,K5CmSI,UAAA,Q4ChSJ,eAAA,KACA,iBAAA,sBACA,gBAAA,YACA,OAAA,IAAA,MAAA,eACA,WAAA,EAAA,MAAA,KAAA,gB3CUE,cAAA,O2CPF,eACE,QAAA,EAGF,kBACE,QAAA,KAIJ,iBACE,MAAA,oBAAA,MAAA,iBAAA,MAAA,YACA,UAAA,KACA,eAAA,KAEA,mCACE,cAAA,OAIJ,cACE,QAAA,KACA,YAAA,OACA,QAAA,MAAA,OACA,MAAA,QACA,iBAAA,sBACA,gBAAA,YACA,cAAA,IAAA,MAAA,gB3CVE,uBAAA,mBACA,wBAAA,mB2CYF,yBACE,aAAA,SACA,YAAA,OAIJ,YACE,QAAA,OACA,UAAA,WC1CF,OACE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,QAAA,KACA,MAAA,KACA,OAAA,KACA,WAAA,OACA,WAAA,KAGA,QAAA,EAOF,cACE,SAAA,SACA,MAAA,KACA,OAAA,MAEA,eAAA,KAGA,0B7BlBI,WAAA,UAAA,IAAA,S6BoBF,UAAA,mB7BhBE,uC6BcJ,0B7BbM,WAAA,M6BiBN,0BACE,UAAA,KAIF,kCACE,UAAA,YAIJ,yBACE,OAAA,kBAEA,wCACE,WAAA,KACA,SAAA,OAGF,qCACE,WAAA,KAIJ,uBACE,QAAA,KACA,YAAA,OACA,WAAA,kBAIF,eACE,SAAA,SACA,QAAA,KACA,eAAA,OACA,MAAA,KAGA,eAAA,KACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,e5C3DE,cAAA,M4C+DF,QAAA,EAIF,gBCpFE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,MAAA,MACA,OAAA,MACA,iBAAA,KAGA,qBAAS,QAAA,EACT,qBAAS,QAAA,GDgFX,cACE,QAAA,KACA,YAAA,EACA,YAAA,OACA,gBAAA,cACA,QAAA,KAAA,KACA,cAAA,IAAA,MAAA,Q5CtEE,uBAAA,kBACA,wBAAA,kB4CwEF,yBACE,QAAA,MAAA,MACA,OAAA,OAAA,OAAA,OAAA,KAKJ,aACE,cAAA,EACA,YAAA,IAKF,YACE,SAAA,SAGA,KAAA,EAAA,EAAA,KACA,QAAA,KAIF,cACE,QAAA,KACA,UAAA,KACA,YAAA,EACA,YAAA,OACA,gBAAA,SACA,QAAA,OACA,WAAA,IAAA,MAAA,Q5CzFE,2BAAA,kBACA,0BAAA,kB4C8FF,gBACE,OAAA,OrC3EA,yBqCkFF,cACE,UAAA,MACA,OAAA,QAAA,KAGF,yBACE,OAAA,oBAGF,uBACE,WAAA,oBAOF,UAAY,UAAA,OrCnGV,yBqCuGF,U9CszKF,U8CpzKI,UAAA,OrCzGA,0BqC8GF,UAAY,UAAA,QASV,kBACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,iCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,gC5C/KF,cAAA,E4CmLE,8BACE,WAAA,KAGF,gC5CvLF,cAAA,EOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,6BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,6BqC0GA,2BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,0CACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,yC5C/KF,cAAA,E4CmLE,uCACE,WAAA,KAGF,yC5CvLF,cAAA,G8ClBJ,SACE,SAAA,SACA,QAAA,KACA,QAAA,MACA,OAAA,ECJA,YAAA,0BAEA,WAAA,OACA,YAAA,IACA,YAAA,IACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,OACA,WAAA,OACA,aAAA,OACA,YAAA,OACA,WAAA,KhDsRI,UAAA,Q+C1RJ,UAAA,WACA,QAAA,EAEA,cAAS,QAAA,GAET,wBACE,SAAA,SACA,QAAA,MACA,MAAA,MACA,OAAA,MAEA,gCACE,SAAA,SACA,QAAA,GACA,aAAA,YACA,aAAA,MAKN,6CAAA,gBACE,QAAA,MAAA,EAEA,4DAAA,+BACE,OAAA,EAEA,oEAAA,uCACE,IAAA,KACA,aAAA,MAAA,MAAA,EACA,iBAAA,KAKN,+CAAA,gBACE,QAAA,EAAA,MAEA,8DAAA,+BACE,KAAA,EACA,MAAA,MACA,OAAA,MAEA,sEAAA,uCACE,MAAA,KACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,KAKN,gDAAA,mBACE,QAAA,MAAA,EAEA,+DAAA,kCACE,IAAA,EAEA,uEAAA,0CACE,OAAA,KACA,aAAA,EAAA,MAAA,MACA,oBAAA,KAKN,8CAAA,kBACE,QAAA,EAAA,MAEA,6DAAA,iCACE,MAAA,EACA,MAAA,MACA,OAAA,MAEA,qEAAA,yCACE,KAAA,KACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,KAqBN,eACE,UAAA,MACA,QAAA,OAAA,MACA,MAAA,KACA,WAAA,OACA,iBAAA,K9C7FE,cAAA,OgDnBJ,SACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,QAAA,MACA,UAAA,MDLA,YAAA,0BAEA,WAAA,OACA,YAAA,IACA,YAAA,IACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,OACA,WAAA,OACA,aAAA,OACA,YAAA,OACA,WAAA,KhDsRI,UAAA,QiDzRJ,UAAA,WACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,ehDIE,cAAA,MgDAF,wBACE,SAAA,SACA,QAAA,MACA,MAAA,KACA,OAAA,MAEA,+BAAA,gCAEE,SAAA,SACA,QAAA,MACA,QAAA,GACA,aAAA,YACA,aAAA,MAMJ,4DAAA,+BACE,OAAA,mBAEA,oEAAA,uCACE,OAAA,EACA,aAAA,MAAA,MAAA,EACA,iBAAA,gBAGF,mEAAA,sCACE,OAAA,IACA,aAAA,MAAA,MAAA,EACA,iBAAA,KAMJ,8DAAA,+BACE,KAAA,mBACA,MAAA,MACA,OAAA,KAEA,sEAAA,uCACE,KAAA,EACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,gBAGF,qEAAA,sCACE,KAAA,IACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,KAMJ,+DAAA,kCACE,IAAA,mBAEA,uEAAA,0CACE,IAAA,EACA,aAAA,EAAA,MAAA,MAAA,MACA,oBAAA,gBAGF,sEAAA,yCACE,IAAA,IACA,aAAA,EAAA,MAAA,MAAA,MACA,oBAAA,KAKJ,wEAAA,2CACE,SAAA,SACA,IAAA,EACA,KAAA,IACA,QAAA,MACA,MAAA,KACA,YAAA,OACA,QAAA,GACA,cAAA,IAAA,MAAA,QAKF,6DAAA,iCACE,MAAA,mBACA,MAAA,MACA,OAAA,KAEA,qEAAA,yCACE,MAAA,EACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,gBAGF,oEAAA,wCACE,MAAA,IACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,KAqBN,gBACE,QAAA,MAAA,KACA,cAAA,EjDuJI,UAAA,KiDpJJ,iBAAA,QACA,cAAA,IAAA,MAAA,ehDtHE,uBAAA,kBACA,wBAAA,kBgDwHF,sBACE,QAAA,KAIJ,cACE,QAAA,KAAA,KACA,MAAA,QC/IF,UACE,SAAA,SAGF,wBACE,aAAA,MAGF,gBACE,SAAA,SACA,MAAA,KACA,SAAA,OCtBA,uBACE,QAAA,MACA,MAAA,KACA,QAAA,GDuBJ,eACE,SAAA,SACA,QAAA,KACA,MAAA,KACA,MAAA,KACA,aAAA,MACA,4BAAA,OAAA,oBAAA,OlClBI,WAAA,UAAA,IAAA,YAIA,uCkCQN,elCPQ,WAAA,MjB61LR,oBACA,oBmD70LA,sBAGE,QAAA,MnDg1LF,0BmD50LA,8CAEE,UAAA,iBnD+0LF,4BmD50LA,4CAEE,UAAA,kBAWA,8BACE,QAAA,EACA,oBAAA,QACA,UAAA,KnDu0LJ,uDACA,qDmDr0LE,qCAGE,QAAA,EACA,QAAA,EnDs0LJ,yCmDn0LE,2CAEE,QAAA,EACA,QAAA,ElC/DE,WAAA,QAAA,GAAA,IAIA,uCjBk4LN,yCmD10LE,2ClCvDM,WAAA,MjBu4LR,uBmDn0LA,uBAEE,SAAA,SACA,IAAA,EACA,OAAA,EACA,QAAA,EAEA,QAAA,KACA,YAAA,OACA,gBAAA,OACA,MAAA,IACA,QAAA,EACA,MAAA,KACA,WAAA,OACA,WAAA,IACA,OAAA,EACA,QAAA,GlCzFI,WAAA,QAAA,KAAA,KAIA,uCjB25LN,uBmDt1LA,uBlCpEQ,WAAA,MjBg6LR,6BADA,6BmDv0LE,6BAAA,6BAEE,MAAA,KACA,gBAAA,KACA,QAAA,EACA,QAAA,GAGJ,uBACE,KAAA,EAGF,uBACE,MAAA,EnD20LF,4BmDt0LA,4BAEE,QAAA,aACA,MAAA,KACA,OAAA,KACA,kBAAA,UACA,oBAAA,IACA,gBAAA,KAAA,KAWF,4BACE,iBAAA,wPAEF,4BACE,iBAAA,yPAQF,qBACE,SAAA,SACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,EACA,QAAA,KACA,gBAAA,OACA,QAAA,EAEA,aAAA,IACA,cAAA,KACA,YAAA,IACA,WAAA,KAEA,sCACE,WAAA,YACA,KAAA,EAAA,EAAA,KACA,MAAA,KACA,OAAA,IACA,QAAA,EACA,aAAA,IACA,YAAA,IACA,YAAA,OACA,OAAA,QACA,iBAAA,KACA,gBAAA,YACA,OAAA,EAEA,WAAA,KAAA,MAAA,YACA,cAAA,KAAA,MAAA,YACA,QAAA,GlC5KE,WAAA,QAAA,IAAA,KAIA,uCkCwJJ,sClCvJM,WAAA,MkC2KN,6BACE,QAAA,EASJ,kBACE,SAAA,SACA,MAAA,IACA,OAAA,QACA,KAAA,IACA,YAAA,QACA,eAAA,QACA,MAAA,KACA,WAAA,OnDi0LF,2CmD3zLE,2CAEE,OAAA,UAAA,eAGF,qDACE,iBAAA,KAGF,iCACE,MAAA,KE7NJ,kCACE,GAAK,UAAA,gBADP,0BACE,GAAK,UAAA,gBAIP,gBACE,QAAA,aACA,MAAA,KACA,OAAA,KACA,eAAA,QACA,OAAA,MAAA,MAAA,aACA,mBAAA,YAEA,cAAA,IACA,kBAAA,KAAA,OAAA,SAAA,eAAA,UAAA,KAAA,OAAA,SAAA,eAGF,mBACE,MAAA,KACA,OAAA,KACA,aAAA,KAQF,gCACE,GACE,UAAA,SAEF,IACE,QAAA,EACA,UAAA,MANJ,wBACE,GACE,UAAA,SAEF,IACE,QAAA,EACA,UAAA,MAKJ,cACE,QAAA,aACA,MAAA,KACA,OAAA,KACA,eAAA,QACA,iBAAA,aAEA,cAAA,IACA,QAAA,EACA,kBAAA,KAAA,OAAA,SAAA,aAAA,UAAA,KAAA,OAAA,SAAA,aAGF,iBACE,MAAA,KACA,OAAA,KAIA,uCACE,gBrDiiMJ,cqD/hMM,2BAAA,KAAA,mBAAA,MCjEN,WACE,SAAA,MACA,OAAA,EACA,QAAA,KACA,QAAA,KACA,eAAA,OACA,UAAA,KAEA,WAAA,OACA,iBAAA,KACA,gBAAA,YACA,QAAA,ErCKI,WAAA,UAAA,IAAA,YAIA,uCqCpBN,WrCqBQ,WAAA,MqCLR,oBPdE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,MAAA,MACA,OAAA,MACA,iBAAA,KAGA,yBAAS,QAAA,EACT,yBAAS,QAAA,GOQX,kBACE,QAAA,KACA,YAAA,OACA,gBAAA,cACA,QAAA,KAAA,KAEA,6BACE,QAAA,MAAA,MACA,WAAA,OACA,aAAA,OACA,cAAA,OAIJ,iBACE,cAAA,EACA,YAAA,IAGF,gBACE,UAAA,EACA,QAAA,KAAA,KACA,WAAA,KAGF,iBACE,IAAA,EACA,KAAA,EACA,MAAA,MACA,aAAA,IAAA,MAAA,eACA,UAAA,kBAGF,eACE,IAAA,EACA,MAAA,EACA,MAAA,MACA,YAAA,IAAA,MAAA,eACA,UAAA,iBAGF,eACE,IAAA,EACA,MAAA,EACA,KAAA,EACA,OAAA,KACA,WAAA,KACA,cAAA,IAAA,MAAA,eACA,UAAA,kBAGF,kBACE,MAAA,EACA,KAAA,EACA,OAAA,KACA,WAAA,KACA,WAAA,IAAA,MAAA,eACA,UAAA,iBAGF,gBACE,UAAA,KCjFF,aACE,QAAA,aACA,WAAA,IACA,eAAA,OACA,OAAA,KACA,iBAAA,aACA,QAAA,GAEA,yBACE,QAAA,aACA,QAAA,GAKJ,gBACE,WAAA,KAGF,gBACE,WAAA,KAGF,gBACE,WAAA,MAKA,+BACE,kBAAA,iBAAA,GAAA,YAAA,SAAA,UAAA,iBAAA,GAAA,YAAA,SAIJ,oCACE,IACE,QAAA,IAFJ,4BACE,IACE,QAAA,IAIJ,kBACE,mBAAA,8DAAA,WAAA,8DACA,kBAAA,KAAA,KAAA,UAAA,KAAA,KACA,kBAAA,iBAAA,GAAA,OAAA,SAAA,UAAA,iBAAA,GAAA,OAAA,SAGF,oCACE,KACE,sBAAA,MAAA,GAAA,cAAA,MAAA,IAFJ,4BACE,KACE,sBAAA,MAAA,GAAA,cAAA,MAAA,IH9CF,iBACE,QAAA,MACA,MAAA,KACA,QAAA,GIJF,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,gBACE,MAAA,QAGE,sBAAA,sBAEE,MAAA,QANN,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,WACE,MAAA,QAGE,iBAAA,iBAEE,MAAA,QANN,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,aACE,MAAA,QAGE,mBAAA,mBAEE,MAAA,QANN,YACE,MAAA,QAGE,kBAAA,kBAEE,MAAA,QANN,WACE,MAAA,QAGE,iBAAA,iBAEE,MAAA,QCLR,OACE,SAAA,SACA,MAAA,KAEA,eACE,QAAA,MACA,YAAA,uBACA,QAAA,GAGF,SACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,MAAA,KACA,OAAA,KAKF,WACE,kBAAA,KADF,WACE,kBAAA,IADF,YACE,kBAAA,OADF,YACE,kBAAA,eCrBJ,WACE,SAAA,MACA,IAAA,EACA,MAAA,EACA,KAAA,EACA,QAAA,KAGF,cACE,SAAA,MACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,KAQE,YACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,KjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,0BiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,0BiDxCA,gBACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MCzBN,QACE,QAAA,KACA,eAAA,IACA,YAAA,OACA,WAAA,QAGF,QACE,QAAA,KACA,KAAA,EAAA,EAAA,KACA,eAAA,OACA,WAAA,QCRF,iB5D+6MA,0D6D36ME,SAAA,mBACA,MAAA,cACA,OAAA,cACA,QAAA,YACA,OAAA,eACA,SAAA,iBACA,KAAA,wBACA,YAAA,iBACA,OAAA,YCXA,uBACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,EACA,QAAA,GCRJ,eCAE,SAAA,OACA,cAAA,SACA,YAAA,OCNF,IACE,QAAA,aACA,WAAA,QACA,MAAA,IACA,WAAA,IACA,iBAAA,aACA,QAAA,ICyDM,gBAOI,eAAA,mBAPJ,WAOI,eAAA,cAPJ,cAOI,eAAA,iBAPJ,cAOI,eAAA,iBAPJ,mBAOI,eAAA,sBAPJ,gBAOI,eAAA,mBAPJ,aAOI,MAAA,eAPJ,WAOI,MAAA,gBAPJ,YAOI,MAAA,eAPJ,WAOI,QAAA,YAPJ,YAOI,QAAA,cAPJ,YAOI,QAAA,aAPJ,YAOI,QAAA,cAPJ,aAOI,QAAA,YAPJ,eAOI,SAAA,eAPJ,iBAOI,SAAA,iBAPJ,kBAOI,SAAA,kBAPJ,iBAOI,SAAA,iBAPJ,UAOI,QAAA,iBAPJ,gBAOI,QAAA,uBAPJ,SAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,SAOI,QAAA,gBAPJ,aAOI,QAAA,oBAPJ,cAOI,QAAA,qBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,QAOI,QAAA,eAPJ,QAOI,WAAA,EAAA,MAAA,KAAA,0BAPJ,WAOI,WAAA,EAAA,QAAA,OAAA,2BAPJ,WAOI,WAAA,EAAA,KAAA,KAAA,2BAPJ,aAOI,WAAA,eAPJ,iBAOI,SAAA,iBAPJ,mBAOI,SAAA,mBAPJ,mBAOI,SAAA,mBAPJ,gBAOI,SAAA,gBAPJ,iBAOI,SAAA,yBAAA,SAAA,iBAPJ,OAOI,IAAA,YAPJ,QAOI,IAAA,cAPJ,SAOI,IAAA,eAPJ,UAOI,OAAA,YAPJ,WAOI,OAAA,cAPJ,YAOI,OAAA,eAPJ,SAOI,KAAA,YAPJ,UAOI,KAAA,cAPJ,WAOI,KAAA,eAPJ,OAOI,MAAA,YAPJ,QAOI,MAAA,cAPJ,SAOI,MAAA,eAPJ,kBAOI,UAAA,+BAPJ,oBAOI,UAAA,2BAPJ,oBAOI,UAAA,2BAPJ,QAOI,OAAA,IAAA,MAAA,kBAPJ,UAOI,OAAA,YAPJ,YAOI,WAAA,IAAA,MAAA,kBAPJ,cAOI,WAAA,YAPJ,YAOI,aAAA,IAAA,MAAA,kBAPJ,cAOI,aAAA,YAPJ,eAOI,cAAA,IAAA,MAAA,kBAPJ,iBAOI,cAAA,YAPJ,cAOI,YAAA,IAAA,MAAA,kBAPJ,gBAOI,YAAA,YAPJ,gBAOI,aAAA,kBAPJ,kBAOI,aAAA,kBAPJ,gBAOI,aAAA,kBAPJ,aAOI,aAAA,kBAPJ,gBAOI,aAAA,kBAPJ,eAOI,aAAA,kBAPJ,cAOI,aAAA,kBAPJ,aAOI,aAAA,kBAPJ,cAOI,aAAA,eAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,MAOI,MAAA,cAPJ,MAOI,MAAA,cAPJ,MAOI,MAAA,cAPJ,OAOI,MAAA,eAPJ,QAOI,MAAA,eAPJ,QAOI,UAAA,eAPJ,QAOI,MAAA,gBAPJ,YAOI,UAAA,gBAPJ,MAOI,OAAA,cAPJ,MAOI,OAAA,cAPJ,MAOI,OAAA,cAPJ,OAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,QAOI,WAAA,eAPJ,QAOI,OAAA,gBAPJ,YAOI,WAAA,gBAPJ,WAOI,KAAA,EAAA,EAAA,eAPJ,UAOI,eAAA,cAPJ,aAOI,eAAA,iBAPJ,kBAOI,eAAA,sBAPJ,qBAOI,eAAA,yBAPJ,aAOI,UAAA,YAPJ,aAOI,UAAA,YAPJ,eAOI,YAAA,YAPJ,eAOI,YAAA,YAPJ,WAOI,UAAA,eAPJ,aAOI,UAAA,iBAPJ,mBAOI,UAAA,uBAPJ,OAOI,IAAA,YAPJ,OAOI,IAAA,iBAPJ,OAOI,IAAA,gBAPJ,OAOI,IAAA,eAPJ,OAOI,IAAA,iBAPJ,OAOI,IAAA,eAPJ,uBAOI,gBAAA,qBAPJ,qBAOI,gBAAA,mBAPJ,wBAOI,gBAAA,iBAPJ,yBAOI,gBAAA,wBAPJ,wBAOI,gBAAA,uBAPJ,wBAOI,gBAAA,uBAPJ,mBAOI,YAAA,qBAPJ,iBAOI,YAAA,mBAPJ,oBAOI,YAAA,iBAPJ,sBAOI,YAAA,mBAPJ,qBAOI,YAAA,kBAPJ,qBAOI,cAAA,qBAPJ,mBAOI,cAAA,mBAPJ,sBAOI,cAAA,iBAPJ,uBAOI,cAAA,wBAPJ,sBAOI,cAAA,uBAPJ,uBAOI,cAAA,kBAPJ,iBAOI,WAAA,eAPJ,kBAOI,WAAA,qBAPJ,gBAOI,WAAA,mBAPJ,mBAOI,WAAA,iBAPJ,qBAOI,WAAA,mBAPJ,oBAOI,WAAA,kBAPJ,aAOI,MAAA,aAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,KAOI,OAAA,YAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,gBAPJ,KAOI,OAAA,eAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,MAOI,aAAA,YAAA,YAAA,YAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,gBAAA,YAAA,gBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,WAAA,YAAA,cAAA,YAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,gBAAA,cAAA,gBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,YAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,gBAPJ,MAOI,WAAA,eAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,eAPJ,SAOI,WAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,SAOI,aAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,SAOI,cAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,SAOI,YAAA,eAPJ,KAOI,QAAA,YAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,gBAPJ,KAOI,QAAA,eAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,eAPJ,MAOI,cAAA,YAAA,aAAA,YAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,gBAAA,aAAA,gBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,YAAA,YAAA,eAAA,YAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,gBAAA,eAAA,gBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,MAOI,eAAA,YAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,gBAPJ,MAOI,eAAA,eAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,gBAOI,YAAA,mCAPJ,MAOI,UAAA,iCAPJ,MAOI,UAAA,gCAPJ,MAOI,UAAA,8BAPJ,MAOI,UAAA,gCAPJ,MAOI,UAAA,kBAPJ,MAOI,UAAA,eAPJ,YAOI,WAAA,iBAPJ,YAOI,WAAA,iBAPJ,UAOI,YAAA,cAPJ,YAOI,YAAA,kBAPJ,WAOI,YAAA,cAPJ,SAOI,YAAA,cAPJ,WAOI,YAAA,iBAPJ,MAOI,YAAA,YAPJ,OAOI,YAAA,eAPJ,SAOI,YAAA,cAPJ,OAOI,YAAA,YAPJ,YAOI,WAAA,eAPJ,UAOI,WAAA,gBAPJ,aAOI,WAAA,iBAPJ,sBAOI,gBAAA,eAPJ,2BAOI,gBAAA,oBAPJ,8BAOI,gBAAA,uBAPJ,gBAOI,eAAA,oBAPJ,gBAOI,eAAA,oBAPJ,iBAOI,eAAA,qBAPJ,WAOI,YAAA,iBAPJ,aAOI,YAAA,iBAPJ,YAOI,UAAA,qBAAA,WAAA,qBAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,gBAIQ,kBAAA,EAGJ,MAAA,+DAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,aAIQ,kBAAA,EAGJ,MAAA,4DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,WAIQ,kBAAA,EAGJ,MAAA,gEAPJ,YAIQ,kBAAA,EAGJ,MAAA,kBAPJ,eAIQ,kBAAA,EAGJ,MAAA,yBAPJ,eAIQ,kBAAA,EAGJ,MAAA,+BAPJ,YAIQ,kBAAA,EAGJ,MAAA,kBAjBJ,iBACE,kBAAA,KADF,iBACE,kBAAA,IADF,iBACE,kBAAA,KADF,kBACE,kBAAA,EASF,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,cAIQ,gBAAA,EAGJ,iBAAA,6DAPJ,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,WAIQ,gBAAA,EAGJ,iBAAA,0DAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,SAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,gBAIQ,gBAAA,EAGJ,iBAAA,sBAjBJ,eACE,gBAAA,IADF,eACE,gBAAA,KADF,eACE,gBAAA,IADF,eACE,gBAAA,KADF,gBACE,gBAAA,EASF,aAOI,iBAAA,6BAPJ,iBAOI,oBAAA,cAAA,iBAAA,cAAA,YAAA,cAPJ,kBAOI,oBAAA,eAAA,iBAAA,eAAA,YAAA,eAPJ,kBAOI,oBAAA,eAAA,iBAAA,eAAA,YAAA,eAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,iBAPJ,WAOI,cAAA,YAPJ,WAOI,cAAA,gBAPJ,WAOI,cAAA,iBAPJ,WAOI,cAAA,gBAPJ,gBAOI,cAAA,cAPJ,cAOI,cAAA,gBAPJ,aAOI,uBAAA,iBAAA,wBAAA,iBAPJ,aAOI,wBAAA,iBAAA,2BAAA,iBAPJ,gBAOI,2BAAA,iBAAA,0BAAA,iBAPJ,eAOI,0BAAA,iBAAA,uBAAA,iBAPJ,SAOI,WAAA,kBAPJ,WAOI,WAAA,iBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,0ByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,0ByDAI,iBAOI,MAAA,eAPJ,eAOI,MAAA,gBAPJ,gBAOI,MAAA,eAPJ,cAOI,QAAA,iBAPJ,oBAOI,QAAA,uBAPJ,aAOI,QAAA,gBAPJ,YAOI,QAAA,eAPJ,aAOI,QAAA,gBAPJ,iBAOI,QAAA,oBAPJ,kBAOI,QAAA,qBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,YAOI,QAAA,eAPJ,eAOI,KAAA,EAAA,EAAA,eAPJ,cAOI,eAAA,cAPJ,iBAOI,eAAA,iBAPJ,sBAOI,eAAA,sBAPJ,yBAOI,eAAA,yBAPJ,iBAOI,UAAA,YAPJ,iBAOI,UAAA,YAPJ,mBAOI,YAAA,YAPJ,mBAOI,YAAA,YAPJ,eAOI,UAAA,eAPJ,iBAOI,UAAA,iBAPJ,uBAOI,UAAA,uBAPJ,WAOI,IAAA,YAPJ,WAOI,IAAA,iBAPJ,WAOI,IAAA,gBAPJ,WAOI,IAAA,eAPJ,WAOI,IAAA,iBAPJ,WAOI,IAAA,eAPJ,2BAOI,gBAAA,qBAPJ,yBAOI,gBAAA,mBAPJ,4BAOI,gBAAA,iBAPJ,6BAOI,gBAAA,wBAPJ,4BAOI,gBAAA,uBAPJ,4BAOI,gBAAA,uBAPJ,uBAOI,YAAA,qBAPJ,qBAOI,YAAA,mBAPJ,wBAOI,YAAA,iBAPJ,0BAOI,YAAA,mBAPJ,yBAOI,YAAA,kBAPJ,yBAOI,cAAA,qBAPJ,uBAOI,cAAA,mBAPJ,0BAOI,cAAA,iBAPJ,2BAOI,cAAA,wBAPJ,0BAOI,cAAA,uBAPJ,2BAOI,cAAA,kBAPJ,qBAOI,WAAA,eAPJ,sBAOI,WAAA,qBAPJ,oBAOI,WAAA,mBAPJ,uBAOI,WAAA,iBAPJ,yBAOI,WAAA,mBAPJ,wBAOI,WAAA,kBAPJ,iBAOI,MAAA,aAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,gBAOI,MAAA,YAPJ,SAOI,OAAA,YAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,gBAPJ,SAOI,OAAA,eAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,eAPJ,YAOI,OAAA,eAPJ,UAOI,aAAA,YAAA,YAAA,YAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,gBAAA,YAAA,gBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,aAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,WAAA,YAAA,cAAA,YAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,gBAAA,cAAA,gBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,aAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,YAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,gBAPJ,UAOI,WAAA,eAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,eAPJ,aAOI,WAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,aAOI,aAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,aAOI,cAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,aAOI,YAAA,eAPJ,SAOI,QAAA,YAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,gBAPJ,SAOI,QAAA,eAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,eAPJ,UAOI,cAAA,YAAA,aAAA,YAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,gBAAA,aAAA,gBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,YAAA,YAAA,eAAA,YAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,gBAAA,eAAA,gBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,UAOI,eAAA,YAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,gBAPJ,UAOI,eAAA,eAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,gBAOI,WAAA,eAPJ,cAOI,WAAA,gBAPJ,iBAOI,WAAA,kBCnDZ,0BD4CQ,MAOI,UAAA,iBAPJ,MAOI,UAAA,eAPJ,MAOI,UAAA,kBAPJ,MAOI,UAAA,kBChCZ,aDyBQ,gBAOI,QAAA,iBAPJ,sBAOI,QAAA,uBAPJ,eAOI,QAAA,gBAPJ,cAOI,QAAA,eAPJ,eAOI,QAAA,gBAPJ,mBAOI,QAAA,oBAPJ,oBAOI,QAAA,qBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,cAOI,QAAA","sourcesContent":["/*!\n * Bootstrap v5.1.3 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$variable-prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n font-size: var(--#{$variable-prefix}root-font-size);\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`