mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95e1b7d171 | ||
|
|
f9ac017faf | ||
|
|
4252b7f76f | ||
|
|
58a7459e45 | ||
|
|
5dd601a90b | ||
|
|
50fe867e0d | ||
|
|
98bdaf0c42 | ||
|
|
1f1149e32e | ||
|
|
51b81c82bf | ||
|
|
15201b7a3c | ||
|
|
ad3f0f6c2e | ||
|
|
838d6a7178 | ||
|
|
a5cb484e52 | ||
|
|
3a252fb948 | ||
|
|
69365cbae4 | ||
|
|
2f1b9aad06 | ||
|
|
9e8371bde6 | ||
|
|
704fcecbdf | ||
|
|
785d8b0cc5 | ||
|
|
eaa68c243a | ||
|
|
a24246bfbc | ||
|
|
50679e6a01 | ||
|
|
d8cc884f01 | ||
|
|
6a534b8479 | ||
|
|
4048dfbaf1 | ||
|
|
2de8807942 | ||
|
|
8642a5b58c | ||
|
|
36de2b9aa4 | ||
|
|
c6bacca7f6 | ||
|
|
da0255a805 | ||
|
|
dee193502f | ||
|
|
b9458497fb | ||
|
|
52d2c8574f | ||
|
|
3da16e98e3 | ||
|
|
85336f3c0e | ||
|
|
9720516185 | ||
|
|
2a0efcff87 | ||
|
|
f9143a05c2 | ||
|
|
bd30cda4a9 | ||
|
|
33d5c0737e | ||
|
|
cb27fd1f98 | ||
|
|
d4ef956660 | ||
|
|
789c62ef71 | ||
|
|
b2a70a6a38 | ||
|
|
dd9607b54e | ||
|
|
14cde26291 | ||
|
|
388577a799 | ||
|
|
d0310ac211 | ||
|
|
99babf3879 | ||
|
|
ffae2a1d0a | ||
|
|
ce69bc14a8 | ||
|
|
4e94cb07be | ||
|
|
4395c8bea5 | ||
|
|
5b35a5db69 | ||
|
|
c1ff8a3a9a | ||
|
|
c352dfe284 | ||
|
|
9f2ec29586 | ||
|
|
63be2308d3 | ||
|
|
e509f8ab46 | ||
|
|
b481753526 | ||
|
|
1796fdb37b | ||
|
|
25f02378cb | ||
|
|
e95e2f5187 | ||
|
|
3f8365ad7e | ||
|
|
8bd0d7c8c9 | ||
|
|
2c261fc6c0 | ||
|
|
0ffb7d3d1e | ||
|
|
1305bf16e8 | ||
|
|
d9cbdf3394 | ||
|
|
a3b11de1a2 | ||
|
|
cf3f943177 | ||
|
|
267f464232 | ||
|
|
b70aabdeae | ||
|
|
8936f79716 | ||
|
|
968452a45e | ||
|
|
05c4b40497 | ||
|
|
60ea48dbaa | ||
|
|
284ca86b7c | ||
|
|
7cd762afa2 | ||
|
|
1b39bd2a4a | ||
|
|
e8fbb88102 | ||
|
|
f6e1f90225 | ||
|
|
0e53edafa3 | ||
|
|
4a10124574 | ||
|
|
d2555835c7 | ||
|
|
73d26e02b3 | ||
|
|
48a85998ad | ||
|
|
1a6b02999b | ||
|
|
976f1de806 | ||
|
|
1546e0e02f | ||
|
|
ce18b914b4 | ||
|
|
a54d6ceee5 | ||
|
|
a32ac4e672 | ||
|
|
93ed2068de | ||
|
|
6b8152d3bc | ||
|
|
eaa3d8c83c | ||
|
|
d772b557a0 | ||
|
|
867d967b37 | ||
|
|
56eef339a8 | ||
|
|
15b93abe04 | ||
|
|
208abda841 | ||
|
|
d4d78ac7db |
No files matched your search
@@ -1,4 +1,40 @@
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
# Dev / build artifacts (recreated inside the build stage)
|
||||
node_modules
|
||||
dist
|
||||
src/gui/dist
|
||||
src/puter-js/dist
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local runtime data
|
||||
volatile
|
||||
config.json
|
||||
config.dev.json
|
||||
/puter
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Git / CI
|
||||
.git
|
||||
.github
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.npm
|
||||
|
||||
# Tests / coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
creds*
|
||||
*.pem
|
||||
+18
-1
@@ -1 +1,18 @@
|
||||
PORT=4000
|
||||
# Copy this file to `.env`, fill in the secrets, and `docker compose -f
|
||||
# docker-compose.full.yml up -d`. None of the defaults below are safe for
|
||||
# anything beyond a local laptop test.
|
||||
|
||||
# ── Public-facing ports (nginx) ---------------------------------------
|
||||
HTTP_PORT=80
|
||||
# HTTPS_PORT=443 # uncomment after you enable TLS in nginx/nginx.conf
|
||||
|
||||
# ── MariaDB ------------------------------------------------------------
|
||||
MARIADB_ROOT_PASSWORD=replace-with-strong-password
|
||||
MARIADB_DATABASE=puter
|
||||
MARIADB_USER=puter
|
||||
MARIADB_PASSWORD=replace-with-strong-password
|
||||
|
||||
# ── S3 (RustFS) --------------------------------------------------------
|
||||
S3_ACCESS_KEY=puter
|
||||
S3_SECRET_KEY=replace-with-strong-secret
|
||||
S3_BUCKET=puter-local
|
||||
@@ -1,41 +1,52 @@
|
||||
#
|
||||
name: Docker Image CI
|
||||
|
||||
# Configures this workflow to run every time a change is pushed to the
|
||||
# branch called `main`.
|
||||
# Two ways in:
|
||||
# - push of a calver tag (YY.MM or YY.MM.p) → publishes <version>, latest, main
|
||||
# - workflow_dispatch → publishes latest + main from whichever ref the user
|
||||
# picks in the Actions UI. Used to fast-forward :latest/:main when the
|
||||
# branch has moved ahead of the most recent release tag.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
branches:
|
||||
- 'main'
|
||||
- '[0-9][0-9].[0-9][0-9]'
|
||||
- '[0-9][0-9].[0-9][0-9].[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_latest:
|
||||
description: 'Push :latest tag'
|
||||
type: boolean
|
||||
default: true
|
||||
push_main:
|
||||
description: 'Push :main tag'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# 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: Validate calver tag
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if ! [[ "$REF_NAME" =~ ^[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then
|
||||
echo "Tag '$REF_NAME' does not match YY.MM or YY.MM.p calver format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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:
|
||||
@@ -49,35 +60,25 @@ jobs:
|
||||
- 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 }}"
|
||||
# type=ref,event=tag only emits on tag pushes, so manual runs skip
|
||||
# the calver tag and just publish whichever of latest / main the
|
||||
# dispatch inputs asked for. On a tag push, both default to true.
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'push' || inputs.push_latest }}
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
|
||||
|
||||
# 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' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Tag Alias
|
||||
|
||||
# Retags an existing image in GHCR under a custom name, without rebuilding.
|
||||
# Uses `docker buildx imagetools create` so the multi-arch manifest is preserved.
|
||||
# Re-running with the same name updates the alias to point at a different source tag.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: "Alias name (e.g., potato)"
|
||||
required: true
|
||||
source_tag:
|
||||
description: "Source tag to alias from"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
alias:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Validate alias name
|
||||
env:
|
||||
NAME: ${{ inputs.name }}
|
||||
run: |
|
||||
if ! [[ "$NAME" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
|
||||
echo "Invalid alias name '$NAME' (allowed: [A-Za-z0-9_.-], must start with [A-Za-z0-9_], max 128 chars)."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NAME" == "latest" || "$NAME" == "main" ]]; then
|
||||
echo "Alias name '$NAME' is reserved by the build workflow."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NAME" =~ ^[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then
|
||||
echo "Alias name '$NAME' looks like a calver tag — pick a non-version name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create alias tag
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
NAME: ${{ inputs.name }}
|
||||
SOURCE_TAG: ${{ inputs.source_tag }}
|
||||
run: |
|
||||
SRC="${IMAGE}:${SOURCE_TAG}"
|
||||
DST="${IMAGE}:${NAME}"
|
||||
echo "Aliasing $DST -> $SRC"
|
||||
docker buildx imagetools create -t "$DST" "$SRC"
|
||||
{
|
||||
echo "### 🏷️ Tag Alias"
|
||||
echo ""
|
||||
echo "- Source: \`$SRC\`"
|
||||
echo "- Alias: \`$DST\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,94 +0,0 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- "src/docs/**"
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- "src/docs/**"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24.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)
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run test:backend -- --coverage --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov
|
||||
|
||||
- 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
|
||||
|
||||
api-test:
|
||||
name: API tests (node env, api-test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24.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: [24.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
|
||||
@@ -22,6 +22,11 @@ dist/
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# Per-developer / deployment config override. `config.default.json` ships in
|
||||
# the repo and is the authoritative source; `config.json` (if present) wins
|
||||
# at runtime for the local machine only.
|
||||
config.json
|
||||
|
||||
# this is for jetbrain IDEs
|
||||
.idea/
|
||||
/puter
|
||||
@@ -63,3 +68,5 @@ AGENTS.md
|
||||
coverage/
|
||||
*.log
|
||||
undefined
|
||||
servers.json
|
||||
config.*.json
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
[submodule "submodules/v86"]
|
||||
path = submodules/v86
|
||||
url = [email protected]:HeyPuter/v86.git
|
||||
[submodule "submodules/twisp"]
|
||||
path = submodules/twisp
|
||||
url = [email protected]:MercuryWorkshop/twisp.git
|
||||
[submodule "submodules/epoxy-tls"]
|
||||
path = submodules/epoxy-tls
|
||||
url = [email protected]:MercuryWorkshop/epoxy-tls.git
|
||||
[submodule "submodules/wiki"]
|
||||
path = submodules/wiki
|
||||
url = https://github.com/HeyPuter/puter.wiki.git
|
||||
Regular → Executable
+10
-8
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
git diff --cached --name-only -z --diff-filter=ACMR -- \
|
||||
'*.js' '*.mjs' '*.cjs' '*.jsx' '*.ts' '*.tsx' '*.vue' \
|
||||
> "$tmpfile"
|
||||
files=$(git diff --cached --name-only --diff-filter=ACMR | \
|
||||
grep -E '^(src/backend|extensions)/.*\.(js|mjs|cjs|ts)$' || true)
|
||||
|
||||
if [ -s "$tmpfile" ]; then
|
||||
xargs -0 eslint --fix < "$tmpfile" || true
|
||||
xargs -0 git add < "$tmpfile"
|
||||
if [ -z "$files" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -f "$tmpfile"
|
||||
echo "$files" | xargs npx eslint --fix --no-warn-ignored
|
||||
status=$?
|
||||
|
||||
echo "$files" | xargs git add
|
||||
|
||||
exit $status
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
Whitespace-only changes.
+2
-1
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
build
|
||||
**/*.dbmig.js
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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.
|
||||
|
||||
<br>
|
||||
|
||||
# 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.
|
||||
|
||||
<br>
|
||||
|
||||
# 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
|
||||
|
||||
<br>
|
||||
|
||||
# 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.
|
||||
|
||||
<br>
|
||||
|
||||
## 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)
|
||||
|
||||
<br>
|
||||
|
||||
## 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!
|
||||
|
||||
<br>
|
||||
|
||||
## 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.
|
||||
|
||||
<br>
|
||||
|
||||
# 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:[email protected])
|
||||
+67
-69
@@ -1,90 +1,88 @@
|
||||
# /!\ NOTICE /!\
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# OSS Puter image — multi-arch (linux/amd64, linux/arm64).
|
||||
#
|
||||
# Build & push:
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
# -t ghcr.io/heyputer/puter:latest --push .
|
||||
#
|
||||
# Local single-arch build:
|
||||
# docker build -t puter .
|
||||
#
|
||||
# Self-hosters inject configuration by mounting a config.json at
|
||||
# /etc/puter/config.json. It is deep-merged over the bundled
|
||||
# config.default.json, so partial overrides work. Absent file = defaults.
|
||||
|
||||
# Many of the developers DO NOT USE the Dockerfile or image.
|
||||
# While we do test new changes to Docker configuration, it's
|
||||
# possible that future changes to the repo might break it.
|
||||
# When changing this file, please try to make it as resiliant
|
||||
# to such changes as possible; developers shouldn't need to
|
||||
# worry about Docker unless the build/run process changes.
|
||||
# ---- Build stage ----
|
||||
FROM node:24-slim AS build
|
||||
|
||||
# Build stage
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /opt/puter
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git python3 make g++ \
|
||||
&& ln -sf /usr/bin/python3 /usr/bin/python
|
||||
# Build toolchain needed for native deps (bcrypt, sharp, better-sqlite3, …).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 make g++ git && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up working directory
|
||||
WORKDIR /app
|
||||
ENV HUSKY=0
|
||||
ENV npm_config_fund=false
|
||||
ENV npm_config_audit=false
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
# ---- Dependency layer ---------------------------------------------------
|
||||
# Copy ONLY package manifests + lockfile first so the npm-install layer
|
||||
# stays cached when only source files change.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY src/backend/package.json src/backend/
|
||||
COPY src/gui/package.json src/gui/
|
||||
COPY src/puter-js/package.json src/puter-js/package-lock.json src/puter-js/
|
||||
COPY src/worker/package.json src/worker/
|
||||
COPY src/docs/package.json src/docs/
|
||||
|
||||
# Fail early if lockfile or manifest is missing
|
||||
RUN test -f package.json && test -f package-lock.json
|
||||
# extensionSetup.mjs runs as the postinstall hook during npm ci. (No-ops
|
||||
# unless any packages/puter/extensions/* gain a package.json.)
|
||||
COPY tools/extensionSetup.mjs tools/extensionSetup.mjs
|
||||
|
||||
# Copy the source files
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
|
||||
# ---- Source layer -------------------------------------------------------
|
||||
COPY . .
|
||||
|
||||
# Install mocha
|
||||
RUN npm i -g npm@latest
|
||||
RUN npm install -g mocha
|
||||
# Compile backend TS, then build GUI + puter-js webpack bundles in
|
||||
# parallel. The GUI/puter-js bundles are how /dist/bundle.min.{js,css}
|
||||
# and /sdk/puter.js fall back to local assets when the kernel-config
|
||||
# CDN keys are unset.
|
||||
RUN npm run build:ts
|
||||
RUN set -e; \
|
||||
(cd src/gui && node ./build.js) & gui_pid=$!; \
|
||||
(cd src/puter-js && npm run build) & pjs_pid=$!; \
|
||||
wait $gui_pid; \
|
||||
wait $pjs_pid
|
||||
|
||||
# Install node modules
|
||||
RUN npm cache clean --force && \
|
||||
for i in 1 2 3; do \
|
||||
npm ci && break || \
|
||||
if [ $i -lt 3 ]; then \
|
||||
sleep 15; \
|
||||
else \
|
||||
LOG_DIR="$(npm config get cache | tr -d '\"')/_logs"; \
|
||||
echo "npm install failed; dumping logs from $LOG_DIR"; \
|
||||
if [ -d "$LOG_DIR" ]; then \
|
||||
ls -al "$LOG_DIR" || true; \
|
||||
cat "$LOG_DIR"/* || true; \
|
||||
else \
|
||||
echo "Log directory not found (npm cache: $(npm config get cache))"; \
|
||||
fi; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
done
|
||||
# ---- Runtime stage (slim — no build tools) ----
|
||||
FROM node:24-slim
|
||||
|
||||
# Run the build command if necessary
|
||||
RUN cd src/gui && npm run build && cd -
|
||||
WORKDIR /opt/puter
|
||||
|
||||
# Production stage
|
||||
FROM node:24-alpine
|
||||
# git: runtime version probe. wget: HEALTHCHECK.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set labels
|
||||
LABEL repo="https://github.com/HeyPuter/puter"
|
||||
LABEL license="AGPL-3.0,https://github.com/HeyPuter/puter/blob/master/LICENSE.txt"
|
||||
LABEL version="1.2.46-beta-1"
|
||||
COPY --from=build --chown=node:node /opt/puter .
|
||||
|
||||
# Install git (required by Puter to check version)
|
||||
RUN apk add --no-cache git
|
||||
RUN mkdir -p /etc/puter /var/puter && \
|
||||
chown -R node:node /etc/puter /var/puter
|
||||
|
||||
# Set up working directory
|
||||
RUN mkdir -p /opt/puter/app
|
||||
WORKDIR /opt/puter/app
|
||||
|
||||
# Copy built artifacts and necessary files from the build stage
|
||||
COPY --from=build /app/src/gui/dist ./dist
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R node:node /opt/puter/app
|
||||
USER node
|
||||
# Self-hosters mount their override at this exact path. The v2 loader
|
||||
# deep-merges it over config.default.json (see backend/index.ts).
|
||||
ENV PUTER_CONFIG_PATH=/etc/puter/config.json
|
||||
ENV NODE_OPTIONS=--enable-source-maps
|
||||
|
||||
EXPOSE 4100
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
|
||||
ENV NO_VAR_RUNTUME=1
|
||||
|
||||
# Attempt to fix `[email protected]` missing after build stage
|
||||
# by doing a redundant `npm install` at this stage
|
||||
RUN npm install
|
||||
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
|
||||
@@ -1,6 +1,6 @@
|
||||
<h3 align="center"><img width="80" alt="Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time." src="https://assets.puter.site/puter-logo.png"></h3>
|
||||
|
||||
<h3 align="center">The Internet OS! Free, Open-Source, and Self-Hostable.</h3>
|
||||
<h3 align="center">The Open-Source Internet Computer!</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://puter.com/?ref=github.com"><strong>« LIVE DEMO »</strong></a>
|
||||
@@ -12,8 +12,6 @@
|
||||
·
|
||||
<a href="https://developer.puter.com" target="_blank">Developers</a>
|
||||
·
|
||||
<a href="https://github.com/heyputer/puter-cli" target="_blank">CLI</a>
|
||||
·
|
||||
<a href="https://discord.com/invite/PQcx7Teh8u">Discord</a>
|
||||
·
|
||||
<a href="https://reddit.com/r/puter">Reddit</a>
|
||||
@@ -21,19 +19,13 @@
|
||||
<a href="https://twitter.com/HeyPuter">X</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center"><img width="800" style="border-radius:5px;" alt="screenshot" src="https://assets.puter.site/puter.com-screenshot-3.webp"></h3>
|
||||
<h3 align="center"><img width="800" style="border-radius:5px;" alt="screenshot" src="https://assets.puter.site/puter.com-screenshot.webp"></h3>
|
||||
|
||||
<br/>
|
||||
|
||||
## 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!
|
||||
Puter is an advanced, open-source, self-hostable internet computer designed to be feature-rich, fast, and highly extensible. Puter's goal is to provide you with every app and feature you need to work, create, and play under one roof.
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -48,59 +40,28 @@ cd puter
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
**→** This should launch Puter at
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
**→** 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.
|
||||
|
||||
<br/>
|
||||
|
||||
### 🐳 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
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
|
||||
<br/>
|
||||
|
||||
### 🐙 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
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
|
||||
<br/>
|
||||
|
||||
#### 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
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
|
||||
<br/>
|
||||
|
||||
### 🚀 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).
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://puter.com/selfhost | sh
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
irm https://puter.com/selfhost?os=windows | iex
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
**→** For more details, see [Self-Hosting Puter](./doc/self-hosting.md).
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -110,16 +71,6 @@ Puter is available as a hosted service at [**puter.com**](https://puter.com).
|
||||
|
||||
<br/>
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Operating Systems:** Linux, macOS, Windows
|
||||
- **RAM:** 2GB minimum (4GB recommended)
|
||||
- **Disk Space:** 1GB free space
|
||||
- **Node.js:** Version 24+
|
||||
- **npm:** Latest stable version
|
||||
|
||||
<br/>
|
||||
|
||||
## Support
|
||||
|
||||
Connect with the maintainers and community through these channels:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"config_name": "oss-default",
|
||||
"env": "dev",
|
||||
"port": 4100,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"jwt_secret": "dev-jwt-secret-change-me",
|
||||
"url_signature_secret": "dev-url-signature-secret-change-me",
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
"no_devwatch": false,
|
||||
"enable_public_folders": true,
|
||||
"is_storage_limited": false,
|
||||
"min_pass_length": 6,
|
||||
"static_hosting_domain": "site.puter.localhost",
|
||||
"static_hosting_domain_alt": "host.puter.localhost",
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
"captcha": { "enabled": false },
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
"builtin_apps": {
|
||||
"dev-center": "./src/dev-center"
|
||||
},
|
||||
"extensions": [
|
||||
"./extensions"
|
||||
],
|
||||
"database": {
|
||||
"engine": "sqlite",
|
||||
"path": "volatile/runtime/puter-database.sqlite"
|
||||
},
|
||||
"s3": {
|
||||
"localConfig": {
|
||||
"dataDir": "volatile/runtime/fauxqs-data",
|
||||
"s3StorageDir": "volatile/runtime/fauxqs-s3-data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
{
|
||||
// Comprehensive template — every key the OSS backend or shipped (non-prod)
|
||||
// extensions read. Copy to `config.json` and trim what you don't need;
|
||||
// unset keys fall back to documented defaults. See
|
||||
// `src/backend/types.ts` for the per-field source of truth.
|
||||
//
|
||||
// Each setting lives at exactly one canonical key — there are no fallback
|
||||
// aliases. Values shown are illustrative, not production secrets.
|
||||
//
|
||||
// Keys consumed only by closed-source / hosted-prod extensions (clickhouse,
|
||||
// cacheUpdateHandler, pages, prodMeteringAndBilling, …) are intentionally
|
||||
// omitted.
|
||||
|
||||
// ── Environment / identity ──────────────────────────────────────────
|
||||
"config_name": "template",
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
"serverId": "node-1",
|
||||
|
||||
// ── Networking / URLs ───────────────────────────────────────────────
|
||||
// Port Puter listens on internally.
|
||||
"port": 4100,
|
||||
// Externally-visible port (set this when behind a reverse proxy on 80/443).
|
||||
"pub_port": 4100,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
// pub_port if unset.
|
||||
"origin": "http://puter.localhost:4100",
|
||||
// Public base URL for the API subdomain (used to build signed URLs and
|
||||
// surfaced to the client by the `installedApps` and `whoami` extensions).
|
||||
"api_base_url": "http://api.puter.localhost:4100",
|
||||
// Subdomains Puter routes on. Wildcard DNS (`*.<domain>`) must point at
|
||||
// this server for site/app hosting to work.
|
||||
"static_hosting_domain": "site.puter.localhost",
|
||||
"static_hosting_domain_alt": "host.puter.localhost",
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
// Host-header / domain handling. Defaults below are the dev-friendly
|
||||
// settings; tighten for any public install.
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
"allow_nipio_domains": false,
|
||||
"custom_domains_enabled": false,
|
||||
"enable_ip_validation": false,
|
||||
// Express `trust proxy` setting — set to the number of reverse-proxy
|
||||
// hops in front of the server (1 = nginx/Cloudflare, 2 = CF→ALB→app),
|
||||
// or to a CIDR / IP / list. `false` (safe default) makes `req.ip` the
|
||||
// direct socket peer. NEVER set to `true` in prod — it trusts every hop
|
||||
// and makes X-Forwarded-For forgeable.
|
||||
"trust_proxy": false,
|
||||
"no_browser_launch": false,
|
||||
|
||||
// ── Dev watcher (devWatcher extension) ──────────────────────────────
|
||||
// Rebuilds GUI + puter.js on file changes when running from source.
|
||||
// Ignored when `env: "prod"` unless `devwatch.enabled: true`.
|
||||
"no_devwatch": false,
|
||||
"devwatch": {
|
||||
// Delay after watcher startup before boot continues. Lets webpack
|
||||
// emit its first build so the homepage doesn't 404 on bundle.min.js.
|
||||
"ready_delay_ms": 5000
|
||||
},
|
||||
|
||||
// ── Auth / session ──────────────────────────────────────────────────
|
||||
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
|
||||
"jwt_secret": "change-me",
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
"enabled": false,
|
||||
"difficulty": "medium"
|
||||
},
|
||||
"oidc": {
|
||||
"providers": {
|
||||
// Google uses OIDC discovery — only ids are required.
|
||||
"google": {
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"scopes": "openid email profile"
|
||||
},
|
||||
// Custom OIDC providers must also supply the three endpoints.
|
||||
"custom-oidc": {
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"authorization_endpoint": "",
|
||||
"token_endpoint": "",
|
||||
"userinfo_endpoint": "",
|
||||
"scopes": "openid email profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Groups / provisioning ───────────────────────────────────────────
|
||||
// UIDs of the persistent groups new users are auto-enrolled in.
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
// When true, ACL grants read/list on `/<user>/Public` to any actor.
|
||||
"enable_public_folders": true,
|
||||
|
||||
// ── Storage / S3 ────────────────────────────────────────────────────
|
||||
"s3": {
|
||||
// Local fauxqs (in-process S3-compatible) — used in dev and the
|
||||
// bundled-defaults Docker mode. Files land under `dataDir`.
|
||||
"localConfig": {
|
||||
"inMemory": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 4566,
|
||||
"dataDir": "volatile/runtime/fauxqs-data",
|
||||
"s3StorageDir": "volatile/runtime/fauxqs-s3-data"
|
||||
},
|
||||
// For real / external S3, replace `localConfig` above with `s3Config`.
|
||||
"_remote_example": {
|
||||
"s3Config": {
|
||||
"useCredentialChain": false,
|
||||
"endpoint": "https://s3.example.com",
|
||||
// Endpoint used in presigned URLs handed to the browser. Set
|
||||
// this when the server-side endpoint isn't reachable from the
|
||||
// browser (e.g. docker-internal `http://s3:9000`).
|
||||
"publicEndpoint": "",
|
||||
"accessKeyId": "",
|
||||
"secretAccessKey": "",
|
||||
"region": "us-west-2",
|
||||
// Set true for RustFS / MinIO / fauxqs (path-style URLs).
|
||||
// Real AWS S3 wants virtual-hosted — leave unset / false.
|
||||
"forcePathStyle": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"s3_bucket": "puter-local",
|
||||
"s3_region": "us-west-2",
|
||||
"region": "us-west-2",
|
||||
// Default per-user storage cap (bytes). 100 MB.
|
||||
"storage_capacity": 104857600,
|
||||
"is_storage_limited": false,
|
||||
"available_device_storage": 0,
|
||||
// ── Thumbnails (thumbnails extension) ───────────────────────────────
|
||||
// Optional dedicated S3-compatible bucket for generated thumbnails.
|
||||
// When unset (or `endpoint` empty), the extension falls back to the
|
||||
// main S3 client / bucket above.
|
||||
"thumbnailStore": {
|
||||
"name": "puter-local",
|
||||
"endpoint": "",
|
||||
"credentials": {
|
||||
"accessKeyId": "",
|
||||
"secretAccessKey": ""
|
||||
}
|
||||
},
|
||||
|
||||
// ── Database ────────────────────────────────────────────────────────
|
||||
"database": {
|
||||
// `sqlite` for single-node / dev; `mysql` for self-host with MariaDB.
|
||||
"engine": "sqlite",
|
||||
// sqlite — file path on disk
|
||||
"path": "volatile/runtime/puter-database.sqlite",
|
||||
"targetVersion": 0,
|
||||
// mysql — connection details
|
||||
"host": "",
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"database": "",
|
||||
// Optional read-replica pool. Reads route here when populated.
|
||||
"replica": {
|
||||
"host": "",
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"database": ""
|
||||
}
|
||||
// mysql self-host bootstrap: set `migrationPaths` to apply the bundled
|
||||
// schema on first boot. Idempotent — safe to leave on.
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/mysql"]
|
||||
},
|
||||
|
||||
// ── DynamoDB (KV store) ─────────────────────────────────────────────
|
||||
"dynamo": {
|
||||
// Local emulator (dynamodb-local) endpoint. Drop this field for real
|
||||
// AWS DynamoDB.
|
||||
"endpoint": "http://localhost:8000",
|
||||
// Set true when pointing at a local emulator so Puter creates the KV
|
||||
// table on boot. NEVER set against real AWS — provision via IaC.
|
||||
// "bootstrapTables": true,
|
||||
"path": "",
|
||||
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
|
||||
// For dynamodb-local, any non-empty values work.
|
||||
"aws": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
}
|
||||
},
|
||||
|
||||
// ── Redis / Valkey (cache + cross-node rate limit) ──────────────────
|
||||
"redis": {
|
||||
// True → in-process redis-mock (dev / single-node).
|
||||
"useMock": true,
|
||||
// Cluster nodes for ioredis. For a single Valkey/Redis container,
|
||||
// run it in cluster mode (one node, all slots).
|
||||
"startupNodes": [
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 7000
|
||||
}
|
||||
]
|
||||
// Defaults to true (matches prod ElastiCache). Set false for plain-TCP
|
||||
// self-host Valkey/Redis.
|
||||
// "tls": false
|
||||
},
|
||||
|
||||
// ── Email (transactional) ───────────────────────────────────────────
|
||||
// Nodemailer transport — used for password resets, email confirmation, etc.
|
||||
"email": {
|
||||
"from": "\"Puter\" <[email protected]>",
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"service": "",
|
||||
"auth": {
|
||||
"user": "",
|
||||
"pass": ""
|
||||
}
|
||||
},
|
||||
|
||||
// ── Rate limiting ───────────────────────────────────────────────────
|
||||
// `memory` for single-node, `redis` for multi-node (default), `kv` for
|
||||
// dynamo-backed counters.
|
||||
"rate_limit": {
|
||||
"backend": "redis"
|
||||
},
|
||||
|
||||
// ── AI / integration providers ──────────────────────────────────────
|
||||
// All AI drivers (chat, image, video, TTS, OCR, STT) read from here.
|
||||
// Provider id == driver-side identifier. Leave empty / omit to disable.
|
||||
"providers": {
|
||||
// ─ Chat / completion ─
|
||||
"claude": { "apiKey": "" },
|
||||
"openai-completion": { "apiKey": "" },
|
||||
"gemini": { "apiKey": "" },
|
||||
"groq": { "apiKey": "" },
|
||||
"deepseek": { "apiKey": "" },
|
||||
"mistral": { "apiKey": "" },
|
||||
"xai": { "apiKey": "" },
|
||||
"moonshot": { "apiKey": "" },
|
||||
"openrouter": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://openrouter.ai/api/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
// is running).
|
||||
"ollama": {
|
||||
"enabled": false,
|
||||
"apiBaseUrl": "http://localhost:11434"
|
||||
},
|
||||
|
||||
// ─ Image generation ─
|
||||
"openai-image-generation": { "apiKey": "" },
|
||||
"gemini-image-generation": { "apiKey": "" },
|
||||
"together-image-generation": { "apiKey": "" },
|
||||
"cloudflare-image-generation": {
|
||||
"apiToken": "",
|
||||
"accountId": "",
|
||||
"apiBaseUrl": "https://api.cloudflare.com/client/v4"
|
||||
},
|
||||
"xai-image-generation": { "apiKey": "" },
|
||||
|
||||
// ─ Video generation ─
|
||||
"openai-video-generation": { "apiKey": "" },
|
||||
"together-video-generation": { "apiKey": "" },
|
||||
"gemini-video-generation": { "apiKey": "" },
|
||||
|
||||
// ─ Speech / OCR ─
|
||||
"openai": { "apiKey": "" },
|
||||
"elevenlabs": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.elevenlabs.io",
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
},
|
||||
"aws-textract": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
},
|
||||
"mistral-ocr": { "apiKey": "" }
|
||||
},
|
||||
|
||||
// ── GUI / static mounts ─────────────────────────────────────────────
|
||||
"gui_assets_root": "./src/gui",
|
||||
"gui_profile": "development",
|
||||
"builtin_apps": {
|
||||
"dev-center": "./src/dev-center"
|
||||
},
|
||||
// Force the bundled GUI even in dev — set true when running from a
|
||||
// pre-built tree without webpack-dev-server.
|
||||
"use_bundled_gui": false,
|
||||
"gui_bundle": "/dist/bundle.min.js",
|
||||
"gui_css": "/dist/bundle.min.css",
|
||||
"gui_puterjs_bundle": "https://js.puter.com/v2/",
|
||||
"gui_params": {
|
||||
"title": "Puter",
|
||||
"short_description": "Your personal cloud computer",
|
||||
"social_media_image": ""
|
||||
},
|
||||
// Optional roots for native app bundles and custom puter.js builds.
|
||||
"native_apps_root": "",
|
||||
"client_libs_root": "",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
// ── Feature flags (whoami extension) ────────────────────────────────
|
||||
// Flat `{ flag_name: boolean }` bag. Server-only by default — flags are
|
||||
// only surfaced to the client if their key is on the allowlist in
|
||||
// `extensions/whoami.ts` (CLIENT_VISIBLE_FEATURE_FLAGS).
|
||||
"feature_flags": {
|
||||
"example_flag": false
|
||||
},
|
||||
|
||||
// ── Misc / safety ───────────────────────────────────────────────────
|
||||
// TLDs / domains rejected at signup (prod only).
|
||||
"blockedEmailDomains": [],
|
||||
"support_email": "[email protected]",
|
||||
// Worker / subdomain names users can't claim.
|
||||
"reserved_words": [],
|
||||
"max_subdomains_per_user": 10,
|
||||
"server_health": {
|
||||
"db_liveness_latency_fail_ms": 1500,
|
||||
"stale_health_loop_fail_ms": 0
|
||||
},
|
||||
|
||||
// ── Extensions ──────────────────────────────────────────────────────
|
||||
// Directories scanned for extension entrypoints (`*.ts` / subdirs).
|
||||
"extensions": [
|
||||
"./extensions"
|
||||
],
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
// When true, all metering checks pass — no per-actor limits enforced.
|
||||
"unlimitedMetering": false
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,214 +0,0 @@
|
||||
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" version="25.0.1">
|
||||
<diagram name="Page-1" id="5LlmtnmR4draSSsTtYyD">
|
||||
<mxGraphModel grid="1" page="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" pageScale="1" pageWidth="1100" pageHeight="850" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-64" value="Legacy Notice" style="rounded=1;whiteSpace=wrap;html=1;align=left;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="555" width="130" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-8" value="only most important directories shown" style="rounded=1;whiteSpace=wrap;html=1;align=right;" vertex="1" parent="1">
|
||||
<mxGeometry x="380" y="50" width="230" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-1" value="src/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="360" y="110" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-2" value="Puter Repository" style="rounded=0;whiteSpace=wrap;html=1;fontSize=18;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="40" width="160" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-4" value="Every directory under src/ is a node module." style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=14;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="470" y="110" width="260" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-5" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-2" target="skmebJAFKBwesmhEX21R-1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="490" y="260" as="sourcePoint" />
|
||||
<mxPoint x="540" y="210" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="320" y="125" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-6" value="backend/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="440" y="190" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-7" value="gui/src/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="440" y="1000" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-10" value="" style="endArrow=none;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-1" target="skmebJAFKBwesmhEX21R-4">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="490" y="260" as="sourcePoint" />
|
||||
<mxPoint x="540" y="210" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-12" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-1" target="skmebJAFKBwesmhEX21R-6">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="490" y="260" as="sourcePoint" />
|
||||
<mxPoint x="540" y="210" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="400" y="205" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-14" value="extensions/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1080" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-15" value="Puter GUI Extensions" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1080" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-16" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-14" target="skmebJAFKBwesmhEX21R-15">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="530" y="980" as="sourcePoint" />
|
||||
<mxPoint x="580" y="930" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-15" target="skmebJAFKBwesmhEX21R-15">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-18" value="UI/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1120" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-19" value="Puter Desktop" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1120" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-20" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-18" target="skmebJAFKBwesmhEX21R-19">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1098" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1098" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-21" value="services/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1160" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-22" value="Frontend Services" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1160" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-23" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-21" target="skmebJAFKBwesmhEX21R-22">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1138" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1138" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-24" value="initgui.js" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1200" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-25" value="Launches services and initializes the GUI" style="rounded=1;whiteSpace=wrap;html=1;fontSize=13;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1200" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-26" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-24" target="skmebJAFKBwesmhEX21R-25">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1185" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1185" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-27" value="IPC.js" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1240" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-28" value="Manages communication between Desktop and apps" style="rounded=1;whiteSpace=wrap;html=1;fontSize=13;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1240" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-29" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-27" target="skmebJAFKBwesmhEX21R-28">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1225" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1225" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-46" value="modules/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="280" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-47" value="New Code goes Here" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="280" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-48" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-46" target="skmebJAFKBwesmhEX21R-47">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="530" y="180" as="sourcePoint" />
|
||||
<mxPoint x="580" y="130" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-47" target="skmebJAFKBwesmhEX21R-47">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-50" value="services/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="320" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-51" value="Core Module services" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="320" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-52" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-50" target="skmebJAFKBwesmhEX21R-51">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="298" as="sourcePoint" />
|
||||
<mxPoint x="740" y="298" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-53" value="routers/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="480" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-54" value="HTTP endpoints" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="480" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-55" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-53" target="skmebJAFKBwesmhEX21R-54">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="458" as="sourcePoint" />
|
||||
<mxPoint x="740" y="458" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-62" value="<div><span style="background-color: initial;">This is the original directory where routes</span></div><div><span style="background-color: initial;">were placed. New services should use the</span></div><div><span style="background-color: initial;">"Endpoint" class to define routes.</span></div><div><span style="background-color: initial;"><br></span></div><div><span style="background-color: initial;">Examples:</span></div><div><span style="background-color: initial;">- PermissionAPIService.js</span></div><div><span style="background-color: initial;">- NotificationService.js</span><br></div><div>- KernelInfoService.js</div><div><span style="color: rgba(0, 0, 0, 0); font-family: monospace; font-size: 0px; text-wrap: nowrap;">%3CmxGraphModel%3E%3Croot%3E%3CmxCell%20id%3D%220%22%2F%3E%3CmxCell%20id%3D%221%22%20parent%3D%220%22%2F%3E%3CmxCell%20id%3D%222%22%20value%3D%22extensions%2F%22%20style%3D%22rounded%3D0%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23e1d5e7%3BstrokeColor%3D%239673a6%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22520%22%20y%3D%22400%22%20width%3D%22140%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%223%22%20value%3D%22Puter%20GUI%20Extensions%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23d5e8d4%3BstrokeColor%3D%2382b366%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22730%22%20y%3D%22400%22%20width%3D%22180%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%224%22%20value%3D%22%22%20style%3D%22endArrow%3Dclassic%3Bhtml%3D1%3Brounded%3D0%3B%22%20edge%3D%221%22%20source%3D%222%22%20target%3D%223%22%20parent%3D%221%22%3E%3CmxGeometry%20width%3D%2250%22%20height%3D%2250%22%20relative%3D%221%22%20as%3D%22geometry%22%3E%3CmxPoint%20x%3D%22530%22%20y%3D%22300%22%20as%3D%22sourcePoint%22%2F%3E%3CmxPoint%20x%3D%22580%22%20y%3D%22250%22%20as%3D%22targetPoint%22%2F%3E%3C%2FmxGeometry%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%225%22%20style%3D%22edgeStyle%3DorthogonalEdgeStyle%3Brounded%3D0%3BorthogonalLoop%3D1%3BjettySize%3Dauto%3Bhtml%3D1%3BexitX%3D0.5%3BexitY%3D1%3BexitDx%3D0%3BexitDy%3D0%3B%22%20edge%3D%221%22%20source%3D%223%22%20target%3D%223%22%20parent%3D%221%22%3E%3CmxGeometry%20relative%3D%221%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%226%22%20value%3D%22UI%2F%22%20style%3D%22rounded%3D0%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23e1d5e7%3BstrokeColor%3D%239673a6%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22520%22%20y%3D%22440%22%20width%3D%22140%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%227%22%20value%3D%22Puter%20Desktop%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23d5e8d4%3BstrokeColor%3D%2382b366%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22730%22%20y%3D%22440%22%20width%3D%22180%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%228%22%20value%3D%22%22%20style%3D%22endArrow%3Dclassic%3Bhtml%3D1%3Brounded%3D0%3B%22%20edge%3D%221%22%20source%3D%226%22%20target%3D%227%22%20parent%3D%221%22%3E%3CmxGeometry%20width%3D%2250%22%20height%3D%2250%22%20relative%3D%221%22%20as%3D%22geometry%22%3E%3CmxPoint%20x%3D%22670%22%20y%3D%22418%22%20as%3D%22sourcePoint%22%2F%3E%3CmxPoint%20x%3D%22740%22%20y%3D%22418%22%20as%3D%22targetPoint%22%2F%3E%3C%2FmxGeometry%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%229%22%20value%3D%22services%2F%22%20style%3D%22rounded%3D0%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23e1d5e7%3BstrokeColor%3D%239673a6%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22520%22%20y%3D%22480%22%20width%3D%22140%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%2210%22%20value%3D%22Frontend%20Services%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23d5e8d4%3BstrokeColor%3D%2382b366%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22730%22%20y%3D%22480%22%20width%3D%22180%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%2211%22%20value%3D%22%22%20style%3D%22endArrow%3Dclassic%3Bhtml%3D1%3Brounded%3D0%3B%22%20edge%3D%221%22%20source%3D%229%22%20target%3D%2210%22%20parent%3D%221%22%3E%3CmxGeometry%20width%3D%2250%22%20height%3D%2250%22%20relative%3D%221%22%20as%3D%22geometry%22%3E%3CmxPoint%20x%3D%22670%22%20y%3D%22458%22%20as%3D%22sourcePoint%22%2F%3E%3CmxPoint%20x%3D%22740%22%20y%3D%22458%22%20as%3D%22targetPoint%22%2F%3E%3C%2FmLine truncated
|
||||
<mxGeometry x="645" y="545" width="270" height="135" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-63" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-62" target="skmebJAFKBwesmhEX21R-54">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="730" y="560" as="sourcePoint" />
|
||||
<mxPoint x="780" y="510" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-65" value="structured/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="700" width="140" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-66" value="unstructured/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="720" width="140" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-68" value="<div>These directories mostly contain code for</div><div>the permission system to make it easier to maintain.</div><div><br></div><div>structured/&lt;pattern&gt;/ contains code that is formatted according to "&lt;pattern&gt;"</div><div><br></div><div>unstructured/ contains miscellaneous code used by the permission system.</div>" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=18;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=13;fontColor=#333333;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="700" width="270" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-69" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;elbow=vertical;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-68" target="skmebJAFKBwesmhEX21R-65">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="730" y="680" as="sourcePoint" />
|
||||
<mxPoint x="780" y="630" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-70" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;elbow=vertical;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-68" target="skmebJAFKBwesmhEX21R-66">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="740" y="720" as="sourcePoint" />
|
||||
<mxPoint x="670" y="720" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-71" value="these should go to:<div>modules/auth/</div><div>in the future</div>" style="rounded=1;whiteSpace=wrap;html=1;glass=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="760" width="140" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-72" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-71" target="skmebJAFKBwesmhEX21R-66">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="730" y="680" as="sourcePoint" />
|
||||
<mxPoint x="780" y="630" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-73" value="util/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="880" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-74" value="<div>Utilities used by various services in Puter's backend.</div><div><br></div><div>These should be incrementally moved to individual `lib` directories in modules/</div>" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=18;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=13;fontColor=#333333;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="880" width="270" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-75" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;elbow=vertical;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-74" target="skmebJAFKBwesmhEX21R-73">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="740" y="740" as="sourcePoint" />
|
||||
<mxPoint x="670" y="740" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-76" value="Migration Notice" style="rounded=1;whiteSpace=wrap;html=1;align=left;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="400" width="130" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-77" value="<div>Services here should be incrementally</div><div>migrated into the modules/ directory.</div>" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=18;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="390" width="270" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
@@ -1,45 +0,0 @@
|
||||
# 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/)
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,60 +0,0 @@
|
||||
## 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": "<name of interface>",
|
||||
"service": "<name of service>",
|
||||
"method": "<name of 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": {}
|
||||
}
|
||||
```
|
||||
@@ -1,219 +0,0 @@
|
||||
# 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<string>`
|
||||
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<string>`
|
||||
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)
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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",
|
||||
});
|
||||
```
|
||||
@@ -1,367 +0,0 @@
|
||||
# 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",
|
||||
"[email protected]"
|
||||
],
|
||||
"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<string>`
|
||||
- **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>`
|
||||
- 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",
|
||||
"[email protected]"
|
||||
],
|
||||
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": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
### 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"
|
||||
}
|
||||
```
|
||||
@@ -1,79 +0,0 @@
|
||||
# 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" }]
|
||||
```
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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(<identifier>):` is used to note differences between other
|
||||
implementations of a similar idea
|
||||
- `name:` pedantic commentary on the name of something
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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: "[email protected]",
|
||||
to: "[email protected]",
|
||||
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.
|
||||
|
||||
<img src="image.png" alt="Email in MailHog interface" width="300" height="200">
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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:
|
||||
// "<username>", or:
|
||||
// "<app> acting on behalf of <username>"
|
||||
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)
|
||||
@@ -1,14 +0,0 @@
|
||||
## 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);
|
||||
})
|
||||
```
|
||||
@@ -1,816 +0,0 @@
|
||||
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: '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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,761 +0,0 @@
|
||||
#### 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('[36;1m === MEASUREMENT ===[0m
|
||||
', {
|
||||
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**:
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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');
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
@@ -1,64 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,2 +0,0 @@
|
||||
### `vscode`
|
||||
- `es6-string-html`
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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`)
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (C) 2024 Puter Technologies Inc.
|
||||
Copyright (C) 2024-present Puter Technologies Inc.
|
||||
|
||||
This file is part of Puter.
|
||||
|
||||
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
# 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
|
||||
<script type="text/javascript" src="./dist/gui.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
// Initialize the GUI. All options are optional!
|
||||
gui({
|
||||
// The origin of the app. This is the base URL of the GUI.
|
||||
gui_origin: "https://puter.com",
|
||||
|
||||
// The origin of the API. This is the base URL of the API endpoints that the GUI will call for all its operations.
|
||||
api_origin: "https://api.puter.com",
|
||||
|
||||
// The domain under which user websites are hosted.
|
||||
hosting_domain: "puter.site",
|
||||
|
||||
// The maximum length of file/directory names.
|
||||
max_item_name_length: 500,
|
||||
|
||||
// If GUI has to enforce email verification before allowing user to publish a website.
|
||||
require_email_verification_to_publish_website: true,
|
||||
})
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## 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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Puter</title>
|
||||
<meta name="author" content="Puter Technologies Inc.">
|
||||
<meta name="description" content="Puter is a privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.">
|
||||
<meta name="facebook-domain-verification" content="e29w3hjbnnnypf4kzk2cewcdaxym1y" />
|
||||
<link rel="canonical" href="https://puter.com">
|
||||
|
||||
<!-- Meta meta tags -->
|
||||
<meta property="og:url" content="https://puter.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Puter">
|
||||
<meta property="og:description" content="Puter is a privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.">
|
||||
<meta property="og:image" content="./dist/images/screenshot.png">
|
||||
|
||||
<!-- Twitter meta tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="puter.com">
|
||||
<meta property="twitter:url" content="https://puter.com">
|
||||
<meta name="twitter:title" content="Puter">
|
||||
<meta name="twitter:description" content="Puter is a privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.">
|
||||
<meta name="twitter:image" content="./dist/images/screenshot.png">
|
||||
|
||||
<!-- favicons -->
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="./dist/favicons/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="./dist/favicons/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="./dist/favicons/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="./dist/favicons/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="./dist/favicons/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="./dist/favicons/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="./dist/favicons/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="./dist/favicons/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./dist/favicons/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="./dist/favicons/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="./dist/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="./dist/favicons/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="./dist/favicons/favicon-16x16.png">
|
||||
<link rel="manifest" href="./dist/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="./dist/favicons/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Preload images when applicable -->
|
||||
<link rel="preload" as="image" href="./dist/images/wallpaper.webp">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Load the GUI script -->
|
||||
<script type="text/javascript" src="./dist/gui.js"></script>
|
||||
<!-- Initialize GUI when document is loaded -->
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
gui()
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
```
|
||||
|
||||
### 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 (`<title></title>`, `<meta property="og:title"`, `<meta name="twitter:title"`, ...) should be dynamically set by the server. For example, if the URL is of an app (e.g. `https://puter.com/app/editor`) the `title` tags and meta tags should contain the app's title rather than the generic Puter title.
|
||||
|
||||
- The `description` meta tags (`<meta name="description"`, `<meta property="og:description"`, `<meta name="twitter:description"`, ...) should be dynamically set by the server. For example, if the URL is of an app (e.g. `https://puter.com/app/editor`) the `description` meta tags should contain the app's description rather than the generic Puter description.
|
||||
|
||||
- Make sure to escape any HTML code that is dynamically added to the HTML page. For example, if the app's description is `Puter is a <b>privacy-first</b> personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.` the `<b>` 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?
|
||||
@@ -1,84 +0,0 @@
|
||||
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.
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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": "[email protected]",
|
||||
"services": {
|
||||
"database": {
|
||||
"engine": "sqlite",
|
||||
"path": "puter-database.sqlite"
|
||||
},
|
||||
"dynamo" :{"path":"./puter-ddb"}
|
||||
},
|
||||
"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)
|
||||
-
|
||||
@@ -1,72 +0,0 @@
|
||||
### `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.
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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)
|
||||
|
||||
For distros using dnf5 (Fedora 41+):
|
||||
```
|
||||
sudo dnf install @development-tools
|
||||
```
|
||||
|
||||
Otherwise:
|
||||
```
|
||||
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
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
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');
|
||||
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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.
|
||||
|
||||
<br/>
|
||||
|
||||
## 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.
|
||||
|
||||
<br/>
|
||||
@@ -1,39 +0,0 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,396 @@
|
||||
# Self-Hosting Puter
|
||||
|
||||
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
|
||||
|
||||
## One-line installer (recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
|
||||
```
|
||||
|
||||
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** with the `compose` plugin.
|
||||
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
|
||||
- Optional: **TLS certs** (or `certbot` to grab them — see Step 4).
|
||||
|
||||
## What's running
|
||||
|
||||
| Container | Image | Role |
|
||||
| --------------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `puter-nginx` | `nginx:1.27-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter |
|
||||
| `puter` | `ghcr.io/heyputer/puter` | The app |
|
||||
| `puter-mariadb` | `mariadb:11` | SQL database — schema applied automatically on first boot |
|
||||
| `puter-valkey` | `valkey/valkey:8-alpine` | Redis-compatible cache + rate-limiter |
|
||||
| `puter-dynamo` | `amazon/dynamodb-local` | KV store — table auto-created on first boot |
|
||||
| `puter-s3` | `rustfs/rustfs` | S3-compatible object storage (MinIO drop-in noted in file) |
|
||||
| `puter-s3-init` | `amazon/aws-cli` | One-shot — creates the bucket on first boot, then exits |
|
||||
|
||||
Optional services (compose profile `ai`, opt-in):
|
||||
|
||||
| Container | Image | Role |
|
||||
| ------------------- | --------------- | -------------------------------------------------------------- |
|
||||
| `puter-ollama` | `ollama/ollama` | Local LLM provider (CPU; GPU passthrough opt-in) |
|
||||
| `puter-ollama-init` | `ollama/ollama` | One-shot — pulls the default model (`tinyllama`) on first boot |
|
||||
|
||||
State lives under `./puter/data/<service>/`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create `.env` and `puter/config/config.json`
|
||||
|
||||
> ⚠️ **Run this whole block in one shell session.** It generates secrets once and writes them into both `.env` (read by docker compose) and `config.json` (read by Puter). The two files **must** agree on the MariaDB password and the S3 secret — if they drift, MariaDB initialises with one password and Puter tries to log in with another, and you get `ER_ACCESS_DENIED_ERROR`.
|
||||
|
||||
```bash
|
||||
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
cat > .env <<EOF
|
||||
HTTP_PORT=80
|
||||
# HTTPS_PORT=443 # uncomment after enabling TLS in Step 3
|
||||
|
||||
MARIADB_ROOT_PASSWORD=$MARIADB_ROOT_PASSWORD
|
||||
MARIADB_DATABASE=puter
|
||||
MARIADB_USER=puter
|
||||
MARIADB_PASSWORD=$MARIADB_PASSWORD
|
||||
|
||||
S3_ACCESS_KEY=puter
|
||||
S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
S3_BUCKET=puter-local
|
||||
EOF
|
||||
|
||||
mkdir -p puter/config puter/data puter/tls
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "puter.localhost",
|
||||
"protocol": "http",
|
||||
"pub_port": 80,
|
||||
"env": "prod",
|
||||
|
||||
"static_hosting_domain": "site.puter.localhost",
|
||||
"static_hosting_domain_alt": "host.puter.localhost",
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
"engine": "mysql",
|
||||
"host": "mariadb",
|
||||
"port": 3306,
|
||||
"user": "puter",
|
||||
"password": "$MARIADB_PASSWORD",
|
||||
"database": "puter",
|
||||
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/mysql"]
|
||||
},
|
||||
|
||||
"redis": {
|
||||
"startupNodes": [{ "host": "valkey", "port": 6379 }],
|
||||
"tls": false
|
||||
},
|
||||
|
||||
"dynamo": {
|
||||
"endpoint": "http://dynamo:8000",
|
||||
"bootstrapTables": true,
|
||||
"aws": {
|
||||
"access_key": "fake",
|
||||
"secret_key": "fake",
|
||||
"region": "us-east-1"
|
||||
}
|
||||
},
|
||||
|
||||
"s3": {
|
||||
"s3Config": {
|
||||
"endpoint": "http://s3:9000",
|
||||
"publicEndpoint": "http://s3.puter.localhost",
|
||||
"accessKeyId": "puter",
|
||||
"secretAccessKey": "$S3_SECRET_KEY",
|
||||
"region": "us-east-1",
|
||||
"forcePathStyle": true
|
||||
}
|
||||
},
|
||||
"s3_bucket": "puter-local",
|
||||
"s3_region": "us-east-1",
|
||||
|
||||
"providers": {
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.puter.localhost` and `app.puter.localhost` with your actual domain (or leave it for a localhost-only trial).
|
||||
|
||||
Why these knobs:
|
||||
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
|
||||
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
|
||||
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. nginx routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
|
||||
- `trust_proxy: 1` — nginx terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the nginx container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (nginx). Bump to `2` if you put Cloudflare in front of nginx; never set `true` (it trusts every hop and makes XFF forgeable).
|
||||
|
||||
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
|
||||
|
||||
## Step 2 — Point DNS at the server \[Optional\]
|
||||
|
||||
In your DNS provider, add records for the main domain plus the subdomains Puter and nginx route on (`api.*`, `site.*`, `app.*`, `s3.*`):
|
||||
|
||||
```
|
||||
A puter.localhost → <your server's public IP>
|
||||
A *.puter.localhost → <your server's public IP>
|
||||
A site.puter.localhost → <your server's public IP>
|
||||
A *.site.puter.localhost → <your server's public IP>
|
||||
A host.puter.localhost → <your server's public IP>
|
||||
A *.host.puter.localhost → <your server's public IP>
|
||||
A app.puter.localhost → <your server's public IP>
|
||||
A *.app.puter.localhost → <your server's public IP>
|
||||
A dev.puter.localhost → <your server's public IP>
|
||||
A *.dev.puter.localhost → <your server's public IP>
|
||||
```
|
||||
|
||||
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and nginx routes browser S3 traffic via `s3.*` to RustFS.
|
||||
|
||||
## Step 3 — TLS (recommended for public installs) \[Optional\]
|
||||
|
||||
Skip this for a quick local demo. Don't skip it for users typing passwords.
|
||||
|
||||
**Get a wildcard cert.** Easiest path with Let's Encrypt + DNS-01 (works for wildcards):
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --manual --preferred-challenges dns \
|
||||
-d puter.localhost -d "*.puter.localhost" \
|
||||
-d site.puter.localhost -d "*.site.puter.localhost" \
|
||||
-d host.puter.localhost -d "*.host.puter.localhost" \
|
||||
-d app.puter.localhost -d "*.app.puter.localhost" \
|
||||
-d dev.puter.localhost -d "*.dev.puter.localhost"
|
||||
```
|
||||
|
||||
The cert needs to cover `*.puter.localhost` so that `s3.puter.localhost` (browser S3 endpoint), plus Puter's own `api.*` / `app.*` subdomains, all validate.
|
||||
|
||||
Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
|
||||
**Wire nginx to use them:**
|
||||
|
||||
1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment **both** `# server { listen 443 ssl … }` blocks (one for `s3.*`, one for the catch-all).
|
||||
2. (Optional) Replace the body of the port-80 blocks with `return 301 https://$host$request_uri;` to force HTTPS everywhere.
|
||||
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `nginx` service.
|
||||
4. In `.env`, uncomment `HTTPS_PORT=443`.
|
||||
5. In `config.json`, switch:
|
||||
```json
|
||||
{ "protocol": "https", "pub_port": 443 }
|
||||
```
|
||||
…and update the S3 public endpoint:
|
||||
```json
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
```
|
||||
|
||||
## Step 4 — Bring it up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
First boot takes ~30s while MariaDB initialises and Puter applies the schema + default apps. Watch:
|
||||
|
||||
```bash
|
||||
docker compose logs -f puter
|
||||
```
|
||||
|
||||
Healthy startup:
|
||||
|
||||
```
|
||||
[config] override from /etc/puter/config.json
|
||||
[mysql] running migrations from /opt/puter/dist/src/backend/clients/database/migrations/mysql: 2 file(s)
|
||||
[mysql] applied mysql_mig_1.sql (...)
|
||||
[mysql] applied mysql_mig_2.sql (9 statements)
|
||||
```
|
||||
|
||||
Then open **<https://puter.local>** (or `http://` if you skipped TLS). Login is `admin` — the temp password is printed once in the puter container logs on first boot:
|
||||
|
||||
```bash
|
||||
docker compose logs puter | grep tmp_password
|
||||
```
|
||||
|
||||
Change it in Settings after first login.
|
||||
|
||||
## Additional configuration
|
||||
|
||||
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
|
||||
|
||||
```json
|
||||
"email": {
|
||||
"from": "\"Puter\" <[email protected]>",
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"auth": { "user": "...", "pass": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
To require email confirmation before login, also set `"strict_email_verification_required": true`.
|
||||
|
||||
### Sign in with Google (or another OIDC provider)
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"google": {
|
||||
"client_id": "...apps.googleusercontent.com",
|
||||
"client_secret": "...",
|
||||
"scopes": "openid email profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `https://puter.<your-domain>/auth/oidc/callback/login` to the OAuth client's authorized redirect URIs in the Google Cloud Console. For non-Google providers, replace `google` with a custom id and supply `authorization_endpoint` / `token_endpoint` / `userinfo_endpoint` explicitly.
|
||||
|
||||
### AI providers
|
||||
|
||||
Any provider with a key set is auto-enabled. Same shape as `ollama` above:
|
||||
|
||||
```json
|
||||
"providers": {
|
||||
"claude": { "apiKey": "sk-ant-..." },
|
||||
"openai-completion": { "apiKey": "sk-..." },
|
||||
"gemini": { "apiKey": "..." },
|
||||
"openai-image-generation": { "apiKey": "sk-..." }
|
||||
}
|
||||
```
|
||||
|
||||
Full provider list (chat, image, video, TTS, OCR) is in the template.
|
||||
|
||||
### Per-user storage quota
|
||||
|
||||
Default is 100 MB per user.
|
||||
|
||||
```json
|
||||
"storage_capacity": 5368709120, // 5 GB
|
||||
"is_storage_limited": true
|
||||
```
|
||||
|
||||
Set `is_storage_limited: false` for unlimited (bounded by host disk).
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
```json
|
||||
"captcha": { "enabled": true, "difficulty": "medium" }
|
||||
```
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
|
||||
```json
|
||||
"blockedEmailDomains": ["mailinator.com", "tempmail.com", "guerrillamail.com"]
|
||||
```
|
||||
|
||||
### Password policy
|
||||
|
||||
```json
|
||||
"min_pass_length": 12
|
||||
```
|
||||
|
||||
### Contact-form recipient
|
||||
|
||||
Where the in-app contact form posts. Defaults to `support@puter.com`.
|
||||
|
||||
```json
|
||||
"support_email": "[email protected]"
|
||||
```
|
||||
|
||||
## Optional: local LLM (Ollama)
|
||||
|
||||
The `ollama` and `ollama-init` services live behind a compose profile so they don't run unless you ask for them. By default, `puter/config/config.json` has `"ollama": { "enabled": false }` — Puter skips the auto-probe entirely. To run a local model:
|
||||
|
||||
1. Flip the config:
|
||||
```json
|
||||
"providers": {
|
||||
"ollama": { "apiBaseUrl": "http://ollama:11434" }
|
||||
}
|
||||
```
|
||||
2. (Optional) Pick a model in `.env`:
|
||||
```bash
|
||||
OLLAMA_DEFAULT_MODEL=tinyllama # default — 1.1B, ~640 MB on disk, ~700 MB RAM
|
||||
# Other tiny picks: qwen2.5:0.5b, llama3.2:1b
|
||||
# Larger / better: phi3.5, llama3.2, mistral
|
||||
```
|
||||
3. Bring up with the `ai` profile:
|
||||
```bash
|
||||
docker compose --profile ai up -d
|
||||
docker compose logs -f ollama-init
|
||||
```
|
||||
`ollama-init` exits 0 once the model is pulled. Subsequent boots find the model already on disk and the pull is a fast no-op.
|
||||
|
||||
Without `--profile ai`, the `ollama` containers stay down and Puter (with `enabled: false`) doesn't try to reach them — the rest of the stack runs identically.
|
||||
|
||||
For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama` service in [docker-compose.yml](../docker-compose.yml). Requires `nvidia-container-toolkit` on the host.
|
||||
|
||||
## Building from source instead of pulling
|
||||
|
||||
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managing running backend
|
||||
|
||||
```bash
|
||||
# update
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# logs
|
||||
docker compose logs -f puter
|
||||
|
||||
# stop, keep data
|
||||
docker compose down
|
||||
|
||||
# stop, NUKE all state (irreversible)
|
||||
docker compose down
|
||||
rm -rf puter/data
|
||||
```
|
||||
|
||||
Migrations re-apply idempotently across pulls. Volumes are preserved.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site loads but I get "Bad Gateway" / nginx errors.**
|
||||
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
|
||||
|
||||
**Login screen says "admin password not set".**
|
||||
First-boot temp password is logged once. Find it: `docker compose logs puter | grep "tmp_password"`. After login, change it in Settings.
|
||||
|
||||
**Healthcheck reports unhealthy but the site works.**
|
||||
The healthcheck hits `puter.localhost:4100/test` from inside the container. If you changed `domain` or `port`, the check still uses defaults. The site itself is fine.
|
||||
|
||||
**Nothing resolves at `puter.example.com` after DNS changes.**
|
||||
DNS propagates slowly. `dig puter.example.com` and `dig api.puter.example.com` should both return your server IP. If not, give it 5–60 minutes.
|
||||
|
||||
**`docker compose up` hangs at "waiting for service to be healthy".**
|
||||
`docker compose ps` shows which container is unhealthy. MariaDB takes ~20–30s on a cold boot; everything else under 5s. If something stays unhealthy, `logs <service>` will tell you why.
|
||||
|
||||
**`Error: DynamoDB aws config requires both access_key and secret_key`.**
|
||||
You wrote `accessKeyId` / `secretAccessKey` under `dynamo.aws`. That config block uses snake_case (`access_key` / `secret_key`). Only the `s3.s3Config` block uses camelCase.
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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.
|
||||
+276
-11
@@ -1,25 +1,290 @@
|
||||
---
|
||||
version: "3.8"
|
||||
# Self-hosted Puter — full stack.
|
||||
#
|
||||
# Brings up Puter + every external service it needs:
|
||||
# - nginx : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
|
||||
# - valkey : redis-compatible cache / rate-limiter backend
|
||||
# - mariadb : SQL database (Puter applies its schema on first boot)
|
||||
# - dynamo : DynamoDB-local (KV store; Puter creates the table itself)
|
||||
# - s3 : RustFS — S3-compatible object storage
|
||||
# - s3-init : one-shot init container that creates the bucket
|
||||
# - puter : the application
|
||||
#
|
||||
# Quick start:
|
||||
# 1. Copy .env.example to .env (or set the variables in your shell).
|
||||
# 2. Drop a config.json into ./puter/config/ — see selfhosted/full-stack.md
|
||||
# for the example that pairs with this compose.
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# Easiest path:
|
||||
# curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
|
||||
# grabs this file, generates secrets, writes .env + config.json, and runs
|
||||
# the compose up for you.
|
||||
#
|
||||
# Production:
|
||||
# - Always replace the default passwords / S3 keys / Puter secrets.
|
||||
# - Front Puter with TLS-terminating reverse proxy (Caddy / nginx).
|
||||
# - Move state-bearing volumes to a backed-up location.
|
||||
|
||||
services:
|
||||
puter:
|
||||
container_name: puter
|
||||
image: ghcr.io/heyputer/puter:latest
|
||||
pull_policy: always
|
||||
# build: ./
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: puter-valkey
|
||||
restart: unless-stopped
|
||||
# Run as a single-node cluster so Puter's ioredis Cluster client
|
||||
# (the only mode it speaks) can connect. On first boot we assign all
|
||||
# 16384 slots to ourselves; subsequent boots find them already in
|
||||
# nodes.conf and skip. `cluster-require-full-coverage no` keeps reads
|
||||
# working if we ever land partial slots.
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
valkey-server \
|
||||
--port 6379 \
|
||||
--cluster-enabled yes \
|
||||
--cluster-config-file /data/nodes.conf \
|
||||
--cluster-node-timeout 5000 \
|
||||
--cluster-require-full-coverage no \
|
||||
--cluster-announce-ip valkey \
|
||||
--cluster-announce-port 6379 \
|
||||
--cluster-announce-bus-port 16379 \
|
||||
--appendonly yes \
|
||||
--save "60 1" &
|
||||
SERVER_PID=$$!
|
||||
until valkey-cli -p 6379 PING > /dev/null 2>&1; do sleep 0.5; done
|
||||
if ! valkey-cli -p 6379 CLUSTER NODES | grep -q '0-16383'; then
|
||||
valkey-cli -p 6379 CLUSTER ADDSLOTSRANGE 0 16383
|
||||
fi
|
||||
wait $$SERVER_PID
|
||||
volumes:
|
||||
- ./puter/data/valkey:/data
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
container_name: puter-mariadb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '4100:4100'
|
||||
environment:
|
||||
# TZ: Europe/Paris
|
||||
# CONFIG_PATH: /etc/puter
|
||||
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root-change-me}
|
||||
MARIADB_DATABASE: ${MARIADB_DATABASE:-puter}
|
||||
MARIADB_USER: ${MARIADB_USER:-puter}
|
||||
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
|
||||
volumes:
|
||||
- ./puter/data/mariadb:/var/lib/mysql
|
||||
healthcheck:
|
||||
# `healthcheck.sh` ships with the mariadb image; --connect verifies
|
||||
# the server is accepting auth, not just listening on the socket.
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
|
||||
dynamo:
|
||||
# Puter creates the `store-kv-v1` table itself on startup
|
||||
# (config.dynamo.bootstrapTables = true does the work).
|
||||
image: amazon/dynamodb-local:latest
|
||||
container_name: puter-dynamo
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
working_dir: /home/dynamodblocal
|
||||
command:
|
||||
- "-jar"
|
||||
- "DynamoDBLocal.jar"
|
||||
- "-sharedDb"
|
||||
- "-dbPath"
|
||||
- "/home/dynamodblocal/data"
|
||||
volumes:
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data
|
||||
|
||||
s3:
|
||||
# RustFS — S3-compatible object storage. Drop-in alternative:
|
||||
# MinIO (image: minio/minio, command: ["server", "/data", "--console-address", ":9001"]).
|
||||
image: rustfs/rustfs:latest
|
||||
container_name: puter-s3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
volumes:
|
||||
- ./puter/data/s3:/data
|
||||
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
|
||||
# which preserves the Host header for S3 signature validation and
|
||||
# rides the same TLS termination as Puter. Uncomment to also expose
|
||||
# 9000 directly on the host for `aws-cli` / debugging.
|
||||
# ports:
|
||||
# - "9000:9000"
|
||||
healthcheck:
|
||||
# RustFS exposes /health on the S3 port. Use wget (curl is not in
|
||||
# the slim image).
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -qO- --tries=1 --timeout=2 http://localhost:9000/health || exit 1",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
s3-init:
|
||||
# One-shot container that creates the `puter-local` bucket on first
|
||||
# boot. Exits 0 once the bucket exists; stays exited 0 thereafter.
|
||||
image: amazon/aws-cli:latest
|
||||
container_name: puter-s3-init
|
||||
depends_on:
|
||||
s3:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-puter}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
endpoint=http://s3:9000
|
||||
bucket=${S3_BUCKET:-puter-local}
|
||||
if aws --endpoint-url "$$endpoint" s3api head-bucket --bucket "$$bucket" 2>/dev/null; then
|
||||
echo "bucket $$bucket already exists"
|
||||
else
|
||||
echo "creating bucket $$bucket"
|
||||
aws --endpoint-url "$$endpoint" s3 mb "s3://$$bucket"
|
||||
fi
|
||||
restart: "no"
|
||||
|
||||
# ── Optional: local LLM ───────────────────────────────────────────
|
||||
# Behind the `ai` compose profile — only starts when explicitly opted
|
||||
# into. Bring up with:
|
||||
# docker compose --profile ai up -d
|
||||
# When enabled, also set in your `puter/config/config.json`:
|
||||
# "providers": { "ollama": { "apiBaseUrl": "http://ollama:11434" } }
|
||||
# When NOT enabled, set:
|
||||
# "providers": { "ollama": { "enabled": false } }
|
||||
# otherwise Puter spams `ECONNREFUSED 127.0.0.1:11434` on startup.
|
||||
ollama:
|
||||
profiles: ["ai"]
|
||||
# CPU-only out of the box; uncomment the GPU `deploy:` block below
|
||||
# if you've got nvidia-docker for much faster inference. Disk + RAM
|
||||
# scale with the model — `tinyllama` (1.1B, ~640 MB on disk, ~700
|
||||
# MB RAM) is the cheapest sane default. Swap via OLLAMA_DEFAULT_MODEL.
|
||||
image: ollama/ollama:latest
|
||||
container_name: puter-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./puter/data/ollama:/root/.ollama
|
||||
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
|
||||
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
|
||||
# ports:
|
||||
# - "11434:11434"
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
# GPU passthrough (NVIDIA). Requires nvidia-container-toolkit on host.
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
ollama-init:
|
||||
profiles: ["ai"]
|
||||
# One-shot — ensures the default model is present. `ollama pull` is
|
||||
# idempotent: present-and-up-to-date → fast no-op; missing → downloads.
|
||||
image: ollama/ollama:latest
|
||||
container_name: puter-ollama-init
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
OLLAMA_HOST: http://ollama:11434
|
||||
OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-tinyllama}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "[ollama-init] ensuring $${OLLAMA_DEFAULT_MODEL}"
|
||||
ollama pull "$${OLLAMA_DEFAULT_MODEL}"
|
||||
echo "[ollama-init] done"
|
||||
restart: "no"
|
||||
|
||||
puter:
|
||||
image: ghcr.io/heyputer/puter:main
|
||||
pull_policy: always
|
||||
# Uncomment to build from this directory instead of pulling the published
|
||||
# image. Also flip pull_policy to `never` so compose doesn't overwrite
|
||||
# your local build by re-pulling :latest.
|
||||
# build:
|
||||
# context: .
|
||||
# # buildx-only: cross-compile to both archs in a single push
|
||||
# # platforms:
|
||||
# # - linux/amd64
|
||||
# # - linux/arm64
|
||||
container_name: puter
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
dynamo:
|
||||
condition: service_started
|
||||
s3-init:
|
||||
condition: service_completed_successfully
|
||||
# Internal-only: nginx reaches it on the compose network. Uncomment
|
||||
# to also expose port 4100 directly on the host (useful for debugging).
|
||||
# ports:
|
||||
# - "4100:4100"
|
||||
expose:
|
||||
- "4100"
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
volumes:
|
||||
# Drop your config.json here — see selfhosted/full-stack.md.
|
||||
- ./puter/config:/etc/puter
|
||||
- ./puter/data:/var/puter
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/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
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: puter-nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
puter:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
# Uncomment when you enable TLS in nginx/nginx.conf:
|
||||
# - "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/nginx/tls:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
+96
-170
@@ -1,204 +1,130 @@
|
||||
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 prettierPlugin from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
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: '^_',
|
||||
// typescript-eslint's flat/recommended preset is an array of configs (base +
|
||||
// eslint-recommended overrides + recommended rules). Flatten its rules so we
|
||||
// can apply them via our own `files`-scoped blocks.
|
||||
const tsRecommendedRules = tseslintPlugin.configs['flat/recommended'].reduce(
|
||||
(acc, cfg) => ({ ...acc, ...(cfg.rules ?? {}) }),
|
||||
{},
|
||||
);
|
||||
|
||||
}],
|
||||
curly: ['error', 'multi-line'],
|
||||
'@stylistic/curly-newline': ['error', 'always'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/indent': ['error', 4, {
|
||||
SwitchCase: 1,
|
||||
CallExpression: {
|
||||
arguments: 1,
|
||||
},
|
||||
}],
|
||||
'@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/function-paren-newline': ['error', 'multiline-arguments'],
|
||||
'@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,
|
||||
}],
|
||||
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||
'@stylistic/linebreak-style': ['error', 'unix'],
|
||||
'no-useless-computed-key': 'error',
|
||||
'no-sequences': [
|
||||
'error', {
|
||||
allowInParentheses: false,
|
||||
},
|
||||
],
|
||||
const prettierRules = {
|
||||
...prettierConfig.rules,
|
||||
'prettier/prettier': 'error',
|
||||
};
|
||||
|
||||
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 unusedVarsOptions = {
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
};
|
||||
|
||||
const sharedPlugins = {
|
||||
js,
|
||||
'@stylistic': stylistic,
|
||||
custom: {
|
||||
rules: {
|
||||
'control-structure-spacing': controlStructureSpacing,
|
||||
'bang-space-if': bangSpaceIf,
|
||||
'space-unary-ops-with-exception': spaceUnaryOpsWithException,
|
||||
},
|
||||
},
|
||||
const preferConstOptions = {
|
||||
destructuring: 'all',
|
||||
ignoreReadBeforeAssign: false,
|
||||
};
|
||||
|
||||
const sharedJsConfig = {
|
||||
rules,
|
||||
plugins: sharedPlugins,
|
||||
const lintedGlobals = {
|
||||
...globals.node,
|
||||
extension: 'readonly',
|
||||
config: 'readonly',
|
||||
global_config: 'readonly',
|
||||
};
|
||||
|
||||
const recommendedJsConfig = {
|
||||
...sharedJsConfig,
|
||||
extends: ['js/recommended'],
|
||||
};
|
||||
const jsFiles = [
|
||||
'src/backend/**/*.{js,mjs,cjs}',
|
||||
'extensions/**/*.{js,mjs,cjs}',
|
||||
];
|
||||
|
||||
const createTsConfig = ({ files, project, ignores = [], globals: tsGlobals }) => ({
|
||||
const tsIgnores = [
|
||||
'**/*.test.ts',
|
||||
'**/*.test.mts',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.mts',
|
||||
'src/backend/test/**',
|
||||
'src/backend/tools/**',
|
||||
'src/backend/vitest.config.ts',
|
||||
'src/backend/vitest.bench.config.ts',
|
||||
];
|
||||
|
||||
const createTsConfig = ({ files, project }) => ({
|
||||
files,
|
||||
ignores,
|
||||
ignores: tsIgnores,
|
||||
languageOptions: {
|
||||
parser: tseslintParser,
|
||||
...(tsGlobals ? { globals: tsGlobals } : {}),
|
||||
globals: lintedGlobals,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project,
|
||||
projectService: { defaultProject: project },
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslintPlugin,
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsRecommendedRules,
|
||||
...prettierRules,
|
||||
'@typescript-eslint/no-unused-vars': ['error', unusedVarsOptions],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'prefer-const': ['error', preferConstOptions],
|
||||
},
|
||||
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([
|
||||
{
|
||||
ignores: [
|
||||
'**/*.dbmig.js',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'volatile/**',
|
||||
'node_modules/**',
|
||||
'puter.js/**',
|
||||
'apps/**',
|
||||
'experiment/**',
|
||||
'workers/**',
|
||||
'src/public/**',
|
||||
'src/gui/**',
|
||||
'src/docs/**',
|
||||
'src/puter-js/**',
|
||||
'src/worker/**',
|
||||
'submodules/**',
|
||||
'tests/**',
|
||||
'tools/**',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: jsFiles,
|
||||
ignores: ['**/*.test.js'],
|
||||
plugins: {
|
||||
js,
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
extends: ['js/recommended'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: lintedGlobals,
|
||||
},
|
||||
rules: {
|
||||
...prettierRules,
|
||||
'no-unused-vars': ['error', unusedVarsOptions],
|
||||
'prefer-const': ['error', preferConstOptions],
|
||||
},
|
||||
},
|
||||
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'],
|
||||
files: ['src/backend/**/*.ts', 'extensions/**/*.ts'],
|
||||
project: './tsconfig.json',
|
||||
}),
|
||||
createTsConfig({
|
||||
files: ['extensions/**/*.ts'],
|
||||
project: './extensions/tsconfig.json',
|
||||
}),
|
||||
backendConfig,
|
||||
testConfig,
|
||||
extensionConfig,
|
||||
frontendConfig,
|
||||
]);
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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]], ' ');
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};;;;
|
||||
@@ -1,206 +0,0 @@
|
||||
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;
|
||||
// Defer multi-line call/new formatting to stylistic paren/argument rules.
|
||||
if ( openParen.loc.start.line !== closeParen.loc.end.line ) return;
|
||||
|
||||
const afterOpen = sourceCode.getTokenAfter(openParen);
|
||||
const beforeClose = sourceCode.getTokenBefore(closeParen);
|
||||
|
||||
// Function calls should NOT have spacing on the same line (multi-line calls are allowed)
|
||||
if ( afterOpen && openParen.range[1] !== afterOpen.range[0] ) {
|
||||
const spaceAfter = sourceCode.getText().slice(openParen.range[1], afterOpen.range[0]);
|
||||
if ( /^\s+$/.test(spaceAfter) && !spaceAfter.includes('\n') ) {
|
||||
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) && !spaceBefore.includes('\n') ) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
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': 'warn',
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,37 +0,0 @@
|
||||
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);
|
||||
});
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import backend from '@heyputer/backend';
|
||||
export default backend;
|
||||
Whitespace-only changes.
@@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
Vendored
-207
@@ -1,207 +0,0 @@
|
||||
|
||||
import type APIError from '@heyputer/backend/src/api/APIError.js';
|
||||
import type query from '@heyputer/backend/src/om/query/query';
|
||||
import type { Actor } from '@heyputer/backend/src/services/auth/Actor.js';
|
||||
import type { ServicesMap } from '@heyputer/backend/src/services/BaseService.d.ts';
|
||||
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.d.ts';
|
||||
import type { DynamoKVStore } from '@heyputer/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts';
|
||||
import type { IUser } from '@heyputer/backend/src/services/User.js';
|
||||
import type { Context } from '@heyputer/backend/src/util/context.js';
|
||||
import type kvjs from '@heyputer/kv.js';
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { Cluster } from 'ioredis';
|
||||
import type FSNodeContext from '../src/backend/src/filesystem/FSNodeContext.js';
|
||||
import type helpers from '../src/backend/src/helpers.js';
|
||||
import type { ICompleteArguments } from '../src/backend/src/services/ai/chat/providers/types.ts';
|
||||
import type * as ExtensionControllerExports from './ExtensionController/src/ExtensionController.ts';
|
||||
import type { s3ClientProvider } from '../src/backend/src/clients/s3/s3ClientProvider.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
services: {
|
||||
get: <T extends keyof ServicesMap | (string & {})>(
|
||||
string: T,
|
||||
) => T extends keyof ServicesMap ? ServicesMap[T] : unknown;
|
||||
};
|
||||
actor?: Actor;
|
||||
rawBody: Buffer;
|
||||
/** @deprecated use actor instead */
|
||||
user: IUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { Cluster } from 'ioredis';
|
||||
|
||||
export interface EndpointOptions {
|
||||
allowedMethods?: string[];
|
||||
subdomain?: string;
|
||||
noauth?: boolean;
|
||||
mw?: RequestHandler[];
|
||||
otherOpts?: Record<string, unknown> & {
|
||||
json?: boolean;
|
||||
noReallyItsJson?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Driver interface types
|
||||
interface ParameterDefinition {
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
optional: boolean;
|
||||
}
|
||||
interface MethodDefinition {
|
||||
description: string;
|
||||
parameters: Record<string, ParameterDefinition>;
|
||||
}
|
||||
interface DriverInterface {
|
||||
description: string;
|
||||
methods: Record<string, MethodDefinition>;
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
redisClient: Cluster;
|
||||
kvjs: kvjs
|
||||
s3ClientProvider: typeof s3ClientProvider;
|
||||
Context: typeof Context;
|
||||
APIError: typeof APIError;
|
||||
}
|
||||
|
||||
interface FilesystemModule {
|
||||
FSNodeContext: FSNodeContext;
|
||||
selectors: unknown;
|
||||
}
|
||||
|
||||
export interface ExtensionEventTypeMap {
|
||||
'metering:registerAvailablePolicies': {
|
||||
availablePolicies: unknown[]
|
||||
},
|
||||
'create.drivers': {
|
||||
createDriver: (interface: string, service: string, executors: any) => any;
|
||||
};
|
||||
'create.permissions': {
|
||||
grant_to_everyone: (permission: string) => void;
|
||||
grant_to_users: (permission: string) => void;
|
||||
};
|
||||
'create.interfaces': {
|
||||
createInterface: (interface: string, interfaces: DriverInterface) => void;
|
||||
};
|
||||
'puter.gui.addons': {
|
||||
bodyContent: string;
|
||||
headContent: string;
|
||||
guiParams: {
|
||||
env: string;
|
||||
app_origin: string;
|
||||
api_origin: string;
|
||||
gui_origin: string;
|
||||
asset_dir: string;
|
||||
launch_options: unknown;
|
||||
app_name_regex: RegExp;
|
||||
app_name_max_length: number;
|
||||
app_title_max_length: number;
|
||||
hosting_domain: string;
|
||||
subdomain_regex: RegExp;
|
||||
subdomain_max_length: number;
|
||||
domain: string;
|
||||
protocol: string;
|
||||
api_base_url: string;
|
||||
app?: { name: string, uid: string } & Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
'app.changed': {
|
||||
app_uid: string;
|
||||
action: 'updated' | 'deleted';
|
||||
};
|
||||
'app.privateAccess.check': {
|
||||
appUid: string;
|
||||
userUid?: string | null;
|
||||
requestHost?: string;
|
||||
requestPath?: string;
|
||||
result: {
|
||||
allowed: boolean;
|
||||
redirectUrl?: string;
|
||||
reason?: string;
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'app.privateAccess.resolveLaunch': {
|
||||
appUid: string;
|
||||
appName?: string;
|
||||
userUid?: string | null;
|
||||
source?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result: {
|
||||
hasAccess: boolean;
|
||||
fallbackAppName?: string;
|
||||
fallbackArgs?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'ai.prompt.validate': {
|
||||
actor: Actor;
|
||||
actor,
|
||||
completionId: string,
|
||||
allow: boolean,
|
||||
intended_service: string,
|
||||
parameters: ICompleteArguments
|
||||
}
|
||||
'outer.cacheUpdate': { cacheKey: string | string[], ttlSeconds?: number, data?: unknown }
|
||||
}
|
||||
|
||||
interface Extension extends RouterMethods {
|
||||
exports: Record<string, unknown>;
|
||||
span: (<T>(label: string, fn: () => T) => () => T) & {
|
||||
run<T>(label: string, fn: () => T): T;
|
||||
run<T>(fn: () => T): T;
|
||||
};
|
||||
config: Record<string | number | symbol, any>;
|
||||
|
||||
on<E extends keyof ExtensionEventTypeMap>(
|
||||
name: E,
|
||||
listener: (event: ExtensionEventTypeMap[E], metadata?: { from_outside?: boolean }) => void | Promise<void>
|
||||
): void;
|
||||
on<T>(name: string, listener: (event: T, metadata?: { from_outside?: boolean }) => void | Promise<void>): void
|
||||
|
||||
import(module: 'data'): {
|
||||
db: BaseDatabaseAccessService;
|
||||
kv: DynamoKVStore;
|
||||
cache: Cluster;
|
||||
s3ClientProvider: typeof s3ClientProvider;
|
||||
};
|
||||
import(module: 'core'): CoreRuntimeModule;
|
||||
import(module: 'fs'): FilesystemModule;
|
||||
import(module: 'query'): typeof query;
|
||||
import(module: 'extensionController'): typeof ExtensionControllerExports;
|
||||
import<T extends `service:${keyof ServicesMap}` | (string & {})>(
|
||||
module: T
|
||||
): T extends `service:${infer R extends keyof ServicesMap}`
|
||||
? ServicesMap[R]
|
||||
: unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Declare the extension variable
|
||||
const extension: Extension;
|
||||
const config: Record<string | number | symbol, any>;
|
||||
const global_config: Record<string | number | symbol, unknown>;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
const { Eq } = extension.import('query');
|
||||
const { db } = extension.import('data');
|
||||
const { APIError, Context } = extension.import('core');
|
||||
const app_es = extension.import('service:es:app') as any;
|
||||
const svc_permission = extension.import('service:permission') as any;
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
const MAX_OFFSET = 100_000;
|
||||
|
||||
const parseIntegerParam = (
|
||||
value: unknown,
|
||||
{
|
||||
key,
|
||||
min,
|
||||
max,
|
||||
fallback,
|
||||
}: { key: string, min: number, max: number, fallback: number },
|
||||
) => {
|
||||
if ( value === undefined || value === null ) return fallback;
|
||||
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: (typeof value === 'string' && value.trim() !== ''
|
||||
? Number(value)
|
||||
: Number.NaN);
|
||||
|
||||
if ( !Number.isFinite(parsed) || !Number.isInteger(parsed) ) {
|
||||
throw APIError.create('field_invalid', undefined, {
|
||||
key,
|
||||
expected: `an integer between ${min} and ${max}`,
|
||||
got: value,
|
||||
});
|
||||
}
|
||||
|
||||
if ( parsed < min || parsed > max ) {
|
||||
throw APIError.create('field_invalid', undefined, {
|
||||
key,
|
||||
expected: `an integer between ${min} and ${max}`,
|
||||
got: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
extension.on('create.interfaces', (event) => {
|
||||
event.createInterface('app-telemetry', {
|
||||
description: 'Provides methods for getting app telemetry',
|
||||
methods: {
|
||||
get_users: {
|
||||
description: 'Returns users who have used your app',
|
||||
parameters: {
|
||||
app_uuid: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
optional: true,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user_count: {
|
||||
description: 'Returns number of users who have used your app',
|
||||
parameters: {
|
||||
app_uuid: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
extension.on('create.drivers', event => {
|
||||
event.createDriver('app-telemetry', 'app-telemetry', {
|
||||
async get_users ({ app_uuid, limit, offset }: { app_uuid: string, limit?: number, offset?: number }) {
|
||||
const safeLimit = parseIntegerParam(limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntegerParam(offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
// first lets make sure executor owns this app
|
||||
const [result] = (await app_es.select({ predicate: new Eq({ key: 'uid', value: app_uuid }) }));
|
||||
if ( ! result ) {
|
||||
throw APIError.create('permission_denied');
|
||||
}
|
||||
if ( ! (await svc_permission.check(Context.get('actor'), `apps-of-user:${result.values_.owner.uuid}:write`, { no_cache: true })) ) {
|
||||
throw APIError.create('permission_denied');
|
||||
}
|
||||
|
||||
// Fetch and return users
|
||||
const users: Array<{ username: string, uuid: string }> = await db.read(
|
||||
`SELECT user.username, user.uuid FROM user_to_app_permissions
|
||||
INNER JOIN user ON user_to_app_permissions.user_id = user.id
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id=? ORDER BY (dt IS NOT NULL), dt, user_id LIMIT ? OFFSET ?`,
|
||||
[result.private_meta.mysql_id, safeLimit, safeOffset],
|
||||
);
|
||||
return users.map(e => {
|
||||
return { user: e.username, user_uuid: e.uuid };
|
||||
});
|
||||
},
|
||||
async user_count ({ app_uuid }: { app_uuid: string }) {
|
||||
// first lets make sure executor owns this app
|
||||
const [result] = (await app_es.select({ predicate: new Eq({ key: 'uid', value: app_uuid }) }));
|
||||
if ( ! result ) {
|
||||
throw APIError.create('permission_denied');
|
||||
}
|
||||
|
||||
// Fetch and return authenticated user count
|
||||
const [data] = await db.read(
|
||||
`SELECT count(*) FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id=?;`,
|
||||
[result.private_meta.mysql_id],
|
||||
);
|
||||
const count = data['count(*)'];
|
||||
return count;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
extension.on('create.permissions', (event) => {
|
||||
event.grant_to_everyone('service:app-telemetry:ii:app-telemetry');
|
||||
});
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
import '../api.js';
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@heyputer/app-telemetry",
|
||||
"main": "app-user-count.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
const MAX_OFFSET = 100_000;
|
||||
|
||||
const parseIntParam = (
|
||||
value: unknown,
|
||||
{
|
||||
key,
|
||||
min,
|
||||
max,
|
||||
fallback,
|
||||
}: { key: string; min: number; max: number; fallback: number },
|
||||
): number => {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
const parsed =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim() !== ''
|
||||
? Number(value)
|
||||
: NaN;
|
||||
if (
|
||||
!Number.isFinite(parsed) ||
|
||||
!Number.isInteger(parsed) ||
|
||||
parsed < min ||
|
||||
parsed > max
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${key} must be an integer between ${min} and ${max}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/users',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (req, res) => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const safeLimit = parseIntParam(req.query.limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(req.query.offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly. v1 got
|
||||
// this for free because its entity-storage layer eager-joined the
|
||||
// owner row; v2's AppStore.getByUid returns the raw row with only
|
||||
// `owner_user_id` populated.
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = (await stores.user.getById(ownerId)) as {
|
||||
uuid?: string;
|
||||
} | null;
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const ownsApp = await services.permission
|
||||
.check(actor!, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const users = await clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN user u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
|
||||
);
|
||||
|
||||
res.json(
|
||||
(users as Array<{ username: string; uuid: string }>).map((e) => ({
|
||||
user: e.username,
|
||||
user_uuid: e.uuid,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/user-count',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (req, res) => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const [row] = (await clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as Record<string, unknown>).id],
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
res.json({ count: row?.n ?? 0 });
|
||||
},
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
//@extension priority -10000
|
||||
|
||||
const { redisClient, kvjs, s3ClientProvider } = extension.import('core');
|
||||
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(),
|
||||
kv: simplified_kv,
|
||||
cache: redisClient,
|
||||
kvjs: kvjs,
|
||||
s3ClientProvider,
|
||||
};
|
||||
@@ -0,0 +1,423 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { PuterService } from '@heyputer/backend/src/services/types.js';
|
||||
import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js';
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const webpack = requireFromHere('webpack') as typeof import('webpack');
|
||||
|
||||
type EnvFactory = (args: {
|
||||
global_config: Record<string, unknown> | null;
|
||||
}) => string | undefined;
|
||||
|
||||
type EnvMap = Record<string, string | undefined | EnvFactory>;
|
||||
|
||||
type CommandEntry = {
|
||||
name: string;
|
||||
directory: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: EnvMap;
|
||||
};
|
||||
|
||||
type WebpackEntry = {
|
||||
name?: string;
|
||||
directory: string;
|
||||
env?: EnvMap;
|
||||
onConfig?: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
type WebpackStats = {
|
||||
hasErrors: () => boolean;
|
||||
toJson: (options: Record<string, unknown>) => {
|
||||
errors?: Array<{ message?: string }>;
|
||||
warnings?: Array<{ message?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
type DevWatcherConfig = {
|
||||
enabled?: boolean;
|
||||
root?: string;
|
||||
commands?: CommandEntry[];
|
||||
webpack?: WebpackEntry[];
|
||||
ready_delay_ms?: number;
|
||||
};
|
||||
|
||||
class ProxyLogger {
|
||||
#buffer = '';
|
||||
|
||||
constructor(private readonly log: (line: string) => void) {}
|
||||
|
||||
attach(stream: NodeJS.ReadableStream | null): void {
|
||||
if (!stream) return;
|
||||
stream.on('data', (chunk) => {
|
||||
this.#buffer += chunk.toString();
|
||||
let lineEndIndex = this.#buffer.indexOf('\n');
|
||||
while (lineEndIndex !== -1) {
|
||||
const line = this.#buffer.substring(0, lineEndIndex);
|
||||
this.log(line);
|
||||
this.#buffer = this.#buffer.substring(lineEndIndex + 1);
|
||||
lineEndIndex = this.#buffer.indexOf('\n');
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (this.#buffer.length) {
|
||||
this.log(this.#buffer);
|
||||
this.#buffer = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const findPackageRoot = (): string => {
|
||||
let dir = __dirname;
|
||||
for (;;) {
|
||||
if (
|
||||
existsSync(path.join(dir, 'package.json')) &&
|
||||
existsSync(path.join(dir, 'src', 'gui')) &&
|
||||
existsSync(path.join(dir, 'src', 'puter-js'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
};
|
||||
|
||||
const resolveFromRoot = (root: string, value: string): string =>
|
||||
path.isAbsolute(value) ? value : path.resolve(root, value);
|
||||
|
||||
const defaultWebpackEntries: WebpackEntry[] = [
|
||||
{
|
||||
name: 'puter.js',
|
||||
directory: 'src/puter-js',
|
||||
onConfig: (config) => {
|
||||
const output = (config.output ?? {}) as Record<string, unknown>;
|
||||
output.filename = 'puter.dev.js';
|
||||
config.output = output;
|
||||
config.devtool = 'source-map';
|
||||
},
|
||||
env: {
|
||||
PUTER_ORIGIN: ({ global_config }) =>
|
||||
String(global_config?.origin ?? ''),
|
||||
PUTER_API_ORIGIN: ({ global_config }) =>
|
||||
String(global_config?.api_base_url ?? ''),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gui',
|
||||
directory: 'src/gui',
|
||||
},
|
||||
];
|
||||
|
||||
class DevWatcherService extends PuterService {
|
||||
#processes: Array<{ name: string; proc: ChildProcess }> = [];
|
||||
#watchers: Array<{
|
||||
close: (handler: (err?: Error | null) => void) => void;
|
||||
}> = [];
|
||||
#started = false;
|
||||
#packageRoot = findPackageRoot();
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
if (!this.#shouldStart()) return;
|
||||
if (this.#started) return;
|
||||
this.#started = true;
|
||||
|
||||
const devwatch = this.#devwatchConfig();
|
||||
const root = resolveFromRoot(
|
||||
this.#packageRoot,
|
||||
devwatch.root ?? this.#packageRoot,
|
||||
);
|
||||
const commands = devwatch.commands ?? [];
|
||||
const webpackEntries = devwatch.webpack ?? defaultWebpackEntries;
|
||||
|
||||
console.log(`[devwatch] starting watchers from ${root}`);
|
||||
await Promise.all([
|
||||
...commands.map((entry) => this.#startCommand(root, entry)),
|
||||
...webpackEntries.map((entry) =>
|
||||
this.#startWebpackWatcher(root, entry),
|
||||
),
|
||||
]);
|
||||
|
||||
const readyDelayMs = devwatch.ready_delay_ms ?? 5000;
|
||||
if (readyDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, readyDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
await Promise.all(
|
||||
this.#watchers.map(
|
||||
(watcher) =>
|
||||
new Promise<void>((resolve) => {
|
||||
watcher.close((err) => {
|
||||
if (err) {
|
||||
console.warn(
|
||||
'[devwatch] failed to close webpack watcher:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.#watchers = [];
|
||||
|
||||
for (const { name, proc } of this.#processes) {
|
||||
if (proc.exitCode !== null || proc.killed) continue;
|
||||
console.log(`[devwatch:${name}] stopping process`);
|
||||
proc.kill();
|
||||
}
|
||||
this.#processes = [];
|
||||
}
|
||||
|
||||
#devwatchConfig(): DevWatcherConfig {
|
||||
const raw = (this.config as Record<string, unknown>).devwatch;
|
||||
return raw && typeof raw === 'object' ? (raw as DevWatcherConfig) : {};
|
||||
}
|
||||
|
||||
#shouldStart(): boolean {
|
||||
const config = this.config as Record<string, unknown>;
|
||||
const devwatch = this.#devwatchConfig();
|
||||
|
||||
if (config.no_devwatch === true) return false;
|
||||
if (devwatch.enabled === false) return false;
|
||||
if (devwatch.enabled === true) return true;
|
||||
|
||||
return this.config.env === 'dev';
|
||||
}
|
||||
|
||||
async #startCommand(root: string, entry: CommandEntry): Promise<void> {
|
||||
const fullpath = resolveFromRoot(root, entry.directory);
|
||||
console.log(`[devwatch] starting ${entry.name} in ${fullpath}`);
|
||||
|
||||
const proc = spawn(entry.command, entry.args ?? [], {
|
||||
shell: true,
|
||||
cwd: fullpath,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.#evaluateEnv(entry.env),
|
||||
},
|
||||
});
|
||||
this.#processes.push({ name: entry.name, proc });
|
||||
|
||||
new ProxyLogger((line) =>
|
||||
console.log(`[devwatch:${entry.name}:1] ${line}`),
|
||||
).attach(proc.stdout);
|
||||
new ProxyLogger((line) =>
|
||||
console.warn(`[devwatch:${entry.name}:2] ${line}`),
|
||||
).attach(proc.stderr);
|
||||
|
||||
proc.on('exit', () => {
|
||||
console.log(
|
||||
`[devwatch:${entry.name}:exit] process exited (${proc.exitCode})`,
|
||||
);
|
||||
this.#processes = this.#processes.filter(
|
||||
(instance) => instance.proc !== proc,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #startWebpackWatcher(
|
||||
root: string,
|
||||
entry: WebpackEntry,
|
||||
): Promise<void> {
|
||||
const directory = entry.directory;
|
||||
let { configjsPath: webpackConfigPath, moduleType } = this.#getConfigJs(
|
||||
{
|
||||
root,
|
||||
directory,
|
||||
configIsFor: 'webpack',
|
||||
possibleConfigNames: [
|
||||
['webpack.config.js', 'package.json'],
|
||||
['webpack.config.cjs', 'commonjs'],
|
||||
['webpack.config.mjs', 'module'],
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
let webpackConfig = await this.#withEnv(entry.env, async () => {
|
||||
if (moduleType === 'module') {
|
||||
webpackConfigPath = pathToFileURL(webpackConfigPath).href;
|
||||
const imported = await nativeImport<{ default?: unknown }>(
|
||||
webpackConfigPath,
|
||||
);
|
||||
return imported.default ?? imported;
|
||||
}
|
||||
return requireFromHere(webpackConfigPath);
|
||||
});
|
||||
|
||||
if (typeof webpackConfig === 'function') {
|
||||
webpackConfig = await this.#withEnv(entry.env, () =>
|
||||
(webpackConfig as () => unknown)(),
|
||||
);
|
||||
}
|
||||
|
||||
this.#normalizeWebpackContext(root, directory, webpackConfig);
|
||||
if (entry.onConfig) {
|
||||
entry.onConfig(webpackConfig as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const compiler = webpack(
|
||||
webpackConfig as Parameters<typeof webpack>[0],
|
||||
);
|
||||
const watcher = compiler.watch({}, (err, stats) => {
|
||||
this.#handleWebpackUpdate(entry, err, stats);
|
||||
});
|
||||
this.#watchers.push(watcher);
|
||||
}
|
||||
|
||||
#getConfigJs(args: {
|
||||
root: string;
|
||||
directory: string;
|
||||
configIsFor: string;
|
||||
possibleConfigNames: Array<
|
||||
[string, 'package.json' | 'commonjs' | 'module']
|
||||
>;
|
||||
}): {
|
||||
configjsPath: string;
|
||||
moduleType: 'commonjs' | 'module';
|
||||
} {
|
||||
const { root, directory, configIsFor, possibleConfigNames } = args;
|
||||
let configjsPath: string | undefined;
|
||||
let moduleType: 'package.json' | 'commonjs' | 'module' | undefined;
|
||||
|
||||
for (const [configName, supposedModuleType] of possibleConfigNames) {
|
||||
const supposedPath = path.join(root, directory, configName);
|
||||
if (existsSync(supposedPath)) {
|
||||
configjsPath = supposedPath;
|
||||
moduleType = supposedModuleType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configjsPath || !moduleType) {
|
||||
throw new Error(
|
||||
`could not find ${configIsFor} config for: ${directory}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (moduleType === 'package.json') {
|
||||
const packageJSONPath = path.join(root, directory, 'package.json');
|
||||
const packageJSONObject = JSON.parse(
|
||||
readFileSync(packageJSONPath, 'utf8'),
|
||||
) as { type?: 'commonjs' | 'module' };
|
||||
moduleType = packageJSONObject.type ?? 'module';
|
||||
}
|
||||
|
||||
return {
|
||||
configjsPath,
|
||||
moduleType,
|
||||
};
|
||||
}
|
||||
|
||||
async #withEnv<T>(env: EnvMap | undefined, fn: () => T | Promise<T>) {
|
||||
if (!env) return fn();
|
||||
|
||||
const oldEnv = process.env;
|
||||
process.env = {
|
||||
...oldEnv,
|
||||
...this.#evaluateEnv(env),
|
||||
};
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env = oldEnv;
|
||||
}
|
||||
}
|
||||
|
||||
#evaluateEnv(env: EnvMap | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!env) return out;
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
try {
|
||||
const evaluated =
|
||||
typeof value === 'function'
|
||||
? value({
|
||||
global_config: this.config as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
})
|
||||
: value;
|
||||
if (evaluated) out[key] = String(evaluated);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (
|
||||
!msg.includes('Cannot read properties of null') &&
|
||||
!msg.includes('Cannot read properties of undefined')
|
||||
) {
|
||||
console.warn(
|
||||
`[devwatch] could not evaluate env function for ${key}: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
#normalizeWebpackContext(
|
||||
root: string,
|
||||
directory: string,
|
||||
webpackConfig: unknown,
|
||||
): void {
|
||||
const configs = Array.isArray(webpackConfig)
|
||||
? webpackConfig
|
||||
: [webpackConfig];
|
||||
|
||||
for (const config of configs) {
|
||||
if (!config || typeof config !== 'object') continue;
|
||||
const obj = config as Record<string, unknown>;
|
||||
obj.context = obj.context
|
||||
? path.resolve(path.join(root, directory), String(obj.context))
|
||||
: path.join(root, directory);
|
||||
}
|
||||
}
|
||||
|
||||
#handleWebpackUpdate(
|
||||
entry: WebpackEntry,
|
||||
err: Error | null | undefined,
|
||||
stats: WebpackStats | undefined,
|
||||
): void {
|
||||
const name = entry.name ?? entry.directory;
|
||||
const firstEventKey = `__devwatch_first_${entry.directory}`;
|
||||
const firstEvent = !(entry as Record<string, unknown>)[firstEventKey];
|
||||
(entry as Record<string, unknown>)[firstEventKey] = true;
|
||||
|
||||
if (err || stats?.hasErrors()) {
|
||||
const info = stats?.toJson({
|
||||
all: false,
|
||||
errors: true,
|
||||
warnings: true,
|
||||
});
|
||||
console.error(
|
||||
`[devwatch] error information: ${name} using Webpack`,
|
||||
{
|
||||
err: err ? err.message : null,
|
||||
errors: info?.errors?.map((e) => e.message) ?? [],
|
||||
warnings: info?.warnings?.map((w) => w.message) ?? [],
|
||||
},
|
||||
);
|
||||
console.error(`[devwatch] failed to update ${name} using Webpack`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstEvent) {
|
||||
console.log(`[devwatch] updated ${name} using Webpack`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension.registerService('devWatcher', DevWatcherService);
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@puter/extension-controller",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-status-codes": "^2.3.0",
|
||||
"stripe": "^19.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"priority": -10
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import type {
|
||||
EndpointOptions,
|
||||
HttpMethod,
|
||||
RouterMethods,
|
||||
} from '../../api.d.ts';
|
||||
declare const extension: Partial<Record<HttpMethod, RouterMethods[HttpMethod]>>;
|
||||
/**
|
||||
* Class decorator to set prefix on prototype and register routes on instantiation
|
||||
* @argument prefix - prefix for all routes under the class
|
||||
* @argument [adminUsernames] - gate all routes behind admin username check
|
||||
*/
|
||||
export const Controller = (
|
||||
prefix: string,
|
||||
adminUsernames?: string[],
|
||||
allowedAppIds?: string[],
|
||||
): ClassDecorator => {
|
||||
return (target: Function) => {
|
||||
target.prototype.__controllerPrefix = prefix;
|
||||
target.prototype.__allowedAppIds = allowedAppIds;
|
||||
target.prototype.__adminUsernames = adminUsernames
|
||||
? [...adminUsernames, 'admin', 'system']
|
||||
: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Method decorator factory that collects route metadata
|
||||
*/
|
||||
interface RouteMeta {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
options?: EndpointOptions | undefined;
|
||||
handler: (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
|
||||
adminUsernames?: string[];
|
||||
allowedAppIds?: string[];
|
||||
}
|
||||
|
||||
const createMethodDecorator = (method: HttpMethod) => {
|
||||
return <This>(
|
||||
path: string,
|
||||
routeOptions?: EndpointOptions & { allowedAppIds?: string[] },
|
||||
adminUsernames?: string[],
|
||||
) => {
|
||||
const { allowedAppIds, ...options } = routeOptions ?? {};
|
||||
return (
|
||||
target: (req: Request, res: Response, next: NextFunction) => void | Promise<void>,
|
||||
_context: ClassMethodDecoratorContext<
|
||||
This,
|
||||
(
|
||||
this: This,
|
||||
...args: [req: Request, res: Response, next: NextFunction]
|
||||
) => void | Promise<void>
|
||||
>,
|
||||
) => {
|
||||
_context.addInitializer(function () {
|
||||
// eslint-disable-next-line no-invalid-this
|
||||
const proto = Object.getPrototypeOf(this); // will be bound to class
|
||||
if ( ! proto.__routes ) {
|
||||
proto.__routes = [];
|
||||
}
|
||||
proto.__routes.push({
|
||||
method,
|
||||
path,
|
||||
options: options as EndpointOptions | undefined,
|
||||
adminUsernames: adminUsernames
|
||||
? [...adminUsernames, 'admin', 'system']
|
||||
: undefined,
|
||||
allowedAppIds,
|
||||
handler: target,
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// HTTP method decorators
|
||||
export const Get = createMethodDecorator('get');
|
||||
export const Post = createMethodDecorator('post');
|
||||
export const Put = createMethodDecorator('put');
|
||||
export const Delete = createMethodDecorator('delete');
|
||||
// TODO DS: add others as needed (patch, etc)
|
||||
|
||||
interface HttpErrorOptions {
|
||||
cause?: unknown;
|
||||
legacyCode?: string;
|
||||
code?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isHttpErrorOptions = (value: unknown): value is HttpErrorOptions => {
|
||||
if ( !value || typeof value !== 'object' || Array.isArray(value) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(value, 'cause')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'legacyCode')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'code')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'fields')
|
||||
);
|
||||
};
|
||||
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
legacyCode?: string;
|
||||
code?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
constructor (
|
||||
statusCode: StatusCodes,
|
||||
message: string,
|
||||
causeOrOptions?: unknown,
|
||||
legacyCode?: string,
|
||||
) {
|
||||
const options = isHttpErrorOptions(causeOrOptions)
|
||||
? causeOrOptions
|
||||
: undefined;
|
||||
const cause = options
|
||||
? options.cause
|
||||
: causeOrOptions;
|
||||
const resolvedLegacyCode = legacyCode ?? options?.legacyCode;
|
||||
const code = options?.code;
|
||||
super(
|
||||
`${statusCode} - ${message}`,
|
||||
cause !== undefined ? { cause } : undefined,
|
||||
);
|
||||
this.statusCode = statusCode;
|
||||
this.legacyCode = resolvedLegacyCode;
|
||||
this.code = code;
|
||||
this.fields = options?.fields;
|
||||
}
|
||||
}
|
||||
|
||||
// Registers all routes from a decorated controller instance to an Express router
|
||||
export class ExtensionController {
|
||||
logger?: Console;
|
||||
// TODO DS: make this work with other express-like routers
|
||||
registerRoutes () {
|
||||
const logger = this.logger || console;
|
||||
const prefix = Object.getPrototypeOf(this).__controllerPrefix || '';
|
||||
const adminsForController = Object.getPrototypeOf(this).__adminUsernames as
|
||||
| string[]
|
||||
| undefined;
|
||||
const allowedAppIdsForController = Object.getPrototypeOf(this).__allowedAppIds as
|
||||
| string[]
|
||||
| undefined;
|
||||
const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || [];
|
||||
for ( const route of routes ) {
|
||||
const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/');
|
||||
const adminsForRoute = route.adminUsernames
|
||||
? adminsForController
|
||||
? adminsForController.concat(route.adminUsernames)
|
||||
: route.adminUsernames
|
||||
: adminsForController
|
||||
? adminsForController
|
||||
: undefined;
|
||||
const allowedAppIds = route.allowedAppIds
|
||||
? allowedAppIdsForController
|
||||
? allowedAppIdsForController.concat(route.allowedAppIds)
|
||||
: route.allowedAppIds
|
||||
: allowedAppIdsForController
|
||||
? allowedAppIdsForController
|
||||
: undefined;
|
||||
|
||||
if ( ! extension[route.method] ) {
|
||||
throw new Error(`Unsupported HTTP method: ${route.method}`);
|
||||
} else {
|
||||
logger.log(`Registering route: [${route.method.toUpperCase()}] ${fullPath}`);
|
||||
|
||||
(extension[route.method] as RouterMethods[HttpMethod])(
|
||||
fullPath,
|
||||
route.options || {},
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
if ( adminsForRoute || allowedAppIds ) {
|
||||
if ( ! req.actor ) {
|
||||
throw new HttpError(StatusCodes.UNAUTHORIZED, 'Unauthenticated');
|
||||
}
|
||||
}
|
||||
if ( adminsForRoute ) {
|
||||
if ( ! adminsForRoute.includes(req.actor!.type.user.username) ) {
|
||||
throw new HttpError(
|
||||
StatusCodes.FORBIDDEN,
|
||||
'Only admins may request this resource.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if ( allowedAppIds ) {
|
||||
if ( ( req.actor!.type?.app?.uid && !allowedAppIds.includes(req.actor!.type.app.uid) ) ) {
|
||||
throw new HttpError(
|
||||
StatusCodes.FORBIDDEN,
|
||||
'This app may not request this resource.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return await route.handler.bind(this)(req, res, next);
|
||||
} catch ( error ) {
|
||||
if ( error instanceof HttpError ) {
|
||||
const payload: Record<string, unknown> = {
|
||||
error: error.message,
|
||||
};
|
||||
if ( error.legacyCode ) {
|
||||
payload.code = error.legacyCode;
|
||||
}
|
||||
if ( error.code ) {
|
||||
if ( payload.code === undefined ) {
|
||||
payload.code = error.code;
|
||||
} else {
|
||||
payload.errorCode = error.code;
|
||||
}
|
||||
}
|
||||
if ( error.fields ) {
|
||||
for ( const [key, value] of Object.entries(error.fields) ) {
|
||||
if ( payload[key] !== undefined ) {
|
||||
continue;
|
||||
}
|
||||
payload[key] = value;
|
||||
}
|
||||
}
|
||||
res.status(error.statusCode).send(payload);
|
||||
logger.warn('httpError:', error);
|
||||
return;
|
||||
}
|
||||
if ( error instanceof Error ) {
|
||||
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: error.message });
|
||||
logger.error('Non-http error:', error);
|
||||
return;
|
||||
}
|
||||
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: 'An unknown error occurred' });
|
||||
logger.error('An unknown error occurred:', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Controller, Delete, ExtensionController, Get, HttpError, Post, Put } from './ExtensionController.js';
|
||||
|
||||
extension.exports = {
|
||||
ExtensionController,
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Post,
|
||||
Delete,
|
||||
HttpError,
|
||||
};
|
||||
|
||||
export {
|
||||
Controller, Delete, ExtensionController, Get, HttpError, Post, Put,
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
Generated
-2653
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@heyputer/fs",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@aws-sdk/client-s3": "^3.1021.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-request-presigner": "^3.1021.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"busboy": "^1.6.0",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"stripe": "^20.1.2",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
*.js
|
||||
*.js.map
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { FSEntryRepository } from '../repositories/FSEntryRepository.js';
|
||||
import type {
|
||||
FsRemoveNodeEventPayload,
|
||||
FsRemoveNodeTarget,
|
||||
OuterGuiItemEventPayload,
|
||||
} from './types.js';
|
||||
|
||||
export class FSEntryCacheInvalidationEventHandler {
|
||||
#fsEntryRepository: FSEntryRepository;
|
||||
|
||||
constructor (fsEntryRepository: FSEntryRepository) {
|
||||
this.#fsEntryRepository = fsEntryRepository;
|
||||
this.#registerHandlers();
|
||||
}
|
||||
|
||||
#registerHandlers (): void {
|
||||
extension.on('outer.gui.item.added', async (event: OuterGuiItemEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleOuterGuiItemEvent(event), 'outer.gui.item.added');
|
||||
});
|
||||
extension.on('outer.gui.item.updated', async (event: OuterGuiItemEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleOuterGuiItemEvent(event), 'outer.gui.item.updated');
|
||||
});
|
||||
extension.on('outer.gui.item.moved', async (event: OuterGuiItemEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleOuterGuiItemEvent(event), 'outer.gui.item.moved');
|
||||
});
|
||||
extension.on('fs.remove.node', async (event: FsRemoveNodeEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleRemoveNodeEvent(event), 'fs.remove.node');
|
||||
});
|
||||
}
|
||||
|
||||
async #runSafely (handler: () => Promise<void>, eventName: string): Promise<void> {
|
||||
try {
|
||||
await handler();
|
||||
} catch ( error ) {
|
||||
console.error(`prodfsv2 cache invalidation failed for ${eventName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
#toUserIds (value: unknown): number[] {
|
||||
if ( ! Array.isArray(value) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userIds: number[] = [];
|
||||
for ( const item of value ) {
|
||||
const numeric = Number(item);
|
||||
if ( Number.isInteger(numeric) && numeric > 0 ) {
|
||||
userIds.push(numeric);
|
||||
}
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
|
||||
#toNonEmptyString (value: unknown): string | null {
|
||||
if ( typeof value !== 'string' ) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
#isUnrecognizedTargetKeyError (error: unknown): boolean {
|
||||
if ( ! (error instanceof Error) ) {
|
||||
return false;
|
||||
}
|
||||
return error.message.includes('unrecognize key for FSNodeContext.get:');
|
||||
}
|
||||
|
||||
async #readTargetValue (target: FsRemoveNodeTarget, keys: string[]): Promise<unknown> {
|
||||
if ( typeof target.get !== 'function' ) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for ( const key of keys ) {
|
||||
try {
|
||||
return await target.get(key);
|
||||
} catch ( error ) {
|
||||
if ( this.#isUnrecognizedTargetKeyError(error) ) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#extractUidFromEntry (value: unknown): string | null {
|
||||
if ( !value || typeof value !== 'object' ) {
|
||||
return null;
|
||||
}
|
||||
const entry = value as { uid?: unknown; uuid?: unknown };
|
||||
return this.#toNonEmptyString(entry.uid) ?? this.#toNonEmptyString(entry.uuid);
|
||||
}
|
||||
|
||||
async #handleOuterGuiItemEvent (event: OuterGuiItemEventPayload): Promise<void> {
|
||||
const userIds = this.#toUserIds(event?.user_id_list);
|
||||
const response = event?.response ?? {};
|
||||
|
||||
const path = this.#toNonEmptyString(response.path);
|
||||
const oldPath = this.#toNonEmptyString(response.old_path);
|
||||
const uid =
|
||||
this.#toNonEmptyString(response.uid)
|
||||
?? this.#toNonEmptyString(response.uuid)
|
||||
?? this.#toNonEmptyString(response.id);
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
for ( const userId of userIds ) {
|
||||
if ( path ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByPathForUser(userId, path));
|
||||
}
|
||||
if ( oldPath && oldPath !== path ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByPathForUser(userId, oldPath));
|
||||
}
|
||||
}
|
||||
if ( uid ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByUuid(uid));
|
||||
}
|
||||
|
||||
if ( tasks.length > 0 ) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleRemoveNodeEvent (event: FsRemoveNodeEventPayload): Promise<void> {
|
||||
const target = event?.target;
|
||||
if ( !target || typeof target.get !== 'function' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userIdValue = await this.#readTargetValue(target, ['user_id']);
|
||||
const pathValue = await this.#readTargetValue(target, ['path']);
|
||||
const uidValue =
|
||||
await this.#readTargetValue(target, ['uid', 'uuid'])
|
||||
?? this.#extractUidFromEntry(await this.#readTargetValue(target, ['entry']));
|
||||
|
||||
const userId = Number(userIdValue);
|
||||
const path = this.#toNonEmptyString(pathValue);
|
||||
const uuid = this.#toNonEmptyString(uidValue);
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
if ( Number.isInteger(userId) && userId > 0 && path ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByPathForUser(userId, path));
|
||||
}
|
||||
if ( uuid ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByUuid(uuid));
|
||||
}
|
||||
|
||||
if ( tasks.length > 0 ) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface OuterGuiItemEventResponse {
|
||||
path?: string;
|
||||
old_path?: string;
|
||||
uid?: string;
|
||||
uuid?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface OuterGuiItemEventPayload {
|
||||
user_id_list?: Array<number | string>;
|
||||
response?: OuterGuiItemEventResponse;
|
||||
}
|
||||
|
||||
export interface FsRemoveNodeTarget {
|
||||
get?: (key: string) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface FsRemoveNodeEventPayload {
|
||||
target?: FsRemoveNodeTarget;
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
/// <reference path="../../api.d.ts" />
|
||||
|
||||
export {};
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
import '@heyputer/backend/src/services/User.js';
|
||||
declare module '../../packages/puter/src/backend/src/services/User.d.ts' {
|
||||
export interface IUser {
|
||||
stripe_customer_id?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { DynamoKVStore } from '@heyputer/backend/src/services/DynamoKVStore/DynamoKVStore.js';
|
||||
import { FSController } from './controllers/FSController.js';
|
||||
import { FSEntryCacheInvalidationEventHandler } from './eventHandlers/FSEntryCacheInvalidationEventHandler.js';
|
||||
import { FSEntryRepository } from './repositories/FSEntryRepository.js';
|
||||
import { S3StorageProvider } from './repositories/S3FileStorageRepository.js';
|
||||
import { FSEntryService } from './services/FSEntryService.js';
|
||||
|
||||
const databaseService = extension.import('service:database');
|
||||
const { cache, s3ClientProvider } = extension.import('data');
|
||||
const eventService = extension.import('service:event');
|
||||
const kvStore = extension.import('service:puter-kvstore') as DynamoKVStore;
|
||||
const filesystemDb = databaseService;
|
||||
|
||||
const fsEntryRepository = new FSEntryRepository(filesystemDb, cache, kvStore);
|
||||
const s3StorageProvider = new S3StorageProvider(s3ClientProvider);
|
||||
const fsEntryService = new FSEntryService(fsEntryRepository, s3StorageProvider);
|
||||
|
||||
const fsController = new FSController(fsEntryService, eventService);
|
||||
(fsController as unknown as { registerRoutes: () => void }).registerRoutes();
|
||||
new FSEntryCacheInvalidationEventHandler(fsEntryRepository);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,383 +0,0 @@
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
UploadPartCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import type { s3ClientProvider } from '@heyputer/backend/src/clients/s3/s3ClientProvider.js';
|
||||
import { Readable } from 'node:stream';
|
||||
import type {
|
||||
MultipartCompleteInput,
|
||||
ServerUploadInput,
|
||||
SignedMultipartPartUrlsInput,
|
||||
SignedUploadInput,
|
||||
SignedUploadPart,
|
||||
SignedUploadResult,
|
||||
} from './s3Types.js';
|
||||
|
||||
export class S3StorageProvider {
|
||||
|
||||
#s3ClientProvider: typeof s3ClientProvider;
|
||||
|
||||
constructor (s3Provider: typeof s3ClientProvider) {
|
||||
this.#s3ClientProvider = s3Provider;
|
||||
}
|
||||
|
||||
#getClientForRegion (region: string): S3Client {
|
||||
return this.#s3ClientProvider.get(region);
|
||||
}
|
||||
|
||||
getMaxSingleUploadSize (): number {
|
||||
return this.#s3ClientProvider.maxSingleUploadSize;
|
||||
}
|
||||
|
||||
getMultipartPartSize (): number {
|
||||
return this.#s3ClientProvider.partSize;
|
||||
}
|
||||
|
||||
#resolveMultipartPartSize (requestedPartSize?: number): number {
|
||||
return Math.max(
|
||||
this.getMaxSingleUploadSize(),
|
||||
requestedPartSize ?? this.getMultipartPartSize(),
|
||||
);
|
||||
}
|
||||
|
||||
async createSignedUploadUrl (fileMetadata: SignedUploadInput, region: string): Promise<SignedUploadResult> {
|
||||
const [result] = await this.batchCreateSignedUploadUrls([fileMetadata], region);
|
||||
if ( ! result ) {
|
||||
throw new Error('Failed to create signed upload url');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async batchCreateSignedUploadUrls (filesMetadata: SignedUploadInput[], region: string): Promise<SignedUploadResult[]> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const now = Date.now();
|
||||
const settledResults = await Promise.allSettled(filesMetadata.map(async (fileMetadata) => {
|
||||
const expiresInSeconds = Math.max(60, Math.min(60 * 60, fileMetadata.expiresInSeconds));
|
||||
const expiresAt = now + expiresInSeconds * 1000;
|
||||
const maxSingleUploadSize = this.getMaxSingleUploadSize();
|
||||
const shouldUseSingleUpload = fileMetadata.uploadMode === 'single'
|
||||
&& fileMetadata.size <= maxSingleUploadSize;
|
||||
|
||||
if ( shouldUseSingleUpload ) {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: fileMetadata.bucket,
|
||||
Key: fileMetadata.objectKey,
|
||||
ContentType: fileMetadata.contentType,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
return {
|
||||
uploadMode: 'single' as const,
|
||||
expiresAt,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
const multipartPartSize = this.#resolveMultipartPartSize(fileMetadata.multipartPartSize);
|
||||
const multipartPartCount = Math.max(1, Math.ceil(fileMetadata.size / multipartPartSize));
|
||||
let multipartUploadId: string | undefined;
|
||||
|
||||
try {
|
||||
const multipartResult = await client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: fileMetadata.bucket,
|
||||
Key: fileMetadata.objectKey,
|
||||
ContentType: fileMetadata.contentType,
|
||||
}));
|
||||
|
||||
if ( ! multipartResult.UploadId ) {
|
||||
throw new Error('Failed to initialize multipart upload');
|
||||
}
|
||||
|
||||
multipartUploadId = multipartResult.UploadId;
|
||||
const partUrls = await this.createSignedMultipartPartUrls({
|
||||
bucket: fileMetadata.bucket,
|
||||
objectKey: fileMetadata.objectKey,
|
||||
multipartUploadId,
|
||||
partNumbers: Array.from({ length: multipartPartCount }, (_, index) => index + 1),
|
||||
expiresInSeconds,
|
||||
}, region);
|
||||
|
||||
return {
|
||||
uploadMode: 'multipart' as const,
|
||||
expiresAt,
|
||||
multipartUploadId,
|
||||
multipartPartSize,
|
||||
multipartPartCount,
|
||||
multipartPartUrls: partUrls,
|
||||
};
|
||||
} catch ( error ) {
|
||||
if ( multipartUploadId ) {
|
||||
try {
|
||||
await this.abortMutipartUpload(
|
||||
multipartUploadId,
|
||||
region,
|
||||
fileMetadata.bucket,
|
||||
fileMetadata.objectKey,
|
||||
);
|
||||
} catch {
|
||||
// Best effort cleanup for partially initialized multipart uploads.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
const failedResults = settledResults.filter((result) => result.status === 'rejected');
|
||||
if ( failedResults.length > 0 ) {
|
||||
await Promise.allSettled(settledResults.map((result, index) => {
|
||||
if ( result.status !== 'fulfilled' ) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if ( result.value.uploadMode !== 'multipart' || !result.value.multipartUploadId ) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const fileMetadata = filesMetadata[index];
|
||||
if ( ! fileMetadata ) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.abortMutipartUpload(
|
||||
result.value.multipartUploadId,
|
||||
region,
|
||||
fileMetadata.bucket,
|
||||
fileMetadata.objectKey,
|
||||
);
|
||||
}));
|
||||
|
||||
const firstFailure = failedResults[0]?.reason;
|
||||
if ( firstFailure instanceof Error ) {
|
||||
throw firstFailure;
|
||||
}
|
||||
throw new Error('Failed to create signed upload urls');
|
||||
}
|
||||
|
||||
return settledResults.map((result) => {
|
||||
if ( result.status !== 'fulfilled' ) {
|
||||
throw new Error('Failed to create signed upload urls');
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
}
|
||||
|
||||
async createSignedMultipartPartUrls (
|
||||
input: SignedMultipartPartUrlsInput,
|
||||
region: string,
|
||||
): Promise<SignedUploadPart[]> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const expiresInSeconds = Math.max(60, Math.min(60 * 60, input.expiresInSeconds));
|
||||
|
||||
return Promise.all(input.partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: input.multipartUploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
return {
|
||||
partNumber,
|
||||
url,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async completeMultipartUpload (input: MultipartCompleteInput, region: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
await client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: input.multipartUploadId,
|
||||
MultipartUpload: {
|
||||
Parts: [...input.parts]
|
||||
.sort((partA, partB) => partA.partNumber - partB.partNumber)
|
||||
.map((part) => ({
|
||||
PartNumber: part.partNumber,
|
||||
ETag: part.etag,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async abortMutipartUpload (uploadId: string, region: string, bucket: string, objectKey: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
await client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
UploadId: uploadId,
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadFromServer (input: ServerUploadInput, region: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const maxSingleUploadSize = this.getMaxSingleUploadSize();
|
||||
const resolvedContentLength = this.#resolveContentLength(input);
|
||||
const shouldUseMultipart = input.body instanceof Readable
|
||||
? resolvedContentLength === undefined || resolvedContentLength > maxSingleUploadSize
|
||||
: resolvedContentLength !== undefined && resolvedContentLength > maxSingleUploadSize;
|
||||
|
||||
if ( ! shouldUseMultipart ) {
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
ContentType: input.contentType,
|
||||
Body: input.body,
|
||||
...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#uploadFromServerMultipart(input, region, this.#resolveMultipartPartSize());
|
||||
}
|
||||
|
||||
async deleteObject (bucket: string, objectKey: string, region: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
await client.send(new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
}));
|
||||
}
|
||||
|
||||
#resolveContentLength (input: ServerUploadInput): number | undefined {
|
||||
if ( Number.isFinite(input.contentLength) && Number(input.contentLength) >= 0 ) {
|
||||
return Number(input.contentLength);
|
||||
}
|
||||
if ( Number.isFinite(input.sizeHint) && Number(input.sizeHint) >= 0 ) {
|
||||
return Number(input.sizeHint);
|
||||
}
|
||||
if ( Buffer.isBuffer(input.body) || input.body instanceof Uint8Array ) {
|
||||
return input.body.byteLength;
|
||||
}
|
||||
if ( typeof input.body === 'string' ) {
|
||||
return Buffer.byteLength(input.body);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#toBuffer (chunk: unknown): Buffer {
|
||||
if ( Buffer.isBuffer(chunk) ) {
|
||||
return chunk;
|
||||
}
|
||||
if ( chunk instanceof Uint8Array ) {
|
||||
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
if ( typeof chunk === 'string' ) {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
throw new Error('Unsupported chunk type for multipart upload');
|
||||
}
|
||||
|
||||
async #uploadFromServerMultipart (
|
||||
input: ServerUploadInput,
|
||||
region: string,
|
||||
partSize: number,
|
||||
): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const createResult = await client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
ContentType: input.contentType,
|
||||
}));
|
||||
|
||||
const uploadId = createResult.UploadId;
|
||||
if ( ! uploadId ) {
|
||||
throw new Error('Failed to initialize multipart upload');
|
||||
}
|
||||
|
||||
const completedParts: Array<{ ETag: string; PartNumber: number; }> = [];
|
||||
let partNumber = 1;
|
||||
|
||||
const uploadPart = async (partBody: Buffer) => {
|
||||
const uploadPartResult = await client.send(new UploadPartCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: partBody,
|
||||
ContentLength: partBody.byteLength,
|
||||
}));
|
||||
if ( ! uploadPartResult.ETag ) {
|
||||
throw new Error(`Multipart upload returned no ETag for part ${partNumber}`);
|
||||
}
|
||||
completedParts.push({
|
||||
ETag: uploadPartResult.ETag,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
partNumber++;
|
||||
};
|
||||
|
||||
try {
|
||||
if ( Buffer.isBuffer(input.body) || input.body instanceof Uint8Array ) {
|
||||
const bufferBody = this.#toBuffer(input.body);
|
||||
for ( let offset = 0; offset < bufferBody.byteLength; offset += partSize ) {
|
||||
const partBody = bufferBody.subarray(offset, offset + partSize);
|
||||
await uploadPart(partBody);
|
||||
}
|
||||
} else if ( typeof input.body === 'string' ) {
|
||||
const bufferBody = Buffer.from(input.body);
|
||||
for ( let offset = 0; offset < bufferBody.byteLength; offset += partSize ) {
|
||||
const partBody = bufferBody.subarray(offset, offset + partSize);
|
||||
await uploadPart(partBody);
|
||||
}
|
||||
} else if ( input.body instanceof Readable ) {
|
||||
let pendingChunk: Buffer = Buffer.alloc(0);
|
||||
for await ( const chunk of input.body ) {
|
||||
const chunkBuffer = this.#toBuffer(chunk);
|
||||
if ( chunkBuffer.byteLength === 0 ) {
|
||||
continue;
|
||||
}
|
||||
pendingChunk = pendingChunk.byteLength === 0
|
||||
? chunkBuffer
|
||||
: Buffer.concat([pendingChunk, chunkBuffer]);
|
||||
while ( pendingChunk.byteLength >= partSize ) {
|
||||
const partBody = pendingChunk.subarray(0, partSize);
|
||||
await uploadPart(partBody);
|
||||
pendingChunk = pendingChunk.subarray(partSize);
|
||||
}
|
||||
}
|
||||
if ( pendingChunk.byteLength > 0 ) {
|
||||
await uploadPart(pendingChunk);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unsupported body type for multipart upload');
|
||||
}
|
||||
|
||||
if ( completedParts.length === 0 ) {
|
||||
await client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
}));
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
ContentType: input.contentType,
|
||||
Body: Buffer.alloc(0),
|
||||
ContentLength: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: completedParts,
|
||||
},
|
||||
}));
|
||||
} catch ( error ) {
|
||||
await client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
})).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export interface SignedUploadInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadMode: 'single' | 'multipart';
|
||||
expiresInSeconds: number;
|
||||
multipartPartSize?: number;
|
||||
}
|
||||
|
||||
export interface SignedUploadPart {
|
||||
partNumber: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SignedUploadResult {
|
||||
uploadMode: 'single' | 'multipart';
|
||||
expiresAt: number;
|
||||
url?: string;
|
||||
multipartUploadId?: string;
|
||||
multipartPartSize?: number;
|
||||
multipartPartCount?: number;
|
||||
multipartPartUrls?: SignedUploadPart[];
|
||||
}
|
||||
|
||||
export interface MultipartCompletePart {
|
||||
partNumber: number;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface MultipartCompleteInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
multipartUploadId: string;
|
||||
parts: MultipartCompletePart[];
|
||||
}
|
||||
|
||||
export interface SignedMultipartPartUrlsInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
multipartUploadId: string;
|
||||
partNumbers: number[];
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
export interface ServerUploadInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
contentType: string;
|
||||
body: Buffer | Uint8Array | string | Readable;
|
||||
contentLength?: number;
|
||||
sizeHint?: number;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { FSEntryCreateInput } from '../types/FSEntry.js';
|
||||
|
||||
export interface FSEntryRow {
|
||||
id: number;
|
||||
uuid: string;
|
||||
bucket: string | null;
|
||||
bucket_region: string | null;
|
||||
public_token: string | null;
|
||||
file_request_token: string | null;
|
||||
is_shortcut: number | boolean;
|
||||
shortcut_to: number | null;
|
||||
user_id: number;
|
||||
parent_id: number | null;
|
||||
parent_uid: string | null;
|
||||
associated_app_id: number | null;
|
||||
is_dir: number | boolean;
|
||||
layout: string | null;
|
||||
sort_by: 'name' | 'modified' | 'type' | 'size' | null;
|
||||
sort_order: 'asc' | 'desc' | null;
|
||||
is_public: number | boolean | null;
|
||||
thumbnail: string | null;
|
||||
immutable: number | boolean;
|
||||
name: string;
|
||||
metadata: string | null;
|
||||
modified: number;
|
||||
created: number | null;
|
||||
accessed: number | null;
|
||||
size: number | null;
|
||||
symlink_path: string | null;
|
||||
is_symlink: number | boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEntryWrite {
|
||||
index: number;
|
||||
input: FSEntryCreateInput;
|
||||
userId: number;
|
||||
targetPath: string;
|
||||
parentPath: string;
|
||||
fileName: string;
|
||||
metadataJson: string | null;
|
||||
bucket: string | null;
|
||||
bucketRegion: string | null;
|
||||
size: number;
|
||||
createPaths: boolean;
|
||||
}
|
||||
|
||||
export interface ReadEntriesByPathsOptions {
|
||||
useTryHardRead?: boolean;
|
||||
skipCache?: boolean;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { getAppIconUrl } from '@heyputer/backend/src/util/appIcon.js';
|
||||
|
||||
const clients = extension.import('client');
|
||||
|
||||
const ALLOWED_ORDER_BY = [
|
||||
'id',
|
||||
'name',
|
||||
'uid',
|
||||
'title',
|
||||
'installed_at',
|
||||
] as const;
|
||||
const ORDER_BY_FIELD_MAP: Record<string, string> = {
|
||||
id: 'apps.id',
|
||||
name: 'apps.name',
|
||||
uid: 'apps.uid',
|
||||
title: 'apps.title',
|
||||
installed_at: 'installed_at',
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true },
|
||||
async (req, res) => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id)
|
||||
throw new HttpError(401, 'Authentication required');
|
||||
|
||||
const orderBy = String(req.query.orderBy ?? 'installed_at');
|
||||
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid orderBy. Allowed: ${ALLOWED_ORDER_BY.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const page = Math.max(Number(req.query.page) || 1, 1);
|
||||
const limit = Math.min(
|
||||
Math.max(Number(req.query.limit) || 100, 1),
|
||||
100,
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
const orderByField = ORDER_BY_FIELD_MAP[orderBy];
|
||||
const sortDirection = req.query.desc ? 'DESC' : 'ASC';
|
||||
|
||||
const installedApps = (await clients.db.read(
|
||||
`SELECT
|
||||
apps.name,
|
||||
apps.uid,
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
WHERE perm.user_id = ?
|
||||
GROUP BY apps.id, apps.name, apps.uid, apps.title, apps.description
|
||||
ORDER BY ${orderByField} ${sortDirection}
|
||||
LIMIT ?
|
||||
OFFSET ?`,
|
||||
[actor.user.id, limit, offset],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => ({
|
||||
...app,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@puter/extension-controller",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-status-codes": "^2.3.0",
|
||||
"stripe": "^19.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.js';
|
||||
import { Request, Response } from 'express';
|
||||
import type { } from '../../../api.js';
|
||||
|
||||
const { Controller, Get, ExtensionController, HttpError } = extension.import('extensionController');
|
||||
|
||||
const getAppIconUrl = extension.import('core').util.helpers.get_app_icon_url;
|
||||
|
||||
@Controller('/installedApps')
|
||||
export class InstalledAppsController extends ExtensionController {
|
||||
|
||||
static ALLOWED_ORDER_BY = ['id', 'name', 'uid', 'title', 'installed_at'];
|
||||
static ORDER_BY_FIELD_MAP: Record<string, string> = {
|
||||
id: 'apps.id',
|
||||
name: 'apps.name',
|
||||
uid: 'apps.uid',
|
||||
title: 'apps.title',
|
||||
installed_at: 'installed_at',
|
||||
};
|
||||
#db: BaseDatabaseAccessService;
|
||||
constructor (db: BaseDatabaseAccessService) {
|
||||
super();
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
@Get('/', { subdomain: 'api' })
|
||||
async getInstalledApps (req: Request<null, null, null, { orderBy: string, desc: boolean, page: number, limit: number }>, res: Response): Promise<void> {
|
||||
const actor = req.actor;
|
||||
if ( ! actor ) {
|
||||
throw new HttpError(401, 'actor not found in context');
|
||||
}
|
||||
if ( actor.type.app ) {
|
||||
throw new HttpError(403, 'Apps are not allowed to access this resource');
|
||||
}
|
||||
req.query.orderBy ??= 'installed_at';
|
||||
if ( ! InstalledAppsController.ALLOWED_ORDER_BY.includes(req.query.orderBy) ) {
|
||||
throw new HttpError(400, `Invalid orderBy field. Allowed fields are: ${InstalledAppsController.ALLOWED_ORDER_BY.join(', ')}`);
|
||||
}
|
||||
|
||||
const page = Math.max(req.query.page || 1, 1);
|
||||
const limit = Math.min(Math.max(req.query.limit || 100, 1), 100);
|
||||
const offset = (page - 1) * limit;
|
||||
const orderByField = InstalledAppsController.ORDER_BY_FIELD_MAP[req.query.orderBy];
|
||||
const sortDirection = req.query.desc ? 'DESC' : 'ASC';
|
||||
|
||||
const installedApps = await this.#db.read(
|
||||
`SELECT
|
||||
apps.name,
|
||||
apps.uid,
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
WHERE perm.user_id = ?
|
||||
GROUP BY apps.id, apps.name, apps.uid, apps.title, apps.description
|
||||
ORDER BY ${orderByField} ${sortDirection}
|
||||
LIMIT ?
|
||||
OFFSET ?`,
|
||||
[actor.type.user.id, limit, offset],
|
||||
) as {
|
||||
name: string;
|
||||
uid: string;
|
||||
title: string;
|
||||
description: string;
|
||||
installed_at: Date;
|
||||
last_opened: Date | null;
|
||||
}[];
|
||||
|
||||
res.send(installedApps.map((app) => ({ ...app, iconUrl: getAppIconUrl(app) })));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { InstalledAppsController } from './controllers/InstalledAppsController.js';
|
||||
|
||||
const installedAppsController = new InstalledAppsController(extension.import('data').db);
|
||||
|
||||
installedAppsController.registerRoutes();
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"../src/*": [
|
||||
"../src/*"
|
||||
]
|
||||
},
|
||||
"allowJs": true,
|
||||
"checkJs": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*.js",
|
||||
"./**/*.d.ts"
|
||||
, "../src/backend/src/deprecated/filesystem/PuterS3StorageStrategy.js" ]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,31 +0,0 @@
|
||||
import { TeePromise } from 'teepromise';
|
||||
|
||||
export default class BaseOperation {
|
||||
static STATUS_PENDING = {};
|
||||
static STATUS_RUNNING = {};
|
||||
static STATUS_DONE = {};
|
||||
|
||||
/** @type {PromiseLike<void> & { resolve: () => void }} */
|
||||
#donePromise;
|
||||
|
||||
constructor () {
|
||||
this.status_ = this.constructor.STATUS_PENDING;
|
||||
this.#donePromise = new TeePromise();
|
||||
}
|
||||
get status () {
|
||||
return this.status_;
|
||||
}
|
||||
set status (status) {
|
||||
this.status_ = status;
|
||||
if ( status === this.constructor.STATUS_DONE ) {
|
||||
this.#donePromise.resolve();
|
||||
}
|
||||
}
|
||||
async awaitDone () {
|
||||
await this.#donePromise;
|
||||
}
|
||||
async onComplete (fn) {
|
||||
await this.#donePromise;
|
||||
fn();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1349 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user