From 0a5bdd6a7590d4fae7c9561191c080029ef29256 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Mon, 1 Dec 2025 08:20:32 +0100 Subject: [PATCH] I ran prettier format --- .github/copilot-instructions.md | 9 + .github/prompts/DocGen.prompt.md | 11 +- .github/workflows/ci.yml | 118 +- .github/workflows/publish-cli.yml | 52 +- .prettierrc | 4 + Backend/package.json | 2 +- Backend/pnpm-workspace.yaml | 12 +- .../prisma/migrations/0_init/migration.sql | 214 + Backend/prisma/schema.prisma | 1 - Backend/src/index.ts | 406 +- Backend/src/lib/Authentication.ts | 176 +- Backend/src/lib/Runner.ts | 1381 +++--- Backend/src/routes/api/account/accesstoken.ts | 524 ++- Backend/src/routes/api/account/getUserInfo.ts | 32 +- Backend/src/routes/api/account/guest.ts | 1034 ++--- Backend/src/routes/api/account/login.ts | 6 +- Backend/src/routes/api/account/manage.ts | 50 +- Backend/src/routes/api/account/register.ts | 65 +- Backend/src/routes/api/files.ts | 628 ++- Backend/src/routes/api/functions.ts | 3693 ++++++++--------- Backend/src/routes/api/namespaces.ts | 50 +- Backend/src/routes/api/storage.ts | 770 ++-- Backend/src/routes/api/triggers.ts | 500 +-- Backend/src/routes/health.ts | 14 +- Backend/src/routes/logout.ts | 2 +- Backend/tsconfig.json | 2 +- CLI/README.md | 132 +- CLI/index.js | 2092 +++++----- CLI/package.json | 50 +- README.md | 11 +- UI/package.json | 116 +- UI/pnpm-workspace.yaml | 6 +- UI/public/index.html | 36 +- UI/public/manifest.json | 26 +- UI/src/App.tsx | 31 +- UI/src/Routes.tsx | 464 +-- UI/src/components/buttons/ActionButton.tsx | 64 +- UI/src/components/buttons/MotionButtons.tsx | 113 +- UI/src/components/cards/CLICommandCard.tsx | 27 +- UI/src/components/cards/ConsoleCard.tsx | 249 +- UI/src/components/cards/FileManagerCard.tsx | 143 +- UI/src/components/cards/LogCard.tsx | 121 +- UI/src/components/cards/TimingCard.tsx | 129 +- UI/src/components/cards/TokenCard.tsx | 242 +- UI/src/components/cards/TriggersCard.tsx | 190 +- UI/src/components/modals/CreateFileModal.tsx | 45 +- .../components/modals/CreateFunctionModal.tsx | 1007 +++-- .../modals/CreateNamespaceModal.tsx | 28 +- .../components/modals/CreateTriggerModal.tsx | 411 +- UI/src/components/modals/DeleteFileModal.tsx | 29 +- .../components/modals/DeleteFunctionModal.tsx | 168 +- .../modals/DeleteNamespaceModal.tsx | 202 +- .../components/modals/DeleteTriggerModal.tsx | 161 +- UI/src/components/modals/EditTriggerModal.tsx | 399 +- UI/src/components/modals/GuestManagement.tsx | 273 +- UI/src/components/modals/Modal.tsx | 153 +- .../components/modals/RenameAccessToken.tsx | 161 +- UI/src/components/modals/RenameFileModal.tsx | 18 +- .../modals/RenameNamespaceModal.tsx | 33 +- UI/src/components/modals/TriggerLogCard.tsx | 424 +- UI/src/components/modals/TriggerLogsModal.tsx | 104 +- UI/src/components/modals/UpdateEnvModal.tsx | 16 +- .../components/modals/UpdateFunctionModal.tsx | 985 +++-- .../components/motion/ScrollProgressbar.tsx | 16 +- UI/src/index.css | 35 +- UI/src/index.tsx | 7 +- UI/src/pages/AccessTokens.tsx | 608 ++- UI/src/pages/Account.tsx | 695 ++-- UI/src/pages/Guest-Access.tsx | 177 +- UI/src/pages/GuestUsers.tsx | 904 ++-- UI/src/pages/Storage.tsx | 514 ++- UI/src/pages/docs/access-tokens.tsx | 253 +- UI/src/pages/docs/cli.tsx | 284 +- UI/src/pages/docs/custom-cors.tsx | 179 +- UI/src/pages/docs/custom-responses.tsx | 168 +- UI/src/pages/docs/data-passing.tsx | 325 +- UI/src/pages/docs/db-com.tsx | 204 +- UI/src/pages/docs/docker-mount.tsx | 221 +- UI/src/pages/docs/environment-variables.tsx | 217 +- UI/src/pages/docs/execution-alias.tsx | 217 +- UI/src/pages/docs/ffmpeg-install.tsx | 307 +- UI/src/pages/docs/getting-started.tsx | 427 +- UI/src/pages/docs/guest-users.tsx | 197 +- UI/src/pages/docs/my-first-function.tsx | 389 +- UI/src/pages/docs/persistent-data.tsx | 194 +- UI/src/pages/docs/raw-body.tsx | 159 +- UI/src/pages/docs/redirects.tsx | 169 +- UI/src/pages/docs/routing.tsx | 161 +- UI/src/pages/docs/secure-headers.tsx | 189 +- UI/src/pages/docs/serve-only.tsx | 145 +- UI/src/pages/docs/user-interfaces.tsx | 140 +- UI/src/pages/functions/FunctionDetail.tsx | 2446 ++++++----- UI/src/pages/functions/FunctionsList.tsx | 122 +- UI/src/pages/index/docs.tsx | 510 ++- UI/src/pages/index/index.tsx | 862 ++-- UI/src/pages/login/LoginPage.tsx | 338 +- UI/src/pages/register/RegisterPage.tsx | 375 +- UI/src/services/backend.accesstokens.ts | 118 +- UI/src/services/backend.account.ts | 88 +- UI/src/services/backend.files.ts | 54 +- UI/src/services/backend.functions.ts | 546 ++- UI/src/services/backend.guest.ts | 246 +- UI/src/services/backend.namespaces.ts | 53 +- UI/src/services/backend.storage.ts | 153 +- UI/src/services/backend.triggers.ts | 166 +- UI/src/types/Prisma.ts | 232 +- UI/src/utils/TextScramble.ts | 136 +- UI/tailwind.config.js | 49 +- UI/tsconfig.json | 44 +- docker-compose.yml | 98 +- 110 files changed, 16733 insertions(+), 16019 deletions(-) create mode 100644 .prettierrc create mode 100644 Backend/prisma/migrations/0_init/migration.sql diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b2343e6..3d07970 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,9 @@ # Copilot Instructions for SHSF +SHSF (Self-Hostable Serverless Functions) is an open-source platform for deploying and managing serverless functions on your own infrastructure. It consists of a backend API server, a web-based UI dashboard, and a CLI tool for interaction. + ## Project Overview + - **SHSF** is a self-hostable serverless functions platform with three main components: - **Backend/**: Node.js/TypeScript API server (uses Prisma for DB, provides REST endpoints, manages functions, triggers, storage, and authentication) - **UI/**: React + Tailwind web dashboard for managing functions, tokens, storage, and more @@ -8,6 +11,7 @@ - Designed for easy deployment via Docker Compose (`docker-compose.yml`), with environment config in `.env`/`example.env`. ## Key Workflows + - **Start all services**: `docker-compose up -d` (from repo root) - **Access UI**: http://localhost:3000 (default) - **First user to register becomes admin** @@ -16,6 +20,7 @@ - **Prisma DB**: Schema in `Backend/prisma/schema.prisma`. Use `pnpm prisma migrate dev` in `Backend/` for DB changes. ## Patterns & Conventions + - **Functions**: User functions are managed via the UI and stored/executed by the backend. Example Python function signature: ```python def main(args): @@ -29,15 +34,18 @@ - **Testing**: No explicit test framework found; manual testing via UI/CLI is typical. ## Integration & Extensibility + - **Add new backend features**: Implement in `Backend/src/routes/` and update UI/CLI as needed. - **External dependencies**: Managed via `pnpm` (see `package.json` in each package) - **Docker**: All services are containerized; update `docker-compose.yml` for orchestration changes. ## Examples + - **Create a new API route**: Add a file to `Backend/src/routes/`, export a handler, it auto registers in the server. - **Add a UI card**: Create a new component in `UI/src/components/cards/` and use it in a page. ## References + - [Backend/README.md] for backend/server details - [UI/README.md] for UI details (if present) - [CLI/README.md] for CLI usage and options @@ -45,4 +53,5 @@ - [docker-compose.yml] for service orchestration --- + For questions about project-specific patterns, check the relevant README or source file. When in doubt, follow the structure and conventions of existing code. When still unsure, always ask for clarification! diff --git a/.github/prompts/DocGen.prompt.md b/.github/prompts/DocGen.prompt.md index b058927..63879e9 100644 --- a/.github/prompts/DocGen.prompt.md +++ b/.github/prompts/DocGen.prompt.md @@ -2,11 +2,13 @@ mode: agent description: Generate a Document for a Feature or Topic model: GPT-4.1 (copilot) -tools: ['edit', 'search', 'usages', 'problems', 'changes'] +tools: ["edit", "search", "usages", "problems", "changes"] --- + You've been provided with the Task of Generating a Document. ## How to write a good doc + - When formatting code, use a proper background, proper indentation, and appropriate syntax highlighting for readability. - Structure your document with clear headings and subheadings to organize content logically. - Use concise and precise language to explain concepts, avoiding unnecessary jargon. @@ -15,11 +17,13 @@ You've been provided with the Task of Generating a Document. - Proofread your document for grammar, spelling, and clarity. - Add warning or note boxes for important information that users should be aware of. - ## How to add a Doc + PRE-WRITING: + - Take a look at older docs, see how they are structured and formatted. - Identify the last doc in the sequence to link from it to the new doc. + 1. Write the within the UI/src/pages/docs directory 2. Add it as a Route within UI/src/Routes.tsx 3. Add a entry in the lessons const list within UI/src/pages/index/docs.tsx @@ -27,5 +31,6 @@ PRE-WRITING: 5. To the new doc add the message back to the previous doc that the user should keep their instance updated as there is no new doc after the new one yet. ## Task + Ask for the information, feature and specifics you need to generate a good document about. -If told to "Check the Changes" or "Look at the Changes", use the changes tool to find out what has changed in the codebase recently. \ No newline at end of file +If told to "Check the Changes" or "Look at the Changes", use the changes tool to find out what has changed in the codebase recently. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ca3ad8..e6428db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,66 +1,66 @@ name: Build Test run-name: Build Test Workflow on: - pull_request: - branches: - - main - push: - branches: - - main - paths: - - 'Backend/**' - - 'UI/**' + pull_request: + branches: + - main + push: + branches: + - main + paths: + - "Backend/**" + - "UI/**" jobs: - compose_validation: - name: Validate Docker Compose - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Install Docker Compose - run: | - sudo apt-get update - sudo apt-get install docker-compose - - name: Set up Docker Build - run: touch .env - - name: Validate Docker Compose files - run: docker-compose config + compose_validation: + name: Validate Docker Compose + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Docker Compose + run: | + sudo apt-get update + sudo apt-get install docker-compose + - name: Set up Docker Build + run: touch .env + - name: Validate Docker Compose files + run: docker-compose config - build_backend: - name: Build Backend - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '22' - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 7 - - name: Install dependencies - run: cd Backend && pnpm install - - name: Build Backend - run: cd Backend && pnpm build + build_backend: + name: Build Backend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "22" + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + - name: Install dependencies + run: cd Backend && pnpm install + - name: Build Backend + run: cd Backend && pnpm build - build_frontend: - name: Build Frontend - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '22' - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 7 - - name: Install dependencies - run: cd UI && pnpm install - - name: Build Frontend - run: cd UI && CI=false && pnpm build + build_frontend: + name: Build Frontend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "22" + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + - name: Install dependencies + run: cd UI && pnpm install + - name: Build Frontend + run: cd UI && CI=false && pnpm build diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 3c404e1..1a9454c 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -1,34 +1,34 @@ name: Publish to npm on: - push: - branches: - - main - paths: - - "CLI/**" - workflow_dispatch: + push: + branches: + - main + paths: + - "CLI/**" + workflow_dispatch: jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "22.x" - registry-url: "https://registry.npmjs.org/" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + registry-url: "https://registry.npmjs.org/" - - name: Install dependencies - run: | - cd CLI - npm i + - name: Install dependencies + run: | + cd CLI + npm i - - name: Publish package - run: | - cd CLI - npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish package + run: | + cd CLI + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..79a1682 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 1, + "useTabs": true +} diff --git a/Backend/package.json b/Backend/package.json index 5db89d4..4d24c3d 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -33,4 +33,4 @@ "dev": "rm -rf dist && esbuild `find src \\( -name '*.ts' -o -name '*.tsx' \\)` --platform='node' --sourcemap --ignore-annotations --format='cjs' --target='es2022' --outdir='dist' && cd dist && node index.js && cd ..", "test": "npm run build" } -} \ No newline at end of file +} diff --git a/Backend/pnpm-workspace.yaml b/Backend/pnpm-workspace.yaml index 09a5c22..a3a0342 100644 --- a/Backend/pnpm-workspace.yaml +++ b/Backend/pnpm-workspace.yaml @@ -1,7 +1,7 @@ onlyBuiltDependencies: - - '@prisma/client' - - '@prisma/engines' - - esbuild - - prisma - - protobufjs - - ssh2 + - "@prisma/client" + - "@prisma/engines" + - esbuild + - prisma + - protobufjs + - ssh2 diff --git a/Backend/prisma/migrations/0_init/migration.sql b/Backend/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..340990b --- /dev/null +++ b/Backend/prisma/migrations/0_init/migration.sql @@ -0,0 +1,214 @@ +-- CreateTable +CREATE TABLE `User` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `displayName` VARCHAR(128) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `role` ENUM('Admin', 'User') NOT NULL DEFAULT 'User', + `avatar_url` VARCHAR(256) NULL, + `password` VARCHAR(256) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Session` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `hash` TEXT NOT NULL, + `userId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Function` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(128) NOT NULL, + `description` VARCHAR(256) NOT NULL, + `image` VARCHAR(256) NOT NULL, + `executionId` VARCHAR(256) NOT NULL, + `executionAlias` VARCHAR(128) NULL, + `userId` INTEGER NOT NULL, + `max_ram` INTEGER NOT NULL DEFAULT 512, + `timeout` INTEGER NOT NULL DEFAULT 15, + `allow_http` BOOLEAN NOT NULL DEFAULT true, + `env` TEXT NULL, + `secure_header` VARCHAR(256) NULL, + `retry_on_failure` BOOLEAN NOT NULL DEFAULT false, + `max_retries` INTEGER NOT NULL DEFAULT 3, + `tags` TEXT NULL, + `startup_file` VARCHAR(256) NULL, + `cors_origins` TEXT NULL, + `docker_mount` BOOLEAN NOT NULL DEFAULT false, + `ffmpeg_install` BOOLEAN NOT NULL DEFAULT false, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `lastRun` DATETIME(3) NULL, + `namespaceId` INTEGER NOT NULL, + + UNIQUE INDEX `Function_executionId_key`(`executionId`), + UNIQUE INDEX `Function_executionAlias_key`(`executionAlias`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `FunctionFile` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(256) NOT NULL, + `content` TEXT NOT NULL, + `functionId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Namespace` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(128) NOT NULL, + `userId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `FunctionTrigger` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(128) NOT NULL, + `description` VARCHAR(256) NOT NULL, + `cron` VARCHAR(128) NOT NULL, + `data` JSON NULL, + `enabled` BOOLEAN NOT NULL DEFAULT true, + `functionId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `lastRun` DATETIME(3) NULL, + `nextRun` DATETIME(3) NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `TriggerLog` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `functionId` INTEGER NOT NULL, + `result` TEXT NULL, + `logs` TEXT NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AccessToken` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `token` VARCHAR(256) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `purpose` VARCHAR(512) NULL, + `expiresAt` DATETIME(3) NULL, + `expired` BOOLEAN NOT NULL DEFAULT false, + `hidden` BOOLEAN NOT NULL DEFAULT false, + `userId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `AccessToken_token_key`(`token`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `FunctionStorage` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(128) NOT NULL, + `purpose` VARCHAR(256) NOT NULL, + `user` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `FunctionStorageItem` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `key` VARCHAR(256) NOT NULL, + `value` TEXT NOT NULL, + `storageId` INTEGER NOT NULL, + `expiresAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `GuestUser` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `displayName` VARCHAR(128) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `permittedFunctions` JSON NOT NULL, + `password_hash` VARCHAR(256) NOT NULL, + `guestOwnerId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `GuestUser_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `GuestSession` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `hash` TEXT NOT NULL, + `guestUserId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `expiresAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Function` ADD CONSTRAINT `Function_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Function` ADD CONSTRAINT `Function_namespaceId_fkey` FOREIGN KEY (`namespaceId`) REFERENCES `Namespace`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FunctionFile` ADD CONSTRAINT `FunctionFile_functionId_fkey` FOREIGN KEY (`functionId`) REFERENCES `Function`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Namespace` ADD CONSTRAINT `Namespace_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FunctionTrigger` ADD CONSTRAINT `FunctionTrigger_functionId_fkey` FOREIGN KEY (`functionId`) REFERENCES `Function`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `TriggerLog` ADD CONSTRAINT `TriggerLog_functionId_fkey` FOREIGN KEY (`functionId`) REFERENCES `Function`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AccessToken` ADD CONSTRAINT `AccessToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FunctionStorage` ADD CONSTRAINT `FunctionStorage_user_fkey` FOREIGN KEY (`user`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FunctionStorageItem` ADD CONSTRAINT `FunctionStorageItem_storageId_fkey` FOREIGN KEY (`storageId`) REFERENCES `FunctionStorage`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `GuestUser` ADD CONSTRAINT `GuestUser_guestOwnerId_fkey` FOREIGN KEY (`guestOwnerId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `GuestSession` ADD CONSTRAINT `GuestSession_guestUserId_fkey` FOREIGN KEY (`guestUserId`) REFERENCES `GuestUser`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/Backend/prisma/schema.prisma b/Backend/prisma/schema.prisma index 59ab4c6..8c40520 100644 --- a/Backend/prisma/schema.prisma +++ b/Backend/prisma/schema.prisma @@ -17,7 +17,6 @@ model User { displayName String @db.VarChar(128) email String @unique role UserRole @default(User) - avatar_url String? @db.VarChar(256) password String? @db.VarChar(256) diff --git a/Backend/src/index.ts b/Backend/src/index.ts index 0754c4d..c53b7a6 100644 --- a/Backend/src/index.ts +++ b/Backend/src/index.ts @@ -13,9 +13,9 @@ export const COOKIE = "shsf_session"; export const DOMAIN = env.DOMAIN!; export const API_KEY_HEADER = "x-access-key"; export const prisma = new PrismaClient({ - log: ["info", "error", "warn"], - errorFormat: "pretty", - transactionOptions: { timeout: 30000, maxWait: 20000 }, + log: ["info", "error", "warn"], + errorFormat: "pretty", + transactionOptions: { timeout: 30000, maxWait: 20000 }, }); const CORS_DOMAINS = env.CORS_URLS!.split(","); @@ -24,248 +24,246 @@ CORS_DOMAINS.push(REACT_APP_API_URL.replace(/\/+$/, "")); // Remove trailing sla console.log(CORS_DOMAINS); console.log( - `Im reachable on ${env.PORT}; For Example: ${env.REACT_APP_API_URL}` + `Im reachable on ${env.PORT}; For Example: ${env.REACT_APP_API_URL}`, ); export const API_URL = env.REACT_APP_API_URL; if (!API_URL) { - throw new Error("REACT_APP_API_URL is not defined in environment variables"); + throw new Error("REACT_APP_API_URL is not defined in environment variables"); } CORS_DOMAINS.push(API_URL); // Middleware Definition export const middleware = new Middleware<{}, {}>("Custom Cors", "1.0.3") - .load((config) => { - console.log(`Custom Cors Locked and Loaded`); - }) - .httpRequest(async (config, server, context, ctr, end) => { - console.log( - `[SHSF API] ${ctr.client.ip} [${ctr.url.method}]➡️ ${ctr.url.href}` - ); + .load((config) => { + console.log(`Custom Cors Locked and Loaded`); + }) + .httpRequest(async (config, server, context, ctr, end) => { + console.log( + `[SHSF API] ${ctr.client.ip} [${ctr.url.method}]➡️ ${ctr.url.href}`, + ); - // Check CORS - const origin = ctr.headers.get("origin"); + // Check CORS + const origin = ctr.headers.get("origin"); - // Validate origin first, regardless of method - if (origin && !CORS_DOMAINS.includes(origin)) { - let allowRequest = false; - console.log( - `[CORS MIDDLEWARE] Policy (Provisional): This origin is not allowed access - ${origin}` - ); + // Validate origin first, regardless of method + if (origin && !CORS_DOMAINS.includes(origin)) { + let allowRequest = false; + console.log( + `[CORS MIDDLEWARE] Policy (Provisional): This origin is not allowed access - ${origin}`, + ); - // Check if its an exec request - if (ctr.url.path.startsWith("/api/exec/")) { - // /api/exec/4/02df8773-1d03-48df-9dd7-fd452c5ba592 - console.log( - `[CORS MIDDLEWARE] Custom CORS might change the outcome of this request. (Function Execution Detected)` - ); + // Check if its an exec request + if (ctr.url.path.startsWith("/api/exec/")) { + // /api/exec/4/02df8773-1d03-48df-9dd7-fd452c5ba592 + console.log( + `[CORS MIDDLEWARE] Custom CORS might change the outcome of this request. (Function Execution Detected)`, + ); - const execId = ctr.url.path.split("/")[4]; // UUID - const func = await prisma.function.findFirst({ - where: { executionId: execId }, - }); - if (func && func.cors_origins) { - const allowedOrigins = func.cors_origins - .split(",") - .map(o => o.trim()) - .filter(o => o.length > 0); - if (allowedOrigins.includes(origin)) { - console.log( - `[CORS MIDDLEWARE] Policy: Allowing access for ${origin} - ${execId}` - ); - allowRequest = true; - } - } else { - console.log( - `[CORS MIDDLEWARE] Policy: No specific origins found for function - ${execId}` - ); - } - } + const execId = ctr.url.path.split("/")[4]; // UUID + const func = await prisma.function.findFirst({ + where: { executionId: execId }, + }); + if (func && func.cors_origins) { + const allowedOrigins = func.cors_origins + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); + if (allowedOrigins.includes(origin)) { + console.log( + `[CORS MIDDLEWARE] Policy: Allowing access for ${origin} - ${execId}`, + ); + allowRequest = true; + } + } else { + console.log( + `[CORS MIDDLEWARE] Policy: No specific origins found for function - ${execId}`, + ); + } + } - if (!allowRequest) { - console.log( - `[CORS MIDDLEWARE] Policy (Final Decision): This origin is not allowed access - ${origin}` - ); - return end( - ctr.status(ctr.$status.FORBIDDEN).print({ - status: "FAILED", - message: "SERVER CORS Policy: This origin is not allowed access", - }) - ); - } - } + if (!allowRequest) { + console.log( + `[CORS MIDDLEWARE] Policy (Final Decision): This origin is not allowed access - ${origin}`, + ); + return end( + ctr.status(ctr.$status.FORBIDDEN).print({ + status: "FAILED", + message: "SERVER CORS Policy: This origin is not allowed access", + }), + ); + } + } - const allowedHeaders = - ctr.headers.get("access-control-request-headers") || "content-type, x-*"; - const allowedMethods = "GET, POST, PUT, DELETE, OPTIONS, PATCH"; - const allowCredentials = "true"; - const controlMaxAge = "86400"; + const allowedHeaders = + ctr.headers.get("access-control-request-headers") || "content-type, x-*"; + const allowedMethods = "GET, POST, PUT, DELETE, OPTIONS, PATCH"; + const allowCredentials = "true"; + const controlMaxAge = "86400"; - if (origin) { - if (ctr.url.method === "OPTIONS") { - ctr.headers.set("Access-Control-Max-Age", controlMaxAge); - ctr.headers.set("Content-Length", "0"); - ctr.headers.set("Access-Control-Allow-Origin", origin); - ctr.headers.set("Access-Control-Allow-Methods", allowedMethods); - ctr.headers.set("Vary", "Origin"); - ctr.headers.set("Access-Control-Allow-Headers", allowedHeaders); - ctr.headers.set("Access-Control-Allow-Credentials", allowCredentials); - console.log( - `[CORS MIDDLEWARE] Preflight handled for origin: ${origin}` - ); - return end(ctr.status(ctr.$status.NO_CONTENT).print("")); - } + if (origin) { + if (ctr.url.method === "OPTIONS") { + ctr.headers.set("Access-Control-Max-Age", controlMaxAge); + ctr.headers.set("Content-Length", "0"); + ctr.headers.set("Access-Control-Allow-Origin", origin); + ctr.headers.set("Access-Control-Allow-Methods", allowedMethods); + ctr.headers.set("Vary", "Origin"); + ctr.headers.set("Access-Control-Allow-Headers", allowedHeaders); + ctr.headers.set("Access-Control-Allow-Credentials", allowCredentials); + console.log(`[CORS MIDDLEWARE] Preflight handled for origin: ${origin}`); + return end(ctr.status(ctr.$status.NO_CONTENT).print("")); + } - ctr.headers.set("Access-Control-Allow-Origin", origin); - ctr.headers.set("Vary", "Origin"); - ctr.headers.set("Access-Control-Allow-Methods", allowedMethods); - ctr.headers.set("Access-Control-Allow-Headers", allowedHeaders); - ctr.headers.set("Access-Control-Allow-Credentials", allowCredentials); - } - }) - .export(); + ctr.headers.set("Access-Control-Allow-Origin", origin); + ctr.headers.set("Vary", "Origin"); + ctr.headers.set("Access-Control-Allow-Methods", allowedMethods); + ctr.headers.set("Access-Control-Allow-Headers", allowedHeaders); + ctr.headers.set("Access-Control-Allow-Credentials", allowCredentials); + } + }) + .export(); export const server = new Server( - Runtime, - { - port: parseInt(env.PORT!), - bind: "0.0.0.0", - version: false, - performance: { lastModified: false, eTag: false }, - logging: { warn: true, debug: false, error: true }, - proxy: { - enabled: true, - credentials: { - authenticate: false, - }, - ips: { - validate: true, - list: [new network.Subnet("192.168.32.0/24")], - }, - }, - }, - [middleware.use({})] + Runtime, + { + port: parseInt(env.PORT!), + bind: "0.0.0.0", + version: false, + performance: { lastModified: false, eTag: false }, + logging: { warn: true, debug: false, error: true }, + proxy: { + enabled: true, + credentials: { + authenticate: false, + }, + ips: { + validate: true, + list: [new network.Subnet("192.168.32.0/24")], + }, + }, + }, + [middleware.use({})], ); export const fileRouter = new server.FileLoader("/") - .load("./routes", { fileBasedRouting: false }) - .export(); + .load("./routes", { fileBasedRouting: false }) + .export(); server.notFound(async (ctr) => { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "The requested resource was not found", - }); + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "The requested resource was not found", + }); }); server - .start() - .then(async (port) => { - await prisma.$connect(); + .start() + .then(async (port) => { + await prisma.$connect(); - console.log(`[SHSF API] Running on ${port}`); + console.log(`[SHSF API] Running on ${port}`); - setInterval(async () => { - await processCrons(); - }, 1000); // Every second - }) - .catch(console.error); + setInterval(async () => { + await processCrons(); + }, 1000); // Every second + }) + .catch(console.error); server.error("httpRequest", async (ctr, error) => { - console.error(error); - ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ - status: "ERROR", - message: "An Unknown Server Error has occurred", - }); + console.error(error); + ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + status: "ERROR", + message: "An Unknown Server Error has occurred", + }); }); // Crons async function processCrons() { - const now = new Date(); - const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000); + const now = new Date(); + const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000); - const crons = await prisma.functionTrigger.findMany({ - where: { - OR: [ - { - nextRun: { - gte: now, - lte: fiveMinutesFromNow, - }, - }, - { - nextRun: null, - }, - ], - enabled: true, - }, - include: { - function: true, - }, - }); + const crons = await prisma.functionTrigger.findMany({ + where: { + OR: [ + { + nextRun: { + gte: now, + lte: fiveMinutesFromNow, + }, + }, + { + nextRun: null, + }, + ], + enabled: true, + }, + include: { + function: true, + }, + }); - for (const cron of crons) { - const interval = CronExpressionParser.parse(cron.cron!, { - currentDate: now, - }); + for (const cron of crons) { + const interval = CronExpressionParser.parse(cron.cron!, { + currentDate: now, + }); - try { - // If nextRun is null, calculate and set it - if (cron.nextRun === null) { - const next = interval.next().toDate(); - await prisma.functionTrigger.update({ - where: { id: cron.id }, - data: { nextRun: next }, - }); - console.log(`Cron #${cron.id} nextRun set to ${next.toISOString()}`); - continue; // Skip further processing for this iteration - } + try { + // If nextRun is null, calculate and set it + if (cron.nextRun === null) { + const next = interval.next().toDate(); + await prisma.functionTrigger.update({ + where: { id: cron.id }, + data: { nextRun: next }, + }); + console.log(`Cron #${cron.id} nextRun set to ${next.toISOString()}`); + continue; // Skip further processing for this iteration + } - const next = interval.next(); + const next = interval.next(); - // Adjusted logic to ensure the cron fires correctly - if (next.getTime() <= now.getTime() + 1000) { - await prisma.functionTrigger.update({ - where: { id: cron.id }, - data: { - lastRun: now, - nextRun: interval.next().toDate(), // Update nextRun to the following occurrence - }, - }); + // Adjusted logic to ensure the cron fires correctly + if (next.getTime() <= now.getTime() + 1000) { + await prisma.functionTrigger.update({ + where: { id: cron.id }, + data: { + lastRun: now, + nextRun: interval.next().toDate(), // Update nextRun to the following occurrence + }, + }); - console.log(`[SHSF CRONS] Cron #${cron.id} executed`); - const files = await prisma.functionFile.findMany({ - where: { functionId: cron.functionId }, - }); + console.log(`[SHSF CRONS] Cron #${cron.id} executed`); + const files = await prisma.functionFile.findMany({ + where: { functionId: cron.functionId }, + }); - await executeFunction( - cron.functionId, - cron.function, - files, - { - enabled: false, - }, - JSON.stringify(cron.data) - ); + await executeFunction( + cron.functionId, + cron.function, + files, + { + enabled: false, + }, + JSON.stringify(cron.data), + ); - console.log( - `[SHSF CRONS] Function for Cron #${cron.id} executed successfully.` - ); - } else { - const secondsUntilNextRun = Math.round( - (next.getTime() - now.getTime()) / 1000 - ); + console.log( + `[SHSF CRONS] Function for Cron #${cron.id} executed successfully.`, + ); + } else { + const secondsUntilNextRun = Math.round( + (next.getTime() - now.getTime()) / 1000, + ); - if (secondsUntilNextRun <= 5) { - console.log( - `[SHSF CRONS] Cron #${cron.id} will run in ${secondsUntilNextRun} seconds` - ); - } - } - } catch (error) { - console.error( - `[SHSF CRONS] Error processing cron ${cron.name} (${cron.id}):`, - error - ); - } - } + if (secondsUntilNextRun <= 5) { + console.log( + `[SHSF CRONS] Cron #${cron.id} will run in ${secondsUntilNextRun} seconds`, + ); + } + } + } catch (error) { + console.error( + `[SHSF CRONS] Error processing cron ${cron.name} (${cron.id}):`, + error, + ); + } + } } diff --git a/Backend/src/lib/Authentication.ts b/Backend/src/lib/Authentication.ts index 25d0e0a..82d5d98 100644 --- a/Backend/src/lib/Authentication.ts +++ b/Backend/src/lib/Authentication.ts @@ -2,101 +2,101 @@ import { AccessToken, Session, User } from "@prisma/client"; import { prisma } from ".."; export async function checkAuthentication( - sessionHash: Session["hash"] | null, - apiKey: string | null, + sessionHash: Session["hash"] | null, + apiKey: string | null, ): Promise< - | { - success: true; - method: "session"; - user: User; - session: Session; - } - | { - success: true; - method: "apiKey"; - user: User; - apiKey: AccessToken; - } - | { - success: false; - message: string; - method: "none"; - } + | { + success: true; + method: "session"; + user: User; + session: Session; + } + | { + success: true; + method: "apiKey"; + user: User; + apiKey: AccessToken; + } + | { + success: false; + message: string; + method: "none"; + } > { - if (!sessionHash && !apiKey) { - return { - success: false, - message: "No authentication data provided", - method: "none", - }; - } + if (!sessionHash && !apiKey) { + return { + success: false, + message: "No authentication data provided", + method: "none", + }; + } - if (sessionHash) { - const session = await prisma.session.findFirst({ - where: { hash: sessionHash }, - include: { user: true }, - }); + if (sessionHash) { + const session = await prisma.session.findFirst({ + where: { hash: sessionHash }, + include: { user: true }, + }); - if (!session) { - return { - success: false, - message: "Invalid session", - method: "none", - }; - } + if (!session) { + return { + success: false, + message: "Invalid session", + method: "none", + }; + } - return { - success: true, - method: "session", - user: session.user, - session: session, - }; - } else if (apiKey) { - const apiKeyRecord = await prisma.accessToken.findFirst({ - where: { token: apiKey }, - include: { user: true }, - }); + return { + success: true, + method: "session", + user: session.user, + session: session, + }; + } else if (apiKey) { + const apiKeyRecord = await prisma.accessToken.findFirst({ + where: { token: apiKey }, + include: { user: true }, + }); - if (!apiKeyRecord) { - return { - success: false, - message: "Invalid API key", - method: "none", - }; - } + if (!apiKeyRecord) { + return { + success: false, + message: "Invalid API key", + method: "none", + }; + } - // Expiration Check - if (apiKeyRecord.expiresAt && apiKeyRecord.expiresAt < new Date()) { - await prisma.accessToken.update({ - where: { id: apiKeyRecord.id }, - data: { expired: true }, - }); - return { - success: false, - message: "API key has expired", - method: "none", - }; - } + // Expiration Check + if (apiKeyRecord.expiresAt && apiKeyRecord.expiresAt < new Date()) { + await prisma.accessToken.update({ + where: { id: apiKeyRecord.id }, + data: { expired: true }, + }); + return { + success: false, + message: "API key has expired", + method: "none", + }; + } - if (apiKeyRecord.expired) { - return { - success: false, - message: "API key has expired", - method: "none", - }; - } + if (apiKeyRecord.expired) { + return { + success: false, + message: "API key has expired", + method: "none", + }; + } - return { - success: true, - method: "apiKey", - user: apiKeyRecord.user, - apiKey: apiKeyRecord, - }; - } else { - return { - success: false, - message: "No data provided to authenticate", - method: "none", - }; - } + return { + success: true, + method: "apiKey", + user: apiKeyRecord.user, + apiKey: apiKeyRecord, + }; + } else { + return { + success: false, + message: "No data provided to authenticate", + method: "none", + }; + } } diff --git a/Backend/src/lib/Runner.ts b/Backend/src/lib/Runner.ts index 05f9b7e..fd578ad 100644 --- a/Backend/src/lib/Runner.ts +++ b/Backend/src/lib/Runner.ts @@ -12,9 +12,9 @@ import { Readable } from "stream"; import { randomBytes } from "crypto"; interface TimingEntry { - timestamp: number; - value: number; - description: string; + timestamp: number; + value: number; + description: string; } // Token expiry for execution tokens (in milliseconds) @@ -237,110 +237,106 @@ def database() -> Database: # You can also use: db = Database()`; export async function executeFunction( - id: number, - functionData: Function, - files: FunctionFile[], - stream: - | { enabled: true; onChunk: (data: string) => void } - | { enabled: false }, - payload: string + id: number, + functionData: Function, + files: FunctionFile[], + stream: + | { enabled: true; onChunk: (data: string) => void } + | { enabled: false }, + payload: string, ) { - const starting_time = Date.now(); - const tooks: TimingEntry[] = []; - let func_result: string = ""; // Stores the JSON string result from the function - let logs: string = ""; // Stores logs from the function execution + const starting_time = Date.now(); + const tooks: TimingEntry[] = []; + let func_result: string = ""; // Stores the JSON string result from the function + let logs: string = ""; // Stores logs from the function execution - const recordTiming = (() => { - let lastTimestamp = starting_time; - return (description: string) => { - const currentTimestamp = Date.now(); - const value = (currentTimestamp - lastTimestamp) / 1000; - tooks.push({ timestamp: currentTimestamp, value, description }); - console.log(`[SHSF CRONS] ${description}: ${value.toFixed(3)} seconds`); - }; - })(); + const recordTiming = (() => { + let lastTimestamp = starting_time; + return (description: string) => { + const currentTimestamp = Date.now(); + const value = (currentTimestamp - lastTimestamp) / 1000; + tooks.push({ timestamp: currentTimestamp, value, description }); + console.log(`[SHSF CRONS] ${description}: ${value.toFixed(3)} seconds`); + }; + })(); - // Serve Only HTML (serve-only) - if (functionData.startup_file?.endsWith(".html")) { - return { - logs: "Serve Only HTML function executed.", - result: { - _shsf: "v2", - _headers: { "Content-Type": "text/html; charset=utf-8" }, - _code: 200, - _res: - files.find((f) => f.name === functionData.startup_file)?.content || - ServeOnlyFileNotFoundHTML, - }, - tooks: [ - { - description: "Serve Only HTML function executed.", - value: 0, - timestamp: starting_time, - }, - ] as TimingEntry[], - exit_code: 0, - }; - } + // Serve Only HTML (serve-only) + if (functionData.startup_file?.endsWith(".html")) { + return { + logs: "Serve Only HTML function executed.", + result: { + _shsf: "v2", + _headers: { "Content-Type": "text/html; charset=utf-8" }, + _code: 200, + _res: + files.find((f) => f.name === functionData.startup_file)?.content || + ServeOnlyFileNotFoundHTML, + }, + tooks: [ + { + description: "Serve Only HTML function executed.", + value: 0, + timestamp: starting_time, + }, + ] as TimingEntry[], + exit_code: 0, + }; + } - const docker = new Docker(); - let dbAccessToken = ""; - const functionIdStr = String(functionData.id); - const containerName = `shsf_func_${functionIdStr}`; - // Persistent directory on the host for this function's app files - const funcAppDir = path.join( - "/opt/shsf_data/functions", - functionIdStr, - "app" - ); - const runtimeType = functionData.image.split(":")[0]; - let exitCode = 0; // Default exit code + const docker = new Docker(); + let dbAccessToken = ""; + const functionIdStr = String(functionData.id); + const containerName = `shsf_func_${functionIdStr}`; + // Persistent directory on the host for this function's app files + const funcAppDir = path.join("/opt/shsf_data/functions", functionIdStr, "app"); + const runtimeType = functionData.image.split(":")[0]; + let exitCode = 0; // Default exit code - // Generate a unique execution ID for this request to avoid race conditions - // Use crypto.randomUUID() for better uniqueness if available, otherwise fallback - const executionId = - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; - const executionDir = path.join( - "/opt/shsf_data/functions", - functionIdStr, - "executions", - executionId - ); + // Generate a unique execution ID for this request to avoid race conditions + // Use crypto.randomUUID() for better uniqueness if available, otherwise fallback + const executionId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + const executionDir = path.join( + "/opt/shsf_data/functions", + functionIdStr, + "executions", + executionId, + ); - // Define startupFile and initScript here as they are needed for script generation - const startupFile = - functionData.startup_file || - (runtimeType === "python" ? "main.py" : "index.js"); - let initScript = - "#!/bin/sh\nset -e\necho '[SHSF INIT] Starting environment setup...'\ncd /app\n"; + // Define startupFile and initScript here as they are needed for script generation + const startupFile = + functionData.startup_file || + (runtimeType === "python" ? "main.py" : "index.js"); + let initScript = + "#!/bin/sh\nset -e\necho '[SHSF INIT] Starting environment setup...'\ncd /app\n"; - try { - let container = docker.getContainer(containerName); - let containerJustCreated = false; + try { + let container = docker.getContainer(containerName); + let containerJustCreated = false; - // Ensure function app directory exists - await fs.mkdir(funcAppDir, { recursive: true }); + // Ensure function app directory exists + await fs.mkdir(funcAppDir, { recursive: true }); - // Create unique execution directory for this request - await fs.mkdir(executionDir, { recursive: true }); - recordTiming("Created unique execution directory"); + // Create unique execution directory for this request + await fs.mkdir(executionDir, { recursive: true }); + recordTiming("Created unique execution directory"); - // Always update the user files regardless of container state - recordTiming("Updating function files"); - await Promise.all( - files.map(async (file) => { - const filePath = path.join(funcAppDir, file.name); - await fs.writeFile(filePath, file.content); - }) - ); - recordTiming("User files written to host app directory"); + // Always update the user files regardless of container state + recordTiming("Updating function files"); + await Promise.all( + files.map(async (file) => { + const filePath = path.join(funcAppDir, file.name); + await fs.writeFile(filePath, file.content); + }), + ); + recordTiming("User files written to host app directory"); - // Always generate/update the runner script to accept payload file path as argument - if (runtimeType === "python") { - const wrapperPath = path.join(funcAppDir, "_runner.py"); - const wrapperContent = `#!/bin/sh + // Always generate/update the runner script to accept payload file path as argument + if (runtimeType === "python") { + const wrapperPath = path.join(funcAppDir, "_runner.py"); + const wrapperContent = `#!/bin/sh # Source environment variables if the file exists if [ -f /app/.shsf_env ]; then . /app/.shsf_env @@ -434,22 +430,22 @@ finally: sys.stdout = original_stdout PYTHON_SCRIPT_EOF `; - await fs.writeFile(wrapperPath, wrapperContent); - await fs.chmod(wrapperPath, "755"); - recordTiming( - "Python runner script (_runner.py) written to host app directory" - ); - } else { - console.warn( - `[executeFunction] Runner script generation skipped: Unsupported runtime type '${runtimeType}' for function ${functionData.id}.` - ); - } + await fs.writeFile(wrapperPath, wrapperContent); + await fs.chmod(wrapperPath, "755"); + recordTiming( + "Python runner script (_runner.py) written to host app directory", + ); + } else { + console.warn( + `[executeFunction] Runner script generation skipped: Unsupported runtime type '${runtimeType}' for function ${functionData.id}.`, + ); + } - // Always generate/update the init.sh script - if (runtimeType === "python") { - // Add ffmpeg installation if requested - if (functionData.ffmpeg_install) { - initScript += ` + // Always generate/update the init.sh script + if (runtimeType === "python") { + // Add ffmpeg installation if requested + if (functionData.ffmpeg_install) { + initScript += ` echo "[SHSF INIT] Checking ffmpeg installation..." if [ ! -f ".already_installed_ffmpeg" ]; then command -v ffmpeg >/dev/null 2>&1 || (apt update && apt-get install -y ffmpeg && touch /app/.already_installed_ffmpeg) @@ -458,9 +454,9 @@ PYTHON_SCRIPT_EOF fi echo "[SHSF INIT] ffmpeg check complete." `; - } - - initScript += ` + } + + initScript += ` if [ -f "requirements.txt" ]; then echo "[SHSF INIT] Setting up Python environment for function ${functionData.id}" VENV_DIR="/pip-cache/venv/function-${functionData.id}" @@ -496,660 +492,649 @@ if [ -f "requirements.txt" ]; then fi echo "[SHSF INIT] Python setup complete." `; - } else { - // This was already checked for runner script, but as a safeguard for init.sh: - console.warn( - `[executeFunction] init.sh script generation skipped: Unsupported runtime type '${runtimeType}' for function ${functionData.id}.` - ); - // Potentially throw an error if an unsupported runtime should halt execution. - // throw new Error(`Unsupported runtime type for init script generation: ${runtimeType}`); - } - initScript += - "\necho '[SHSF INIT] Environment setup finished successfully.'\n"; - await fs.writeFile(path.join(funcAppDir, "init.sh"), initScript); - await fs.chmod(path.join(funcAppDir, "init.sh"), "755"); - recordTiming("init.sh script generated on host"); + } else { + // This was already checked for runner script, but as a safeguard for init.sh: + console.warn( + `[executeFunction] init.sh script generation skipped: Unsupported runtime type '${runtimeType}' for function ${functionData.id}.`, + ); + // Potentially throw an error if an unsupported runtime should halt execution. + // throw new Error(`Unsupported runtime type for init script generation: ${runtimeType}`); + } + initScript += + "\necho '[SHSF INIT] Environment setup finished successfully.'\n"; + await fs.writeFile(path.join(funcAppDir, "init.sh"), initScript); + await fs.chmod(path.join(funcAppDir, "init.sh"), "755"); + recordTiming("init.sh script generated on host"); - // Check if any file contains _db_com, and if so, setup DB communication - const requiresDbCom = files.some((file) => - file.content.includes("_db_com") - ); - if (requiresDbCom) { - // Generate a unique, short-lived access token for this execution - dbAccessToken = randomBytes(32).toString("hex"); - await prisma.accessToken.create({ - data: { - userId: functionData.userId, - name: `token_exec_${executionId}`, - token: dbAccessToken, - hidden: true, - purpose: `Short-lived access token for function execution ${executionId}`, - expiresAt: new Date(Date.now() + EXECUTION_TOKEN_EXPIRY_MS), // 10 minutes expiry - }, - }); - recordTiming("Short-lived access token for DB communication created"); + // Check if any file contains _db_com, and if so, setup DB communication + const requiresDbCom = files.some((file) => file.content.includes("_db_com")); + if (requiresDbCom) { + // Generate a unique, short-lived access token for this execution + dbAccessToken = randomBytes(32).toString("hex"); + await prisma.accessToken.create({ + data: { + userId: functionData.userId, + name: `token_exec_${executionId}`, + token: dbAccessToken, + hidden: true, + purpose: `Short-lived access token for function execution ${executionId}`, + expiresAt: new Date(Date.now() + EXECUTION_TOKEN_EXPIRY_MS), // 10 minutes expiry + }, + }); + recordTiming("Short-lived access token for DB communication created"); - // Add Database Communication Script (python) - const dbScript = DbComScript.replace("{{API}}", API_URL!).replace( - "{{AUTHKEY}}", - dbAccessToken - ); - await fs.writeFile(path.join(funcAppDir, "_db_com.py"), dbScript); - await fs.chmod(path.join(funcAppDir, "_db_com.py"), "755"); - recordTiming("Database communication script generated on host"); - } + // Add Database Communication Script (python) + const dbScript = DbComScript.replace("{{API}}", API_URL!).replace( + "{{AUTHKEY}}", + dbAccessToken, + ); + await fs.writeFile(path.join(funcAppDir, "_db_com.py"), dbScript); + await fs.chmod(path.join(funcAppDir, "_db_com.py"), "755"); + recordTiming("Database communication script generated on host"); + } - try { - const inspectInfo = await container.inspect(); - if (!inspectInfo.State.Running) { - recordTiming("Starting existing stopped container"); - await container.start(); - recordTiming("Container started"); - } else { - recordTiming("Found existing running container"); - } - } catch (error: any) { - if (error.statusCode === 404) { - // Container not found, create it - containerJustCreated = true; - recordTiming("Container not found, preparing for creation"); + try { + const inspectInfo = await container.inspect(); + if (!inspectInfo.State.Running) { + recordTiming("Starting existing stopped container"); + await container.start(); + recordTiming("Container started"); + } else { + recordTiming("Found existing running container"); + } + } catch (error: any) { + if (error.statusCode === 404) { + // Container not found, create it + containerJustCreated = true; + recordTiming("Container not found, preparing for creation"); - // Cache directories setup on host (ensure these base paths exist) - const baseCacheDir = "/opt/shsf_data/cache"; // Centralized cache on host - await fs.mkdir(baseCacheDir, { recursive: true }); - const pipCacheHost = path.join(baseCacheDir, "pip"); + // Cache directories setup on host (ensure these base paths exist) + const baseCacheDir = "/opt/shsf_data/cache"; // Centralized cache on host + await fs.mkdir(baseCacheDir, { recursive: true }); + const pipCacheHost = path.join(baseCacheDir, "pip"); - await Promise.all([fs.mkdir(pipCacheHost, { recursive: true })]); - recordTiming("Host cache directories ensured"); + await Promise.all([fs.mkdir(pipCacheHost, { recursive: true })]); + recordTiming("Host cache directories ensured"); - // Mount the base function directory which contains both app/ and executions/ - const funcBaseDir = path.join( - "/opt/shsf_data/functions", - functionIdStr - ); - // Mount /app and /executions separately instead of the old /function_data - let BINDS: string[] = [ - `${funcBaseDir}/app:/app`, - `${funcBaseDir}/executions:/executions`, - ]; + // Mount the base function directory which contains both app/ and executions/ + const funcBaseDir = path.join("/opt/shsf_data/functions", functionIdStr); + // Mount /app and /executions separately instead of the old /function_data + let BINDS: string[] = [ + `${funcBaseDir}/app:/app`, + `${funcBaseDir}/executions:/executions`, + ]; - if (functionData.docker_mount) { - BINDS.push("/var/run/docker.sock:/var/run/docker.sock"); // Mount Docker socket - } + if (functionData.docker_mount) { + BINDS.push("/var/run/docker.sock:/var/run/docker.sock"); // Mount Docker socket + } - if (runtimeType === "python") { - BINDS.push(`${pipCacheHost}:/pip-cache`); // Mount persistent pip cache - } else { - throw new Error( - `Unsupported runtime type for container BIND setup: ${runtimeType}` - ); - } + if (runtimeType === "python") { + BINDS.push(`${pipCacheHost}:/pip-cache`); // Mount persistent pip cache + } else { + throw new Error( + `Unsupported runtime type for container BIND setup: ${runtimeType}`, + ); + } - // Image pull logic (same as original) - const imageStart = Date.now(); - let imagePulled = false; - try { - const imageExists = await docker.listImages({ - filters: JSON.stringify({ reference: [functionData.image] }), - }); - if (imageExists.length === 0) { - imagePulled = true; - recordTiming("Pulling image: " + functionData.image); - const pullStream = await docker.pull(functionData.image); - await new Promise((resolve, reject) => { - docker.modem.followProgress(pullStream, (err) => - err ? reject(err) : resolve(null) - ); - }); - } - } catch (imgError) { - console.error("Error checking or pulling image:", imgError); - throw imgError; - } - recordTiming( - imagePulled ? "Image pull complete" : "Image check complete" - ); + // Image pull logic (same as original) + const imageStart = Date.now(); + let imagePulled = false; + try { + const imageExists = await docker.listImages({ + filters: JSON.stringify({ reference: [functionData.image] }), + }); + if (imageExists.length === 0) { + imagePulled = true; + recordTiming("Pulling image: " + functionData.image); + const pullStream = await docker.pull(functionData.image); + await new Promise((resolve, reject) => { + docker.modem.followProgress(pullStream, (err) => + err ? reject(err) : resolve(null), + ); + }); + } + } catch (imgError) { + console.error("Error checking or pulling image:", imgError); + throw imgError; + } + recordTiming(imagePulled ? "Image pull complete" : "Image check complete"); - const initialEnv = functionData.env - ? JSON.parse(functionData.env).map( - (env: { name: string; value: any }) => `${env.name}=${env.value}` - ) - : []; + const initialEnv = functionData.env + ? JSON.parse(functionData.env).map( + (env: { name: string; value: any }) => `${env.name}=${env.value}`, + ) + : []; - container = await docker.createContainer({ - Image: functionData.image, - name: containerName, - Env: initialEnv, - HostConfig: { - Binds: BINDS, - AutoRemove: false, // CRITICAL: Container is persistent - Memory: (functionData.max_ram || 128) * 1024 * 1024, - }, - // Run init.sh once, then keep container alive - Cmd: [ - "/bin/sh", - "-c", - "/app/init.sh && echo '[SHSF] Container initialized and idling.' && tail -f /dev/null", - ], - Tty: false, // No TTY needed for background container - }); - recordTiming("Container created"); - await container.start(); - recordTiming("New container started after init"); - } else { - // Some other error inspecting container - throw error; - } - } + container = await docker.createContainer({ + Image: functionData.image, + name: containerName, + Env: initialEnv, + HostConfig: { + Binds: BINDS, + AutoRemove: false, // CRITICAL: Container is persistent + Memory: (functionData.max_ram || 128) * 1024 * 1024, + }, + // Run init.sh once, then keep container alive + Cmd: [ + "/bin/sh", + "-c", + "/app/init.sh && echo '[SHSF] Container initialized and idling.' && tail -f /dev/null", + ], + Tty: false, // No TTY needed for background container + }); + recordTiming("Container created"); + await container.start(); + recordTiming("New container started after init"); + } else { + // Some other error inspecting container + throw error; + } + } - // At this point, container is running (either existing or newly created and initialized) - // Now, execute the function logic using docker exec + // At this point, container is running (either existing or newly created and initialized) + // Now, execute the function logic using docker exec - // Write payload to a unique file for this execution to avoid race conditions - const payloadFilePath = path.join(executionDir, "payload.json"); - await fs.writeFile(payloadFilePath, payload); - recordTiming("Payload written to unique execution file"); + // Write payload to a unique file for this execution to avoid race conditions + const payloadFilePath = path.join(executionDir, "payload.json"); + await fs.writeFile(payloadFilePath, payload); + recordTiming("Payload written to unique execution file"); - const execEnv: string[] = []; // Remove RUN_DATA from env - // Add function-specific env vars to exec as well, in case they are needed by the runner script directly - // and not just by the init.sh environment. - if (functionData.env) { - try { - const parsedEnv = JSON.parse(functionData.env); - if (Array.isArray(parsedEnv)) { - parsedEnv.forEach((envVar: { name: string; value: any }) => - execEnv.push(`${envVar.name}=${envVar.value}`) - ); - } - } catch (e) { - console.error("Failed to parse functionData.env for exec:", e); - } - } + const execEnv: string[] = []; // Remove RUN_DATA from env + // Add function-specific env vars to exec as well, in case they are needed by the runner script directly + // and not just by the init.sh environment. + if (functionData.env) { + try { + const parsedEnv = JSON.parse(functionData.env); + if (Array.isArray(parsedEnv)) { + parsedEnv.forEach((envVar: { name: string; value: any }) => + execEnv.push(`${envVar.name}=${envVar.value}`), + ); + } + } catch (e) { + console.error("Failed to parse functionData.env for exec:", e); + } + } - // Pass the unique payload file path as an argument to the runner script - const containerPayloadPath = `/executions/${executionId}/payload.json`; // Updated to use /executions mount - const execCmd = - runtimeType === "python" - ? ["/bin/sh", "/app/_runner.py", containerPayloadPath] - : (() => { - throw new Error( - `Unsupported runtime type for exec command: ${runtimeType}` - ); - })(); + // Pass the unique payload file path as an argument to the runner script + const containerPayloadPath = `/executions/${executionId}/payload.json`; // Updated to use /executions mount + const execCmd = + runtimeType === "python" + ? ["/bin/sh", "/app/_runner.py", containerPayloadPath] + : (() => { + throw new Error( + `Unsupported runtime type for exec command: ${runtimeType}`, + ); + })(); - const exec = await container.exec({ - Cmd: execCmd, - Env: execEnv, - AttachStdout: true, - AttachStderr: true, - Tty: false, - }); - recordTiming("Exec created"); + const exec = await container.exec({ + Cmd: execCmd, + Env: execEnv, + AttachStdout: true, + AttachStderr: true, + Tty: false, + }); + recordTiming("Exec created"); - const execStream = await exec.start({ hijack: true, stdin: false }); - recordTiming("Exec started"); + const execStream = await exec.start({ hijack: true, stdin: false }); + recordTiming("Exec started"); - const execOutput = { stdout: "", stderr: "" }; - const MAX_OUTPUT_SIZE = 3 * 1024 * 1024; // 3MB limit to stay under Docker's 4MB limit - let stdoutTruncated = false; - let stderrTruncated = false; + const execOutput = { stdout: "", stderr: "" }; + const MAX_OUTPUT_SIZE = 3 * 1024 * 1024; // 3MB limit to stay under Docker's 4MB limit + let stdoutTruncated = false; + let stderrTruncated = false; - const stdoutMultiplex = new PassThrough(); - const stderrMultiplex = new PassThrough(); + const stdoutMultiplex = new PassThrough(); + const stderrMultiplex = new PassThrough(); - stdoutMultiplex.on("data", (chunk) => { - const text = chunk.toString("utf8"); - if (execOutput.stdout.length + text.length <= MAX_OUTPUT_SIZE) { - execOutput.stdout += text; - } else if (!stdoutTruncated) { - const remaining = MAX_OUTPUT_SIZE - execOutput.stdout.length; - if (remaining > 0) { - execOutput.stdout += text.substring(0, remaining); - } - execOutput.stdout += - "\n[SHSF TRUNCATED] Output exceeded 3MB limit and was truncated"; - stdoutTruncated = true; - } - }); + stdoutMultiplex.on("data", (chunk) => { + const text = chunk.toString("utf8"); + if (execOutput.stdout.length + text.length <= MAX_OUTPUT_SIZE) { + execOutput.stdout += text; + } else if (!stdoutTruncated) { + const remaining = MAX_OUTPUT_SIZE - execOutput.stdout.length; + if (remaining > 0) { + execOutput.stdout += text.substring(0, remaining); + } + execOutput.stdout += + "\n[SHSF TRUNCATED] Output exceeded 3MB limit and was truncated"; + stdoutTruncated = true; + } + }); - stderrMultiplex.on("data", (chunk) => { - const text = chunk.toString("utf8"); - if (execOutput.stderr.length + text.length <= MAX_OUTPUT_SIZE) { - execOutput.stderr += text; - } else if (!stderrTruncated) { - const remaining = MAX_OUTPUT_SIZE - execOutput.stderr.length; - if (remaining > 0) { - execOutput.stderr += text.substring(0, remaining); - } - execOutput.stderr += - "\n[SHSF TRUNCATED] Logs exceeded 3MB limit and were truncated"; - stderrTruncated = true; - } + stderrMultiplex.on("data", (chunk) => { + const text = chunk.toString("utf8"); + if (execOutput.stderr.length + text.length <= MAX_OUTPUT_SIZE) { + execOutput.stderr += text; + } else if (!stderrTruncated) { + const remaining = MAX_OUTPUT_SIZE - execOutput.stderr.length; + if (remaining > 0) { + execOutput.stderr += text.substring(0, remaining); + } + execOutput.stderr += + "\n[SHSF TRUNCATED] Logs exceeded 3MB limit and were truncated"; + stderrTruncated = true; + } - if (stream.enabled && !stderrTruncated) { - const ansiRegex = /\x1B\[[0-9;]*[A-Za-z]/g; - const nonPrintableRegex = /[^\x20-\x7E\n\r\t]/g; - const cleanText = text - .replace(ansiRegex, "") - .replace(nonPrintableRegex, ""); - stream.onChunk(cleanText); - } - }); + if (stream.enabled && !stderrTruncated) { + const ansiRegex = /\x1B\[[0-9;]*[A-Za-z]/g; + const nonPrintableRegex = /[^\x20-\x7E\n\r\t]/g; + const cleanText = text + .replace(ansiRegex, "") + .replace(nonPrintableRegex, ""); + stream.onChunk(cleanText); + } + }); - docker.modem.demuxStream(execStream, stdoutMultiplex, stderrMultiplex); + docker.modem.demuxStream(execStream, stdoutMultiplex, stderrMultiplex); - const execTimeoutMs = (functionData.timeout || 15) * 1000; // functionData.timeout is in seconds + const execTimeoutMs = (functionData.timeout || 15) * 1000; // functionData.timeout is in seconds - const execPromise = new Promise( - (resolve, reject) => { - execStream.on("end", () => { - exec.inspect().then(resolve).catch(reject); - }); - execStream.on("error", reject); - } - ); + const execPromise = new Promise((resolve, reject) => { + execStream.on("end", () => { + exec.inspect().then(resolve).catch(reject); + }); + execStream.on("error", reject); + }); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error(`Execution timed out after ${execTimeoutMs / 1000}s`) - ), - execTimeoutMs - ) - ); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject(new Error(`Execution timed out after ${execTimeoutMs / 1000}s`)), + execTimeoutMs, + ), + ); - let execResultDetails: Docker.ExecInspectInfo; - try { - execResultDetails = await Promise.race([execPromise, timeoutPromise]); - exitCode = execResultDetails.ExitCode ?? 1; // Default to 1 if null/undefined - logs = execOutput.stderr; - if (exitCode === 0 && execOutput.stdout) { - func_result = execOutput.stdout.trim(); - } else if (exitCode !== 0) { - // Combine outputs but respect size limits - const combinedOutput = `Exit Code: ${exitCode}\n${execOutput.stderr}\n${execOutput.stdout}`; - logs = - combinedOutput.length > MAX_OUTPUT_SIZE - ? combinedOutput.substring(0, MAX_OUTPUT_SIZE) + - "\n[SHSF TRUNCATED] Combined output exceeded 3MB limit" - : combinedOutput; - console.error( - `[executeFunction] Exec failed with code ${exitCode}. Logs truncated due to size.` - ); - } - } catch (execError: any) { - console.error( - "[executeFunction] Exec failed or timed out:", - execError.message - ); - logs = `${execOutput.stderr}\nExecution Error: ${execError.message}`; - exitCode = -1; - func_result = ""; - } - recordTiming("Container execution via exec finished"); - // Process result if successful - let parsedResult: any = null; - if (exitCode === 0 && func_result) { - try { - // Look for the function result markers - const startMarker = "SHSF_FUNCTION_RESULT_START"; - const endMarker = "SHSF_FUNCTION_RESULT_END"; - const startIdx = func_result.indexOf(startMarker); - const endIdx = func_result.lastIndexOf(endMarker); + let execResultDetails: Docker.ExecInspectInfo; + try { + execResultDetails = await Promise.race([execPromise, timeoutPromise]); + exitCode = execResultDetails.ExitCode ?? 1; // Default to 1 if null/undefined + logs = execOutput.stderr; + if (exitCode === 0 && execOutput.stdout) { + func_result = execOutput.stdout.trim(); + } else if (exitCode !== 0) { + // Combine outputs but respect size limits + const combinedOutput = `Exit Code: ${exitCode}\n${execOutput.stderr}\n${execOutput.stdout}`; + logs = + combinedOutput.length > MAX_OUTPUT_SIZE + ? combinedOutput.substring(0, MAX_OUTPUT_SIZE) + + "\n[SHSF TRUNCATED] Combined output exceeded 3MB limit" + : combinedOutput; + console.error( + `[executeFunction] Exec failed with code ${exitCode}. Logs truncated due to size.`, + ); + } + } catch (execError: any) { + console.error( + "[executeFunction] Exec failed or timed out:", + execError.message, + ); + logs = `${execOutput.stderr}\nExecution Error: ${execError.message}`; + exitCode = -1; + func_result = ""; + } + recordTiming("Container execution via exec finished"); + // Process result if successful + let parsedResult: any = null; + if (exitCode === 0 && func_result) { + try { + // Look for the function result markers + const startMarker = "SHSF_FUNCTION_RESULT_START"; + const endMarker = "SHSF_FUNCTION_RESULT_END"; + const startIdx = func_result.indexOf(startMarker); + const endIdx = func_result.lastIndexOf(endMarker); - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - // Ensure markers are present and in correct order - // Extract only the content between markers - const actualResult = func_result - .substring(startIdx + startMarker.length, endIdx) - .trim(); + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + // Ensure markers are present and in correct order + // Extract only the content between markers + const actualResult = func_result + .substring(startIdx + startMarker.length, endIdx) + .trim(); - // Content before or after markers in stdout is now unexpected, but log it as a warning if it occurs. - const prefix = func_result.substring(0, startIdx).trim(); - if (prefix) { - logs += `\n[Runner Warning] Unexpected content before result marker in stdout: ${prefix}`; - } + // Content before or after markers in stdout is now unexpected, but log it as a warning if it occurs. + const prefix = func_result.substring(0, startIdx).trim(); + if (prefix) { + logs += `\n[Runner Warning] Unexpected content before result marker in stdout: ${prefix}`; + } - const suffix = func_result - .substring(endIdx + endMarker.length) - .trim(); - if (suffix) { - logs += `\n[Runner Warning] Unexpected content after result marker in stdout: ${suffix}`; - } + const suffix = func_result.substring(endIdx + endMarker.length).trim(); + if (suffix) { + logs += `\n[Runner Warning] Unexpected content after result marker in stdout: ${suffix}`; + } - parsedResult = JSON.parse(actualResult); - } else { - // If no markers are found, or they are in the wrong order, - // treat the entire stdout as potential logging output. - console.warn( - `[executeFunction] Function result markers not found or in wrong order in stdout. Treating stdout as logs.` - ); - if (func_result.trim()) { - logs += `\nStdout content (no valid markers found):\n${func_result.trim()}`; - } - // No parsedResult, leave it as null - } - } catch (e: any) { - console.error( - `[executeFunction] Failed to parse JSON result from stdout: ${e.message}. Raw stdout content: ${func_result}` - ); - logs += `\nError parsing result JSON from stdout: ${e.message}`; - exitCode = -2; // Custom code for result parsing error - } - } + parsedResult = JSON.parse(actualResult); + } else { + // If no markers are found, or they are in the wrong order, + // treat the entire stdout as potential logging output. + console.warn( + `[executeFunction] Function result markers not found or in wrong order in stdout. Treating stdout as logs.`, + ); + if (func_result.trim()) { + logs += `\nStdout content (no valid markers found):\n${func_result.trim()}`; + } + // No parsedResult, leave it as null + } + } catch (e: any) { + console.error( + `[executeFunction] Failed to parse JSON result from stdout: ${e.message}. Raw stdout content: ${func_result}`, + ); + logs += `\nError parsing result JSON from stdout: ${e.message}`; + exitCode = -2; // Custom code for result parsing error + } + } - tooks.push({ - timestamp: Date.now(), - value: (Date.now() - starting_time) / 1000, - description: "Total execution time (including potential setup)", - }); + tooks.push({ + timestamp: Date.now(), + value: (Date.now() - starting_time) / 1000, + description: "Total execution time (including potential setup)", + }); - // Revoke/delete the short-lived access token after execution - if (dbAccessToken) { - try { - await prisma.accessToken.deleteMany({ - where: { - token: dbAccessToken, - }, - }); - recordTiming("Short-lived access token for DB communication revoked"); - } catch (tokenCleanupError) { - console.error("Error revoking short-lived access token:", tokenCleanupError); - } - } + // Revoke/delete the short-lived access token after execution + if (dbAccessToken) { + try { + await prisma.accessToken.deleteMany({ + where: { + token: dbAccessToken, + }, + }); + recordTiming("Short-lived access token for DB communication revoked"); + } catch (tokenCleanupError) { + console.error( + "Error revoking short-lived access token:", + tokenCleanupError, + ); + } + } - return { - logs, - result: parsedResult, // Return parsed object or null - tooks, - exit_code: exitCode, - }; - } catch (error: any) { - console.error( - `[executeFunction] Critical error during execution of function ${id}:`, - error - ); - recordTiming("Critical error occurred"); - tooks.push({ - timestamp: Date.now(), - value: (Date.now() - starting_time) / 1000, - description: "Total execution time until error", - }); - return { - logs: `${logs}\nCritical Error: ${error.message}\n${error.stack}`, - result: "Sorry, an error occurred during execution.", - tooks, - exit_code: error.statusCode || -3, // Custom code for unhandled errors - }; - } finally { - recordTiming("Finalizing execution log"); + return { + logs, + result: parsedResult, // Return parsed object or null + tooks, + exit_code: exitCode, + }; + } catch (error: any) { + console.error( + `[executeFunction] Critical error during execution of function ${id}:`, + error, + ); + recordTiming("Critical error occurred"); + tooks.push({ + timestamp: Date.now(), + value: (Date.now() - starting_time) / 1000, + description: "Total execution time until error", + }); + return { + logs: `${logs}\nCritical Error: ${error.message}\n${error.stack}`, + result: "Sorry, an error occurred during execution.", + tooks, + exit_code: error.statusCode || -3, // Custom code for unhandled errors + }; + } finally { + recordTiming("Finalizing execution log"); - // Clean up the unique execution directory - try { - await fs.rm(executionDir, { recursive: true, force: true }); - recordTiming("Cleaned up execution directory"); - } catch (cleanupError: any) { - if (cleanupError.code === "EACCES") { - console.error( - `[executeFunction] Permission denied when cleaning up execution directory ${executionDir}:`, - cleanupError - ); - } else if (cleanupError.code === "EBUSY") { - console.error( - `[executeFunction] Directory in use, could not clean up execution directory ${executionDir}:`, - cleanupError - ); - } else { - console.error( - `[executeFunction] Error cleaning up execution directory ${executionDir}:`, - cleanupError - ); - } - } + // Clean up the unique execution directory + try { + await fs.rm(executionDir, { recursive: true, force: true }); + recordTiming("Cleaned up execution directory"); + } catch (cleanupError: any) { + if (cleanupError.code === "EACCES") { + console.error( + `[executeFunction] Permission denied when cleaning up execution directory ${executionDir}:`, + cleanupError, + ); + } else if (cleanupError.code === "EBUSY") { + console.error( + `[executeFunction] Directory in use, could not clean up execution directory ${executionDir}:`, + cleanupError, + ); + } else { + console.error( + `[executeFunction] Error cleaning up execution directory ${executionDir}:`, + cleanupError, + ); + } + } - // Container and funcAppDir are not removed here as they are persistent. - // Cleanup of old/unused containers/directories would be a separate process/tool. + // Container and funcAppDir are not removed here as they are persistent. + // Cleanup of old/unused containers/directories would be a separate process/tool. - console.log( - `[SHSF CRONS] Function ${functionData.id} (${ - functionData.name - }) processed. Resulting exit code: ${exitCode}. Total time: ${ - (Date.now() - starting_time) / 1000 - } seconds` - ); + console.log( + `[SHSF CRONS] Function ${functionData.id} (${ + functionData.name + }) processed. Resulting exit code: ${exitCode}. Total time: ${ + (Date.now() - starting_time) / 1000 + } seconds`, + ); - try { - await prisma.function.update({ - where: { id }, - data: { lastRun: new Date() }, - }); - } catch (dbError) { - console.error("Error updating function lastRun:", dbError); - } + try { + await prisma.function.update({ + where: { id }, + data: { lastRun: new Date() }, + }); + } catch (dbError) { + console.error("Error updating function lastRun:", dbError); + } - try { - // Ensure func_result is a string for the DB, even if it's an error message or empty - const resultForDb = - typeof func_result === "string" && func_result !== "" - ? func_result - : JSON.stringify(null); - const DB_FIELD_LIMIT = 10000; // Reasonable DB field size limit + try { + // Ensure func_result is a string for the DB, even if it's an error message or empty + const resultForDb = + typeof func_result === "string" && func_result !== "" + ? func_result + : JSON.stringify(null); + const DB_FIELD_LIMIT = 10000; // Reasonable DB field size limit - await prisma.triggerLog.create({ - data: { - functionId: id, - logs: - logs.length > DB_FIELD_LIMIT - ? logs.substring(0, DB_FIELD_LIMIT) + "...[truncated for DB]" - : logs, - result: JSON.stringify({ - exit_code: exitCode, - tooks: tooks, - output: - resultForDb.length > DB_FIELD_LIMIT - ? resultForDb.substring(0, DB_FIELD_LIMIT) + - "...[truncated for DB]" - : resultForDb, - payload: - payload.length > DB_FIELD_LIMIT - ? payload.substring(0, DB_FIELD_LIMIT) + "...[truncated for DB]" - : payload, - }), - }, - }); - } catch (error) { - console.error("Error creating trigger log:", error); - } - } + await prisma.triggerLog.create({ + data: { + functionId: id, + logs: + logs.length > DB_FIELD_LIMIT + ? logs.substring(0, DB_FIELD_LIMIT) + "...[truncated for DB]" + : logs, + result: JSON.stringify({ + exit_code: exitCode, + tooks: tooks, + output: + resultForDb.length > DB_FIELD_LIMIT + ? resultForDb.substring(0, DB_FIELD_LIMIT) + "...[truncated for DB]" + : resultForDb, + payload: + payload.length > DB_FIELD_LIMIT + ? payload.substring(0, DB_FIELD_LIMIT) + "...[truncated for DB]" + : payload, + }), + }, + }); + } catch (error) { + console.error("Error creating trigger log:", error); + } + } } export async function buildPayloadFromGET( - ctr: DataContext< - "HttpRequest", - "GET", - HttpRequestContext<{}>, - UsableMiddleware<{}>[] - > + ctr: DataContext< + "HttpRequest", + "GET", + HttpRequestContext<{}>, + UsableMiddleware<{}>[] + >, ): Promise<{ - headers: Record; - queries: Record; - source_ip: string; - route: string | "default"; - method: string; + headers: Record; + queries: Record; + source_ip: string; + route: string | "default"; + method: string; }> { - return { - headers: Object.fromEntries(ctr.headers.entries()), - queries: Object.fromEntries(ctr.queries.entries()), - source_ip: ctr.client.ip.usual(), - route: ctr.params.get("route") || "default", - method: "GET", - }; + return { + headers: Object.fromEntries(ctr.headers.entries()), + queries: Object.fromEntries(ctr.queries.entries()), + source_ip: ctr.client.ip.usual(), + route: ctr.params.get("route") || "default", + method: "GET", + }; } export async function buildPayloadFromPOST( - ctr: DataContext< - "HttpRequest", - "POST", - HttpRequestContext<{}>, - UsableMiddleware<{}>[] - > + ctr: DataContext< + "HttpRequest", + "POST", + HttpRequestContext<{}>, + UsableMiddleware<{}>[] + >, ): Promise<{ - headers: Record; - body: string; - queries: Record; - source_ip: string; - route: string | "default"; - raw_body: string; - method: string; + headers: Record; + body: string; + queries: Record; + source_ip: string; + route: string | "default"; + raw_body: string; + method: string; }> { - return { - headers: Object.fromEntries(ctr.headers.entries()), - queries: Object.fromEntries(ctr.queries.entries()), - body: await ctr.rawBody("utf-8"), - raw_body: await ctr.rawBody("binary"), - source_ip: ctr.client.ip.usual(), - route: ctr.params.get("route") || "default", - method: "POST", - }; + return { + headers: Object.fromEntries(ctr.headers.entries()), + queries: Object.fromEntries(ctr.queries.entries()), + body: await ctr.rawBody("utf-8"), + raw_body: await ctr.rawBody("binary"), + source_ip: ctr.client.ip.usual(), + route: ctr.params.get("route") || "default", + method: "POST", + }; } export async function installDependencies( - functionId: number, - functionData: any, - files: any[] + functionId: number, + functionData: any, + files: any[], ): Promise { - const docker = new Docker(); - const functionIdStr = String(functionId); - const containerName = `shsf_func_${functionIdStr}`; + const docker = new Docker(); + const functionIdStr = String(functionId); + const containerName = `shsf_func_${functionIdStr}`; - try { - let container = docker.getContainer(containerName); + try { + let container = docker.getContainer(containerName); - try { - const inspectInfo = await container.inspect(); - if (!inspectInfo.State.Running) { - await container.start(); - } - } catch (error: any) { - if (error.statusCode === 404) { - return 404; // We cant run it, as we dont know what it does. - } else { - throw error; - } - } + try { + const inspectInfo = await container.inspect(); + if (!inspectInfo.State.Running) { + await container.start(); + } + } catch (error: any) { + if (error.statusCode === 404) { + return 404; // We cant run it, as we dont know what it does. + } else { + throw error; + } + } - const execEnv: string[] = functionData.env - ? JSON.parse(functionData.env).map( - (env: { name: string; value: any }) => `${env.name}=${env.value}` - ) - : []; + const execEnv: string[] = functionData.env + ? JSON.parse(functionData.env).map( + (env: { name: string; value: any }) => `${env.name}=${env.value}`, + ) + : []; - const exec = await container.exec({ - Cmd: [ - "/bin/sh", - "-c", - "cd /app && if [ -f requirements.txt ]; then pip install --user -r requirements.txt; else echo 'No requirements.txt found.'; fi", - ], - Env: execEnv, - AttachStdout: true, - AttachStderr: true, - Tty: false, - }); + const exec = await container.exec({ + Cmd: [ + "/bin/sh", + "-c", + "cd /app && if [ -f requirements.txt ]; then pip install --user -r requirements.txt; else echo 'No requirements.txt found.'; fi", + ], + Env: execEnv, + AttachStdout: true, + AttachStderr: true, + Tty: false, + }); - const execStream = await exec.start({ hijack: true, stdin: false }); + const execStream = await exec.start({ hijack: true, stdin: false }); - // Consume the stream to completion (required for exec to finish) - await new Promise((resolve, reject) => { - execStream.on("end", resolve); - execStream.on("error", reject); - // Drain the stream - execStream.resume(); - }); + // Consume the stream to completion (required for exec to finish) + await new Promise((resolve, reject) => { + execStream.on("end", resolve); + execStream.on("error", reject); + // Drain the stream + execStream.resume(); + }); - // Inspect the exec to get the exit code - const inspect = await exec.inspect(); - if (inspect.ExitCode === 0) { - return true; - } else { - return false; - } - } catch (error) { - console.error("Error installing dependencies:", error); - return false; - } + // Inspect the exec to get the exit code + const inspect = await exec.inspect(); + if (inspect.ExitCode === 0) { + return true; + } else { + return false; + } + } catch (error) { + console.error("Error installing dependencies:", error); + return false; + } } // Helper function to clean up container when deleting a function export async function cleanupFunctionContainer(functionId: number) { - const functionIdStr = String(functionId); - const containerName = `shsf_func_${functionIdStr}`; - const funcAppDir = path.join("/opt/shsf_data/functions", functionIdStr); + const functionIdStr = String(functionId); + const containerName = `shsf_func_${functionIdStr}`; + const funcAppDir = path.join("/opt/shsf_data/functions", functionIdStr); - try { - const docker = new Docker(); - // Try to stop and remove the container if it exists - try { - const container = docker.getContainer(containerName); - const containerInfo = await container.inspect(); + try { + const docker = new Docker(); + // Try to stop and remove the container if it exists + try { + const container = docker.getContainer(containerName); + const containerInfo = await container.inspect(); - if (containerInfo.State.Running) { - console.log(`[SHSF] Stopping container for function ${functionId}`); - await container.kill({ t: 10 }); // 10-second timeout - } + if (containerInfo.State.Running) { + console.log(`[SHSF] Stopping container for function ${functionId}`); + await container.kill({ t: 10 }); // 10-second timeout + } - console.log(`[SHSF] Removing container for function ${functionId}`); - await container.remove(); - } catch (containerError: any) { - if (containerError.statusCode !== 404) { - console.error( - `[SHSF] Error removing container for function ${functionId}:`, - containerError - ); - } else { - console.log( - `[SHSF] Container for function ${functionId} not found, skipping removal` - ); - } - } + console.log(`[SHSF] Removing container for function ${functionId}`); + await container.remove(); + } catch (containerError: any) { + if (containerError.statusCode !== 404) { + console.error( + `[SHSF] Error removing container for function ${functionId}:`, + containerError, + ); + } else { + console.log( + `[SHSF] Container for function ${functionId} not found, skipping removal`, + ); + } + } - // Remove the function directory - try { - console.log(`[SHSF] Removing function directory: ${funcAppDir}`); - await fs.rm(funcAppDir, { recursive: true, force: true }); - } catch (dirError) { - console.error( - `[SHSF] Error removing function directory ${funcAppDir}:`, - dirError - ); - } + // Remove the function directory + try { + console.log(`[SHSF] Removing function directory: ${funcAppDir}`); + await fs.rm(funcAppDir, { recursive: true, force: true }); + } catch (dirError) { + console.error( + `[SHSF] Error removing function directory ${funcAppDir}:`, + dirError, + ); + } - // Clean up cache directories - try { - // Python venv - const pipCacheDir = `/opt/shsf_data/cache/pip/venv/function-${functionId}`; - if (fsSync.existsSync(pipCacheDir)) { - await fs.rm(pipCacheDir, { recursive: true, force: true }); - } + // Clean up cache directories + try { + // Python venv + const pipCacheDir = `/opt/shsf_data/cache/pip/venv/function-${functionId}`; + if (fsSync.existsSync(pipCacheDir)) { + await fs.rm(pipCacheDir, { recursive: true, force: true }); + } - // Pip hash - const pipHashDir = `/opt/shsf_data/cache/pip/hashes/function-${functionId}`; - if (fsSync.existsSync(pipHashDir)) { - await fs.rm(pipHashDir, { recursive: true, force: true }); - } - } catch (cacheError) { - console.error( - `[SHSF] Error cleaning up cache directories for function ${functionId}:`, - cacheError - ); - } + // Pip hash + const pipHashDir = `/opt/shsf_data/cache/pip/hashes/function-${functionId}`; + if (fsSync.existsSync(pipHashDir)) { + await fs.rm(pipHashDir, { recursive: true, force: true }); + } + } catch (cacheError) { + console.error( + `[SHSF] Error cleaning up cache directories for function ${functionId}:`, + cacheError, + ); + } - return true; - } catch (error) { - console.error( - `[SHSF] Error during container cleanup for function ${functionId}:`, - error - ); - return false; - } + return true; + } catch (error) { + console.error( + `[SHSF] Error during container cleanup for function ${functionId}:`, + error, + ); + return false; + } } diff --git a/Backend/src/routes/api/account/accesstoken.ts b/Backend/src/routes/api/account/accesstoken.ts index 9255d31..93e0411 100644 --- a/Backend/src/routes/api/account/accesstoken.ts +++ b/Backend/src/routes/api/account/accesstoken.ts @@ -3,288 +3,286 @@ import { API_KEY_HEADER, COOKIE, fileRouter, prisma } from "../../.."; import { checkAuthentication } from "../../../lib/Authentication"; function maskToken(token: string) { - if (token.length <= 8) return token; - return token.slice(0, 4) + "..." + token.slice(-4); + if (token.length <= 8) return token; + return token.slice(0, 4) + "..." + token.slice(-4); } export = new fileRouter.Path("/") - // Generate a new access token - .http("POST", "/api/account/accesstoken/generate", (http) => - http - .ratelimit((limit) => limit.hits(3).window(60000).penalty(10000)) - .onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - name: z.string().min(2).max(128), - purpose: z.string().max(512).optional(), - expires_in: z.number().int().min(1).max(365).nullable().optional(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + // Generate a new access token + .http("POST", "/api/account/accesstoken/generate", (http) => + http + .ratelimit((limit) => limit.hits(3).window(60000).penalty(10000)) + .onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + name: z.string().min(2).max(128), + purpose: z.string().max(512).optional(), + expires_in: z.number().int().min(1).max(365).nullable().optional(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - // Prevent creating a token with reserved prefixes - const disallowedPrefixes = [ - "token_exec_", - "sys_token_", - "internal_", - "func_token_", - "db_token_", - "storage_token_", - "mount_token_", - "shsf_", - ]; - const matchedPrefix = disallowedPrefixes.find((prefix) => - data.name.startsWith(prefix) - ); - if (matchedPrefix) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: "FAILED", - message: `Token name cannot start with "${matchedPrefix}" as it is reserved for system use`, - }); - } + // Prevent creating a token with reserved prefixes + const disallowedPrefixes = [ + "token_exec_", + "sys_token_", + "internal_", + "func_token_", + "db_token_", + "storage_token_", + "mount_token_", + "shsf_", + ]; + const matchedPrefix = disallowedPrefixes.find((prefix) => + data.name.startsWith(prefix), + ); + if (matchedPrefix) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: "FAILED", + message: `Token name cannot start with "${matchedPrefix}" as it is reserved for system use`, + }); + } - // Generate secure random token - const token = randomBytes(32).toString("hex"); - let expiresAt: Date | null = null; - if (typeof data.expires_in === "number") { - expiresAt = new Date( - Date.now() + data.expires_in * 24 * 60 * 60 * 1000 - ); - } - // If expires_in is null or undefined, expiresAt stays null (never expires) + // Generate secure random token + const token = randomBytes(32).toString("hex"); + let expiresAt: Date | null = null; + if (typeof data.expires_in === "number") { + expiresAt = new Date(Date.now() + data.expires_in * 24 * 60 * 60 * 1000); + } + // If expires_in is null or undefined, expiresAt stays null (never expires) - // Check for existing token with same name for this user - const existingToken = await prisma.accessToken.findFirst({ - where: { - userId: authCheck.user.id, - name: data.name, - }, - }); - if (existingToken) { - return ctr.status(ctr.$status.CONFLICT).print({ - status: "FAILED", - message: "An access token with this name already exists", - }); - } + // Check for existing token with same name for this user + const existingToken = await prisma.accessToken.findFirst({ + where: { + userId: authCheck.user.id, + name: data.name, + }, + }); + if (existingToken) { + return ctr.status(ctr.$status.CONFLICT).print({ + status: "FAILED", + message: "An access token with this name already exists", + }); + } - const created = await prisma.accessToken.create({ - data: { - token, - name: data.name, - purpose: data.purpose, - expiresAt, - userId: authCheck.user.id, - }, - }); + const created = await prisma.accessToken.create({ + data: { + token, + name: data.name, + purpose: data.purpose, + expiresAt, + userId: authCheck.user.id, + }, + }); - return ctr.print({ - status: "OK", - id: created.id, - name: created.name, - purpose: created.purpose, - expiresAt: created.expiresAt, - createdAt: created.createdAt, - token, // Only show full token on creation - }); - }) - ) - // Revoke (delete) an access token - .http("DELETE", "/api/account/accesstoken/revoke", (http) => - http - .ratelimit((limit) => limit.hits(5).window(60000).penalty(10000)) - .onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - id: z.number().int(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + id: created.id, + name: created.name, + purpose: created.purpose, + expiresAt: created.expiresAt, + createdAt: created.createdAt, + token, // Only show full token on creation + }); + }), + ) + // Revoke (delete) an access token + .http("DELETE", "/api/account/accesstoken/revoke", (http) => + http + .ratelimit((limit) => limit.hits(5).window(60000).penalty(10000)) + .onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + id: z.number().int(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - // Only allow deleting own tokens - const token = await prisma.accessToken.findUnique({ - where: { id: data.id }, - }); - if (!token || token.userId !== authCheck.user.id) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Token not found", - }); - } + // Only allow deleting own tokens + const token = await prisma.accessToken.findUnique({ + where: { id: data.id }, + }); + if (!token || token.userId !== authCheck.user.id) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Token not found", + }); + } - if (token.hidden) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: "FAILED", - message: - "Failed to revoke token; Token is created by system and cannot be revoked manually", - }); - } + if (token.hidden) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: "FAILED", + message: + "Failed to revoke token; Token is created by system and cannot be revoked manually", + }); + } - await prisma.accessToken.delete({ - where: { id: data.id }, - }); + await prisma.accessToken.delete({ + where: { id: data.id }, + }); - return ctr.print({ - status: "OK", - message: "Token revoked", - }); - }) - ) - // List all access tokens for the user (masked) - .http("GET", "/api/account/accesstoken/list", (http) => - http - .ratelimit((limit) => limit.hits(10).window(60000).penalty(5000)) - .onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + return ctr.print({ + status: "OK", + message: "Token revoked", + }); + }), + ) + // List all access tokens for the user (masked) + .http("GET", "/api/account/accesstoken/list", (http) => + http + .ratelimit((limit) => limit.hits(10).window(60000).penalty(5000)) + .onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const tokens = await prisma.accessToken.findMany({ - where: { userId: authCheck.user.id }, - orderBy: { createdAt: "desc" }, - }); + const tokens = await prisma.accessToken.findMany({ + where: { userId: authCheck.user.id }, + orderBy: { createdAt: "desc" }, + }); - return ctr.print({ - status: "OK", - tokens: tokens - .filter((t) => !t.hidden) - .map((t) => ({ - id: t.id, - name: t.name, - purpose: t.purpose, - expiresAt: t.expiresAt, - createdAt: t.createdAt, - expired: t.expired, - tokenMasked: maskToken(t.token), - })), - }); - }) - ) - // Update access token name and purpose - .http("PATCH", "/api/account/accesstoken/update", (http) => - http - .ratelimit((limit) => limit.hits(5).window(60000).penalty(10000)) - .onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - id: z.number().int(), - name: z.string().min(2).max(128).optional(), - purpose: z.string().max(512).optional().nullable(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + tokens: tokens + .filter((t) => !t.hidden) + .map((t) => ({ + id: t.id, + name: t.name, + purpose: t.purpose, + expiresAt: t.expiresAt, + createdAt: t.createdAt, + expired: t.expired, + tokenMasked: maskToken(t.token), + })), + }); + }), + ) + // Update access token name and purpose + .http("PATCH", "/api/account/accesstoken/update", (http) => + http + .ratelimit((limit) => limit.hits(5).window(60000).penalty(10000)) + .onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + id: z.number().int(), + name: z.string().min(2).max(128).optional(), + purpose: z.string().max(512).optional().nullable(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - // Only allow updating own tokens - const token = await prisma.accessToken.findUnique({ - where: { id: data.id }, - }); - if (!token || token.userId !== authCheck.user.id) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Token not found", - }); - } + // Only allow updating own tokens + const token = await prisma.accessToken.findUnique({ + where: { id: data.id }, + }); + if (!token || token.userId !== authCheck.user.id) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Token not found", + }); + } - // If name is being changed, check for conflicts - if (data.name && data.name !== token.name) { - const existingToken = await prisma.accessToken.findFirst({ - where: { - userId: authCheck.user.id, - name: data.name, - }, - }); - if (existingToken) { - return ctr.status(ctr.$status.CONFLICT).print({ - status: "FAILED", - message: "An access token with this name already exists", - }); - } - } + // If name is being changed, check for conflicts + if (data.name && data.name !== token.name) { + const existingToken = await prisma.accessToken.findFirst({ + where: { + userId: authCheck.user.id, + name: data.name, + }, + }); + if (existingToken) { + return ctr.status(ctr.$status.CONFLICT).print({ + status: "FAILED", + message: "An access token with this name already exists", + }); + } + } - const updated = await prisma.accessToken.update({ - where: { id: data.id }, - data: { - name: data.name ?? token.name, - purpose: data.purpose !== undefined ? data.purpose : token.purpose, - }, - }); + const updated = await prisma.accessToken.update({ + where: { id: data.id }, + data: { + name: data.name ?? token.name, + purpose: data.purpose !== undefined ? data.purpose : token.purpose, + }, + }); - return ctr.print({ - status: "OK", - id: updated.id, - name: updated.name, - purpose: updated.purpose, - expiresAt: updated.expiresAt, - createdAt: updated.createdAt, - expired: updated.expired, - hidden: updated.hidden, - tokenMasked: maskToken(updated.token), - }); - }) - ) - // Auth Check - .http("GET", "/api/account/accesstoken/authcheck", (http) => - http - .ratelimit((limit) => limit.hits(20).window(60000).penalty(2000)) - .onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + return ctr.print({ + status: "OK", + id: updated.id, + name: updated.name, + purpose: updated.purpose, + expiresAt: updated.expiresAt, + createdAt: updated.createdAt, + expired: updated.expired, + hidden: updated.hidden, + tokenMasked: maskToken(updated.token), + }); + }), + ) + // Auth Check + .http("GET", "/api/account/accesstoken/authcheck", (http) => + http + .ratelimit((limit) => limit.hits(20).window(60000).penalty(2000)) + .onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - return ctr.print({ - status: "OK", - message: "Authenticated", - userId: authCheck.user.id, - method: authCheck.method, - }); - }) - ); + return ctr.print({ + status: "OK", + message: "Authenticated", + userId: authCheck.user.id, + method: authCheck.method, + }); + }), + ); diff --git a/Backend/src/routes/api/account/getUserInfo.ts b/Backend/src/routes/api/account/getUserInfo.ts index 44a27a3..b6618fd 100644 --- a/Backend/src/routes/api/account/getUserInfo.ts +++ b/Backend/src/routes/api/account/getUserInfo.ts @@ -1,26 +1,28 @@ import { API_KEY_HEADER, COOKIE, fileRouter } from "../../.."; import { checkAuthentication } from "../../../lib/Authentication"; -export = new fileRouter.Path("/").http("GET", "/api/account/getUserInfo", (http) => - http - .onRequest(async (ctr) => { +export = new fileRouter.Path("/").http( + "GET", + "/api/account/getUserInfo", + (http) => + http.onRequest(async (ctr) => { const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER), - ); + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } return ctr.print({ status: "OK", user: authCheck.user, - session: authCheck.method === "session" ? authCheck.session : null, - apiKey: authCheck.method === "apiKey" ? authCheck.apiKey : null, + session: authCheck.method === "session" ? authCheck.session : null, + apiKey: authCheck.method === "apiKey" ? authCheck.apiKey : null, }); - }) + }), ); diff --git a/Backend/src/routes/api/account/guest.ts b/Backend/src/routes/api/account/guest.ts index 95bed76..d978dc9 100644 --- a/Backend/src/routes/api/account/guest.ts +++ b/Backend/src/routes/api/account/guest.ts @@ -1,572 +1,572 @@ import { Cookie } from "rjweb-server"; import { - API_KEY_HEADER, - COOKIE, - DOMAIN, - fileRouter, - prisma, - REACT_APP_API_URL, - UI_URL, + API_KEY_HEADER, + COOKIE, + DOMAIN, + fileRouter, + prisma, + REACT_APP_API_URL, + UI_URL, } from "../../.."; import { checkAuthentication } from "../../../lib/Authentication"; import * as bcrypt from "bcrypt"; export = new fileRouter.Path("/") - // Create a new guest user - .http("POST", "/api/account/guest/create", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - displayName: z.string().min(2).max(128), - email: z.string().email(), - password: z.string().min(8).max(256), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + // Create a new guest user + .http("POST", "/api/account/guest/create", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + displayName: z.string().min(2).max(128), + email: z.string().email(), + password: z.string().min(8).max(256), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - // Check if a regular user with this email already exists - const existingUser = await prisma.user.findUnique({ - where: { email: data.email }, - }); - if (existingUser) { - return ctr.status(ctr.$status.CONFLICT).print({ - status: "FAILED", - message: "A regular user with this email already exists", - }); - } + // Check if a regular user with this email already exists + const existingUser = await prisma.user.findUnique({ + where: { email: data.email }, + }); + if (existingUser) { + return ctr.status(ctr.$status.CONFLICT).print({ + status: "FAILED", + message: "A regular user with this email already exists", + }); + } - // Check for existing guest with same email for this owner - const existing = await prisma.guestUser.findFirst({ - where: { email: data.email, guestOwnerId: authCheck.user.id }, - }); - if (existing) { - return ctr.status(ctr.$status.CONFLICT).print({ - status: "FAILED", - message: "A guest user with this email already exists", - }); - } + // Check for existing guest with same email for this owner + const existing = await prisma.guestUser.findFirst({ + where: { email: data.email, guestOwnerId: authCheck.user.id }, + }); + if (existing) { + return ctr.status(ctr.$status.CONFLICT).print({ + status: "FAILED", + message: "A guest user with this email already exists", + }); + } - const password_hash = await bcrypt.hash(data.password, 10); + const password_hash = await bcrypt.hash(data.password, 10); - const created = await prisma.guestUser.create({ - data: { - displayName: data.displayName, - email: data.email, - password_hash: password_hash, - guestOwnerId: authCheck.user.id, - permittedFunctions: [], - }, - }); + const created = await prisma.guestUser.create({ + data: { + displayName: data.displayName, + email: data.email, + password_hash: password_hash, + guestOwnerId: authCheck.user.id, + permittedFunctions: [], + }, + }); - return ctr.print({ - status: "OK", - guest: { - id: created.id, - displayName: created.displayName, - email: created.email, - createdAt: created.createdAt, - updatedAt: created.updatedAt, - }, - }); - }) - ) - // List all guest users for the authenticated owner - .http("GET", "/api/account/guest/list", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + return ctr.print({ + status: "OK", + guest: { + id: created.id, + displayName: created.displayName, + email: created.email, + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }, + }); + }), + ) + // List all guest users for the authenticated owner + .http("GET", "/api/account/guest/list", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guests = await prisma.guestUser.findMany({ - where: { guestOwnerId: authCheck.user.id }, - orderBy: { createdAt: "desc" }, - include: { - _count: { - select: { - sessions: { where: { expiresAt: { gt: new Date() } } }, - }, - }, - }, - }); + const guests = await prisma.guestUser.findMany({ + where: { guestOwnerId: authCheck.user.id }, + orderBy: { createdAt: "desc" }, + include: { + _count: { + select: { + sessions: { where: { expiresAt: { gt: new Date() } } }, + }, + }, + }, + }); - return ctr.print({ - status: "OK", - guests: guests.map((g) => ({ - id: g.id, - displayName: g.displayName, - email: g.email, - permittedFunctions: g.permittedFunctions, - createdAt: g.createdAt, - updatedAt: g.updatedAt, - activeSessions: g._count.sessions, - _count: undefined, // Remove _count from response - })), - }); - }) - ) - // Update a guest user (displayName, permittedFunctions, password) - .http("PATCH", "/api/account/guest/update", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - id: z.number().int(), - displayName: z.string().min(2).max(128).optional(), - password: z.string().min(8).max(256).optional(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + guests: guests.map((g) => ({ + id: g.id, + displayName: g.displayName, + email: g.email, + permittedFunctions: g.permittedFunctions, + createdAt: g.createdAt, + updatedAt: g.updatedAt, + activeSessions: g._count.sessions, + _count: undefined, // Remove _count from response + })), + }); + }), + ) + // Update a guest user (displayName, permittedFunctions, password) + .http("PATCH", "/api/account/guest/update", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + id: z.number().int(), + displayName: z.string().min(2).max(128).optional(), + password: z.string().min(8).max(256).optional(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guest = await prisma.guestUser.findUnique({ - where: { id: data.id }, - }); - if (!guest || guest.guestOwnerId !== authCheck.user.id) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { id: data.id }, + }); + if (!guest || guest.guestOwnerId !== authCheck.user.id) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } - const updated = await prisma.guestUser.update({ - where: { id: data.id }, - data: { - displayName: data.displayName ?? guest.displayName, - password_hash: data.password - ? await bcrypt.hash(data.password, 10) - : guest.password_hash, - }, - }); + const updated = await prisma.guestUser.update({ + where: { id: data.id }, + data: { + displayName: data.displayName ?? guest.displayName, + password_hash: data.password + ? await bcrypt.hash(data.password, 10) + : guest.password_hash, + }, + }); - return ctr.print({ - status: "OK", - guest: { - id: updated.id, - displayName: updated.displayName, - email: updated.email, - createdAt: updated.createdAt, - updatedAt: updated.updatedAt, - }, - }); - }) - ) - // Delete a guest user - .http("DELETE", "/api/account/guest/delete", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - id: z.number().int(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + guest: { + id: updated.id, + displayName: updated.displayName, + email: updated.email, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }, + }); + }), + ) + // Delete a guest user + .http("DELETE", "/api/account/guest/delete", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + id: z.number().int(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guest = await prisma.guestUser.findUnique({ - where: { id: data.id }, - }); - if (!guest || guest.guestOwnerId !== authCheck.user.id) { - // Verify ownership & Existence - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { id: data.id }, + }); + if (!guest || guest.guestOwnerId !== authCheck.user.id) { + // Verify ownership & Existence + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } - await prisma.guestUser.delete({ - where: { id: data.id }, - }); + await prisma.guestUser.delete({ + where: { id: data.id }, + }); - return ctr.print({ - status: "OK", - message: "Guest user deleted", - }); - }) - ) - // Assign a function to a guest user - .http("POST", "/api/account/guest/assign-function", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - guestId: z.number().int(), - functionId: z.number().int(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + message: "Guest user deleted", + }); + }), + ) + // Assign a function to a guest user + .http("POST", "/api/account/guest/assign-function", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + guestId: z.number().int(), + functionId: z.number().int(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guest = await prisma.guestUser.findUnique({ - where: { id: data.guestId }, - }); - if (!guest) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } - if (guest.guestOwnerId !== authCheck.user.id) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: "You do not own this guest user", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { id: data.guestId }, + }); + if (!guest) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } + if (guest.guestOwnerId !== authCheck.user.id) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: "You do not own this guest user", + }); + } - const permitted = Array.isArray(guest.permittedFunctions) - ? guest.permittedFunctions - : []; - if (!permitted.includes(data.functionId)) { - permitted.push(data.functionId); - } + const permitted = Array.isArray(guest.permittedFunctions) + ? guest.permittedFunctions + : []; + if (!permitted.includes(data.functionId)) { + permitted.push(data.functionId); + } - await prisma.guestUser.update({ - where: { id: data.guestId }, - data: { permittedFunctions: permitted }, - }); + await prisma.guestUser.update({ + where: { id: data.guestId }, + data: { permittedFunctions: permitted }, + }); - return ctr.print({ - status: "OK", - message: "Function assigned to guest user", - }); - }) - ) - // Unassign a function from a guest user - .http("POST", "/api/account/guest/unassign-function", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - guestId: z.number().int(), - functionId: z.number().int(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + message: "Function assigned to guest user", + }); + }), + ) + // Unassign a function from a guest user + .http("POST", "/api/account/guest/unassign-function", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + guestId: z.number().int(), + functionId: z.number().int(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guest = await prisma.guestUser.findUnique({ - where: { id: data.guestId }, - }); - if (!guest || guest.guestOwnerId !== authCheck.user.id) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { id: data.guestId }, + }); + if (!guest || guest.guestOwnerId !== authCheck.user.id) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } - const permitted = Array.isArray(guest.permittedFunctions) - ? guest.permittedFunctions - .filter((fid): fid is number => typeof fid === "number") - .filter((fid) => fid !== data.functionId) - : []; + const permitted = Array.isArray(guest.permittedFunctions) + ? guest.permittedFunctions + .filter((fid): fid is number => typeof fid === "number") + .filter((fid) => fid !== data.functionId) + : []; - await prisma.guestUser.update({ - where: { id: data.guestId }, - data: { permittedFunctions: permitted }, - }); + await prisma.guestUser.update({ + where: { id: data.guestId }, + data: { permittedFunctions: permitted }, + }); - return ctr.print({ - status: "OK", - message: "Function unassigned from guest user", - }); - }) - ) - // Get function names by execution ids - .http("POST", "/api/account/guest/function-names", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - functionIds: z.array(z.number().int()), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + message: "Function unassigned from guest user", + }); + }), + ) + // Get function names by execution ids + .http("POST", "/api/account/guest/function-names", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + functionIds: z.array(z.number().int()), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const functions = await prisma.function.findMany({ - where: { id: { in: data.functionIds }, userId: authCheck.user.id }, - }); + const functions = await prisma.function.findMany({ + where: { id: { in: data.functionIds }, userId: authCheck.user.id }, + }); - return ctr.print({ - status: "OK", - data: functions.map((fn) => fn.name), - }); - }) - ) - // list guests from a function's perspective - .http("GET", "/api/account/guest/function/{functionId}", (http) => - http.onRequest(async (ctr) => { - const functionId = Number(ctr.params.get("functionId")); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: "FAILED", - message: "Invalid function ID", - }); - } + return ctr.print({ + status: "OK", + data: functions.map((fn) => fn.name), + }); + }), + ) + // list guests from a function's perspective + .http("GET", "/api/account/guest/function/{functionId}", (http) => + http.onRequest(async (ctr) => { + const functionId = Number(ctr.params.get("functionId")); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: "FAILED", + message: "Invalid function ID", + }); + } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guests = await prisma.guestUser.findMany({ - where: { guestOwnerId: authCheck.user.id }, - }); + const guests = await prisma.guestUser.findMany({ + where: { guestOwnerId: authCheck.user.id }, + }); - const filteredGuests = guests.filter( - (g) => - Array.isArray(g.permittedFunctions) && - g.permittedFunctions.includes(functionId) - ); + const filteredGuests = guests.filter( + (g) => + Array.isArray(g.permittedFunctions) && + g.permittedFunctions.includes(functionId), + ); - return ctr.print({ - status: "OK", - data: filteredGuests, - }); - }) - ) - // Auth Check, email and password, does this user exist and who is it (id) - .http("POST", "/api/account/guest/auth-check", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - email: z.string().email(), - password: z.string().min(8).max(256), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + data: filteredGuests, + }); + }), + ) + // Auth Check, email and password, does this user exist and who is it (id) + .http("POST", "/api/account/guest/auth-check", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + email: z.string().email(), + password: z.string().min(8).max(256), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const guest = await prisma.guestUser.findUnique({ - where: { email: data.email }, - }); - if (!guest) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { email: data.email }, + }); + if (!guest) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } - const passwordMatch = await bcrypt.compare( - data.password, - guest.password_hash - ); - if (!passwordMatch) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: "Incorrect password", - }); - } + const passwordMatch = await bcrypt.compare( + data.password, + guest.password_hash, + ); + if (!passwordMatch) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: "Incorrect password", + }); + } - return ctr.print({ - status: "OK", - guest: { - id: guest.id, - displayName: guest.displayName, - email: guest.email, - createdAt: guest.createdAt, - updatedAt: guest.updatedAt, - }, - }); - }) - ) - // Authenticate a guest user, return a session cookie - .http("POST", "/api/account/guest/authenticate", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - email: z.string().email(), - password: z.string().min(8).max(256), - namespaceId: z.number().int(), - functionExecId: z.string().uuid(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + guest: { + id: guest.id, + displayName: guest.displayName, + email: guest.email, + createdAt: guest.createdAt, + updatedAt: guest.updatedAt, + }, + }); + }), + ) + // Authenticate a guest user, return a session cookie + .http("POST", "/api/account/guest/authenticate", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + email: z.string().email(), + password: z.string().min(8).max(256), + namespaceId: z.number().int(), + functionExecId: z.string().uuid(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const guest = await prisma.guestUser.findUnique({ - where: { email: data.email }, - }); - if (!guest) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { email: data.email }, + }); + if (!guest) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } - const passwordMatch = await bcrypt.compare( - data.password, - guest.password_hash - ); - if (!passwordMatch) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: "Incorrect password", - }); - } + const passwordMatch = await bcrypt.compare( + data.password, + guest.password_hash, + ); + if (!passwordMatch) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: "Incorrect password", + }); + } - // Does this guest have access to this function? - const functionData = await prisma.function.findUnique({ - where: { executionId: data.functionExecId }, - }); - if ( - !functionData || - !Array.isArray(guest.permittedFunctions) || - !guest.permittedFunctions.includes(functionData.id) - ) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: "FAILED", - message: "Guest user does not have access to this function", - }); - } + // Does this guest have access to this function? + const functionData = await prisma.function.findUnique({ + where: { executionId: data.functionExecId }, + }); + if ( + !functionData || + !Array.isArray(guest.permittedFunctions) || + !guest.permittedFunctions.includes(functionData.id) + ) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: "FAILED", + message: "Guest user does not have access to this function", + }); + } - // Helper to generate a random hex string - function randomHex(length = 16) { - return Array.from(crypto.getRandomValues(new Uint8Array(length))) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } + // Helper to generate a random hex string + function randomHex(length = 16) { + return Array.from(crypto.getRandomValues(new Uint8Array(length))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } - const sessionToken = await prisma.guestSession.create({ - data: { - guestUserId: guest.id, - hash: crypto.randomUUID() + randomHex(), - expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000), // 6 hours - }, - }); + const sessionToken = await prisma.guestSession.create({ + data: { + guestUserId: guest.id, + hash: crypto.randomUUID() + randomHex(), + expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000), // 6 hours + }, + }); - let newdomain = REACT_APP_API_URL.replace("https://", "") - .replace("http://", "") - .replace("/", ""); - console.log(`[GUEST AUTH] Setting cookie for domain: ${newdomain}`); + let newdomain = REACT_APP_API_URL.replace("https://", "") + .replace("http://", "") + .replace("/", ""); + console.log(`[GUEST AUTH] Setting cookie for domain: ${newdomain}`); - ctr.cookies.set( - `shsf_guest_${data.namespaceId}_${data.functionExecId}`, - new Cookie(sessionToken.hash, { - domain: newdomain, - expires: new Date(Date.now() + 1000 * 60 * 60 * 6), // 6 hours - }) - ); + ctr.cookies.set( + `shsf_guest_${data.namespaceId}_${data.functionExecId}`, + new Cookie(sessionToken.hash, { + domain: newdomain, + expires: new Date(Date.now() + 1000 * 60 * 60 * 6), // 6 hours + }), + ); - return ctr.print({ - status: "OK", - message: "Guest authenticated", - }); - }) - ) - // Clear all sessions for a guest user - .http("POST", "/api/account/guest/clear-sessions", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - guestId: z.number().int(), - }) - ); - if (!data) - return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); + return ctr.print({ + status: "OK", + message: "Guest authenticated", + }); + }), + ) + // Clear all sessions for a guest user + .http("POST", "/api/account/guest/clear-sessions", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + guestId: z.number().int(), + }), + ); + if (!data) + return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.status(ctr.$status.UNAUTHORIZED).print({ - status: "FAILED", - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.status(ctr.$status.UNAUTHORIZED).print({ + status: "FAILED", + message: authCheck.message, + }); + } - const guest = await prisma.guestUser.findUnique({ - where: { id: data.guestId }, - }); - if (!guest || guest.guestOwnerId !== authCheck.user.id) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: "FAILED", - message: "Guest user not found", - }); - } + const guest = await prisma.guestUser.findUnique({ + where: { id: data.guestId }, + }); + if (!guest || guest.guestOwnerId !== authCheck.user.id) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: "FAILED", + message: "Guest user not found", + }); + } - await prisma.guestSession.deleteMany({ - where: { guestUserId: data.guestId }, - }); + await prisma.guestSession.deleteMany({ + where: { guestUserId: data.guestId }, + }); - return ctr.print({ - status: "OK", - message: "All sessions cleared for guest user", - }); - }) - ); + return ctr.print({ + status: "OK", + message: "All sessions cleared for guest user", + }); + }), + ); diff --git a/Backend/src/routes/api/account/login.ts b/Backend/src/routes/api/account/login.ts index cffdb1c..9a3c793 100644 --- a/Backend/src/routes/api/account/login.ts +++ b/Backend/src/routes/api/account/login.ts @@ -11,7 +11,7 @@ export = new fileRouter.Path("/").http("POST", "/api/account/login", (http) => z.object({ email: z.string().email().max(200), password: z.string().min(8).max(120), - }) + }), ); if (!data) @@ -81,7 +81,7 @@ export = new fileRouter.Path("/").http("POST", "/api/account/login", (http) => new Cookie(hash, { domain: newdomain, expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days - }) + }), ); ctr.print({ @@ -90,5 +90,5 @@ export = new fileRouter.Path("/").http("POST", "/api/account/login", (http) => }); return ctr; - }) + }), ); diff --git a/Backend/src/routes/api/account/manage.ts b/Backend/src/routes/api/account/manage.ts index fc9ad6a..c17ca5e 100644 --- a/Backend/src/routes/api/account/manage.ts +++ b/Backend/src/routes/api/account/manage.ts @@ -34,17 +34,17 @@ export = new fileRouter.Path("/") // Don't include the hash for security }, }, - functions:{ - include:{ - files:true, - triggers:true, - TriggerLog:true - } - }, - namespaces:true, + functions: { + include: { + files: true, + triggers: true, + TriggerLog: true, + }, + }, + namespaces: true, accessTokens: true, guestUsers: true, - storages: true + storages: true, }, }); @@ -61,28 +61,33 @@ export = new fileRouter.Path("/") id: userData.id, email: userData.email, displayName: userData.displayName, - avatar_url: userData.avatar_url, createdAt: userData.createdAt, updatedAt: userData.updatedAt, }, - functions: userData.functions, - namespaces: userData.namespaces, + functions: userData.functions, + namespaces: userData.namespaces, sessions: userData.sessions, exportedAt: new Date().toISOString(), exportVersion: "1.0", guestUsers: userData.guestUsers, storages: userData.storages, - accessTokens: userData.accessTokens + accessTokens: userData.accessTokens, }; // Set headers for file download ctr.headers.set("Content-Type", "application/json"); - ctr.headers.set("Content-Disposition", `attachment; filename="shsf-account-export-${new Date().toISOString().split('T')[0]}.json"`); - ctr.headers.set("Content-Length", Buffer.byteLength(JSON.stringify(exportData)).toString()); + ctr.headers.set( + "Content-Disposition", + `attachment; filename="shsf-account-export-${new Date().toISOString().split("T")[0]}.json"`, + ); + ctr.headers.set( + "Content-Length", + Buffer.byteLength(JSON.stringify(exportData)).toString(), + ); ctr.headers.set("X-Content-Type-Options", "nosniff"); return ctr.print(exportData); - }) + }), ) .http("DELETE", "/api/account/delete", (http) => http @@ -91,8 +96,8 @@ export = new fileRouter.Path("/") const [data, error] = await ctr.bindBody((z) => z.object({ password: z.string().min(8).max(120), - confirmation: z.literal("DELETE_MY_ACCOUNT") - }) + confirmation: z.literal("DELETE_MY_ACCOUNT"), + }), ); if (!data) @@ -118,7 +123,10 @@ export = new fileRouter.Path("/") }); } - const passwordMatch = await bcrypt.compare(data.password, authCheck.user.password); + const passwordMatch = await bcrypt.compare( + data.password, + authCheck.user.password, + ); if (!passwordMatch) { return ctr.status(ctr.$status.UNAUTHORIZED).print({ @@ -136,7 +144,7 @@ export = new fileRouter.Path("/") id: true, }, }); - + const functionIds = userFunctions.map((f) => f.id); for (const functionId of functionIds) { // Clean up Docker container; Also deletes function files @@ -157,5 +165,5 @@ export = new fileRouter.Path("/") status: "OK", message: "Account deleted successfully", }); - }) + }), ); diff --git a/Backend/src/routes/api/account/register.ts b/Backend/src/routes/api/account/register.ts index fee0d8a..96317bf 100644 --- a/Backend/src/routes/api/account/register.ts +++ b/Backend/src/routes/api/account/register.ts @@ -15,31 +15,29 @@ export = new fileRouter.Path("/").http( return ctr .status(ctr.$status.BAD_REQUEST) .print("Registration is disabled"); - } + } const [data, error] = await ctr.bindBody((z) => z.object({ display_name: z.string().max(128), email: z.string().email().max(200), password: z.string().min(8).max(120), - password_confirm: z.string().min(8).max(120) - }) + password_confirm: z.string().min(8).max(120), + }), ); if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); if (data.password !== data.password_confirm) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Passwords do not match"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Passwords do not match"); } if (ctr.cookies.has(COOKIE)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - message: "You are already logged in", - status: "FAILED", - }); - } + return ctr.status(ctr.$status.BAD_REQUEST).print({ + message: "You are already logged in", + status: "FAILED", + }); + } // ! Make sure user does NOT exist const email_check = await prisma.user.findFirst({ @@ -50,9 +48,7 @@ export = new fileRouter.Path("/").http( if (email_check) { ctr.clearRateLimit(); - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Email already in use"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Email already in use"); } if (data.display_name.length < 3) { @@ -62,10 +58,7 @@ export = new fileRouter.Path("/").http( .print("Display Name must be at least 3 characters long"); } - if ( - data.display_name.includes("[") || - data.display_name.includes("]") - ) { + if (data.display_name.includes("[") || data.display_name.includes("]")) { return ctr.status(400).print("Display Name can not contain [ or ]"); } if (data.display_name === "") { @@ -77,21 +70,15 @@ export = new fileRouter.Path("/").http( if (data.email === "") { ctr.clearRateLimit(); - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Email cannot be empty"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Email cannot be empty"); } const password_hash = await bcrypt.hash(data.password, 10); const hash = createHash("sha256") - .update( - `${Date.now()}+${data.email}+${randomBytes(16).toString("hex")}` - ) + .update(`${Date.now()}+${data.email}+${randomBytes(16).toString("hex")}`) .digest("hex"); - const icon_url = "https://cdn.reversed.dev/pictures/default.png"; - const first_user = (await prisma.user.count()) === 0; const user = await prisma.user @@ -100,27 +87,29 @@ export = new fileRouter.Path("/").http( displayName: data.display_name, email: data.email, password: password_hash, - avatar_url: icon_url, role: first_user ? "Admin" : "User", sessions: { create: { - hash + hash, }, }, }, }) .catch(async (e) => { ctr.clearRateLimit(); - ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: e.toString() }); + ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: "FAILED", message: e.toString() }); return null; }); if (!user) return; - let newdomain=""; + let newdomain = ""; if (DOMAIN === "localhost") { newdomain = "localhost"; } else { - if (DOMAIN.split(".").length > 2) { // we are on a subdomain + if (DOMAIN.split(".").length > 2) { + // we are on a subdomain newdomain = DOMAIN.split(".").slice(1).join("."); // Ex. "sub.domain.com" => "domain.com" } else { newdomain = "." + DOMAIN; @@ -128,12 +117,12 @@ export = new fileRouter.Path("/").http( } ctr.cookies.set( - COOKIE, - new Cookie(hash, { - domain: newdomain, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days - }) - ); + COOKIE, + new Cookie(hash, { + domain: newdomain, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days + }), + ); ctr.print({ status: "OK", @@ -145,5 +134,5 @@ export = new fileRouter.Path("/").http( }, }); return; - }) + }), ); diff --git a/Backend/src/routes/api/files.ts b/Backend/src/routes/api/files.ts index de0c339..ec95f54 100644 --- a/Backend/src/routes/api/files.ts +++ b/Backend/src/routes/api/files.ts @@ -9,360 +9,354 @@ const docker = new Docker(); // Helper function to update container dependencies when key files change async function updateContainerDependencies( - functionId: number, - filename: string, - content: string + functionId: number, + filename: string, + content: string, ) { - // Only process dependency files - if (filename !== "requirements.txt" && filename !== "package.json") { - return; - } + // Only process dependency files + if (filename !== "requirements.txt" && filename !== "package.json") { + return; + } - const containerName = `shsf_func_${functionId}`; - try { - const container = docker.getContainer(containerName); - await container.restart(); - console.log( - `[SHSF] Restarted container ${containerName} due to changes in ${filename}` - ); - return true; - } catch (err) { - console.error(`[SHSF] Failed to restart container ${containerName}:`, err); - return false; - } + const containerName = `shsf_func_${functionId}`; + try { + const container = docker.getContainer(containerName); + await container.restart(); + console.log( + `[SHSF] Restarted container ${containerName} due to changes in ${filename}`, + ); + return true; + } catch (err) { + console.error(`[SHSF] Failed to restart container ${containerName}:`, err); + return false; + } } export = new fileRouter.Path("/") - .http("PUT", "/api/function/{id}/file", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); + .http("PUT", "/api/function/{id}/file", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const [data, error] = await ctr.bindBody((z) => - z.object({ - filename: z.string().min(1).max(256), - code: z.string(), - }) - ); + const [data, error] = await ctr.bindBody((z) => + z.object({ + filename: z.string().min(1).max(256), + code: z.string(), + }), + ); - if (!data) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: error.toString(), - }); - } + if (!data) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: error.toString(), + }); + } - const DisallowedFiles = ["_runner.py", "_runner.js", "init.sh"]; - if (DisallowedFiles.includes(data.filename)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: `File name "${data.filename}" is not allowed`, - }); - } + const DisallowedFiles = ["_runner.py", "_runner.js", "init.sh"]; + if (DisallowedFiles.includes(data.filename)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: `File name "${data.filename}" is not allowed`, + }); + } - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } - const existingFile = await prisma.functionFile.findFirst({ - where: { - functionId: functionId, - name: data.filename, - }, - }); + const existingFile = await prisma.functionFile.findFirst({ + where: { + functionId: functionId, + name: data.filename, + }, + }); - let out; - if (existingFile) { - out = await prisma.functionFile.update({ - where: { - id: existingFile.id, - }, - data: { - content: data.code, - }, - }); - } else { - out = await prisma.functionFile.create({ - data: { - name: data.filename, - content: data.code, - functionId: functionId, - }, - }); - } + let out; + if (existingFile) { + out = await prisma.functionFile.update({ + where: { + id: existingFile.id, + }, + data: { + content: data.code, + }, + }); + } else { + out = await prisma.functionFile.create({ + data: { + name: data.filename, + content: data.code, + functionId: functionId, + }, + }); + } - return ctr.print({ - status: "OK", - data: out, - }); - }) - ) - .http("GET", "/api/function/{id}/files", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); + return ctr.print({ + status: "OK", + data: out, + }); + }), + ) + .http("GET", "/api/function/{id}/files", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } - const files = await prisma.functionFile.findMany({ - where: { - functionId: functionId, - }, - }); + const files = await prisma.functionFile.findMany({ + where: { + functionId: functionId, + }, + }); - return ctr.print({ - status: "OK", - data: files, - }); - }) - ) - .http("DELETE", "/api/function/{id}/file/{fileId}", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); + return ctr.print({ + status: "OK", + data: files, + }); + }), + ) + .http("DELETE", "/api/function/{id}/file/{fileId}", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const id = ctr.params.get("id"); - const fileId = ctr.params.get("fileId"); + const id = ctr.params.get("id"); + const fileId = ctr.params.get("fileId"); - if (!id || !fileId) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id or file id", - }); - } + if (!id || !fileId) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id or file id", + }); + } - const functionId = parseInt(id); - const fileIdInt = parseInt(fileId); + const functionId = parseInt(id); + const fileIdInt = parseInt(fileId); - if (isNaN(functionId) || isNaN(fileIdInt)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id or file id", - }); - } + if (isNaN(functionId) || isNaN(fileIdInt)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id or file id", + }); + } - const totalFiles = await prisma.functionFile.count({ - where: { - functionId: functionId, - }, - }); + const totalFiles = await prisma.functionFile.count({ + where: { + functionId: functionId, + }, + }); - if (totalFiles <= 1) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Cannot delete the only file in the function", - }); - } + if (totalFiles <= 1) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Cannot delete the only file in the function", + }); + } - const fileToDelete = await prisma.functionFile.findUnique({ - where: { id: fileIdInt }, - }); + const fileToDelete = await prisma.functionFile.findUnique({ + where: { id: fileIdInt }, + }); - await prisma.functionFile.delete({ - where: { - id: fileIdInt, - }, - }); + await prisma.functionFile.delete({ + where: { + id: fileIdInt, + }, + }); - // If deleting a dependencies file, we should create an empty one - // to prevent broken deployments - if ( - fileToDelete && - (fileToDelete.name === "requirements.txt" || - fileToDelete.name === "package.json") - ) { - const funcAppDir = path.join( - "/opt/shsf_data/functions", - String(functionId), - "app" - ); - try { - await fs.writeFile( - path.join(funcAppDir, fileToDelete.name), - "# File was deleted\n" - ); - console.log( - `[SHSF] Created empty ${fileToDelete.name} after deletion to prevent broken deployments` - ); - } catch (err) { - console.error( - `[SHSF] Error creating empty ${fileToDelete.name}:`, - err - ); - } - } + // If deleting a dependencies file, we should create an empty one + // to prevent broken deployments + if ( + fileToDelete && + (fileToDelete.name === "requirements.txt" || + fileToDelete.name === "package.json") + ) { + const funcAppDir = path.join( + "/opt/shsf_data/functions", + String(functionId), + "app", + ); + try { + await fs.writeFile( + path.join(funcAppDir, fileToDelete.name), + "# File was deleted\n", + ); + console.log( + `[SHSF] Created empty ${fileToDelete.name} after deletion to prevent broken deployments`, + ); + } catch (err) { + console.error(`[SHSF] Error creating empty ${fileToDelete.name}:`, err); + } + } - return ctr.print({ - status: "OK", - message: "File deleted successfully", - }); - }) - ) - .http("PATCH", "/api/function/{id}/file/{fileId}/rename", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); + return ctr.print({ + status: "OK", + message: "File deleted successfully", + }); + }), + ) + .http("PATCH", "/api/function/{id}/file/{fileId}/rename", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const id = ctr.params.get("id"); - const fileId = ctr.params.get("fileId"); + const id = ctr.params.get("id"); + const fileId = ctr.params.get("fileId"); - if (!id || !fileId) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id or file id", - }); - } + if (!id || !fileId) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id or file id", + }); + } - const [data, error] = await ctr.bindBody((z) => - z.object({ - newFilename: z.string().min(1).max(256), - }) - ); + const [data, error] = await ctr.bindBody((z) => + z.object({ + newFilename: z.string().min(1).max(256), + }), + ); - if (!data) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: error.toString(), - }); - } + if (!data) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: error.toString(), + }); + } - const functionId = parseInt(id); - const fileIdInt = parseInt(fileId); + const functionId = parseInt(id); + const fileIdInt = parseInt(fileId); - if (isNaN(functionId) || isNaN(fileIdInt)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id or file id", - }); - } + if (isNaN(functionId) || isNaN(fileIdInt)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id or file id", + }); + } - const oldFile = await prisma.functionFile.findUnique({ - where: { id: fileIdInt }, - }); + const oldFile = await prisma.functionFile.findUnique({ + where: { id: fileIdInt }, + }); - const updatedFile = await prisma.functionFile.update({ - where: { - id: fileIdInt, - }, - data: { - name: data.newFilename, - }, - }); + const updatedFile = await prisma.functionFile.update({ + where: { + id: fileIdInt, + }, + data: { + name: data.newFilename, + }, + }); - // Handle renames of dependency files, which requires updating files on disk too - if ( - oldFile && - (oldFile.name === "requirements.txt" || - oldFile.name === "package.json" || - data.newFilename === "requirements.txt" || - data.newFilename === "package.json") - ) { - const funcAppDir = path.join( - "/opt/shsf_data/functions", - String(functionId), - "app" - ); - try { - // If renaming away from a dependency file, create an empty one - if ( - oldFile.name === "requirements.txt" || - oldFile.name === "package.json" - ) { - await fs.writeFile( - path.join(funcAppDir, oldFile.name), - "# File was renamed\n" - ); - console.log( - `[SHSF] Created empty ${oldFile.name} after rename to prevent broken deployments` - ); - } + // Handle renames of dependency files, which requires updating files on disk too + if ( + oldFile && + (oldFile.name === "requirements.txt" || + oldFile.name === "package.json" || + data.newFilename === "requirements.txt" || + data.newFilename === "package.json") + ) { + const funcAppDir = path.join( + "/opt/shsf_data/functions", + String(functionId), + "app", + ); + try { + // If renaming away from a dependency file, create an empty one + if ( + oldFile.name === "requirements.txt" || + oldFile.name === "package.json" + ) { + await fs.writeFile( + path.join(funcAppDir, oldFile.name), + "# File was renamed\n", + ); + console.log( + `[SHSF] Created empty ${oldFile.name} after rename to prevent broken deployments`, + ); + } - // If renaming to a dependency file, write the content and update dependencies - if ( - data.newFilename === "requirements.txt" || - data.newFilename === "package.json" - ) { - await fs.writeFile( - path.join(funcAppDir, data.newFilename), - updatedFile.content - ); - await updateContainerDependencies( - functionId, - data.newFilename, - updatedFile.content - ); - } - } catch (err) { - console.error( - `[SHSF] Error handling rename of dependency file:`, - err - ); - } - } + // If renaming to a dependency file, write the content and update dependencies + if ( + data.newFilename === "requirements.txt" || + data.newFilename === "package.json" + ) { + await fs.writeFile( + path.join(funcAppDir, data.newFilename), + updatedFile.content, + ); + await updateContainerDependencies( + functionId, + data.newFilename, + updatedFile.content, + ); + } + } catch (err) { + console.error(`[SHSF] Error handling rename of dependency file:`, err); + } + } - return ctr.print({ - status: "OK", - data: updatedFile, - }); - }) - ); + return ctr.print({ + status: "OK", + data: updatedFile, + }); + }), + ); diff --git a/Backend/src/routes/api/functions.ts b/Backend/src/routes/api/functions.ts index d6759d8..aaf245c 100644 --- a/Backend/src/routes/api/functions.ts +++ b/Backend/src/routes/api/functions.ts @@ -1,19 +1,19 @@ import { randomUUID } from "crypto"; import { - API_KEY_HEADER, - COOKIE, - fileRouter, - prisma, - REACT_APP_API_URL, - UI_URL, + API_KEY_HEADER, + COOKIE, + fileRouter, + prisma, + REACT_APP_API_URL, + UI_URL, } from "../.."; import { checkAuthentication } from "../../lib/Authentication"; import { - buildPayloadFromGET, - buildPayloadFromPOST, - cleanupFunctionContainer, - executeFunction, - installDependencies, + buildPayloadFromGET, + buildPayloadFromPOST, + cleanupFunctionContainer, + executeFunction, + installDependencies, } from "../../lib/Runner"; import Docker from "dockerode"; import { env } from "process"; @@ -21,14 +21,14 @@ import { Cookie } from "rjweb-server"; import { Function, FunctionFile } from "@prisma/client"; const Images: string[] = [ - // Python versions - "python:3.9", - "python:3.10", - "python:3.11", - "python:3.12", - "python:3.13", - "python:3.14", - "python:3.15", + // Python versions + "python:3.9", + "python:3.10", + "python:3.11", + "python:3.12", + "python:3.13", + "python:3.14", + "python:3.15", ]; // Create Docker client instance for container management @@ -36,1840 +36,1837 @@ const docker = new Docker(); // Helper function for HTTP execution permission (guest/auth logic) async function checkHttpExecutionPermission( - ctr: any, - functionData: { - files: FunctionFile[]; - namespace: { id: number; name: string }; - } & Function, - namespaceId: number, - functionId: string + ctr: any, + functionData: { + files: FunctionFile[]; + namespace: { id: number; name: string }; + } & Function, + namespaceId: number, + functionId: string, ) { - // Returns: { state: boolean, reason: string, redirect?: string } - // Handles secure_header, x-access-key, guest users/cookies, and sets cookies if needed - let permissionToExecute: { - state: boolean; - reason: string; - redirect?: string; - } = { - state: true, - reason: "", - }; + // Returns: { state: boolean, reason: string, redirect?: string } + // Handles secure_header, x-access-key, guest users/cookies, and sets cookies if needed + let permissionToExecute: { + state: boolean; + reason: string; + redirect?: string; + } = { + state: true, + reason: "", + }; - // Secure header check - if (functionData.secure_header) { - if (!ctr.headers.has("x-secure-header")) { - permissionToExecute = { state: false, reason: "Missing secure header" }; - } else { - const secureHeader = ctr.headers.get("x-secure-header"); - if (secureHeader !== functionData.secure_header) { - permissionToExecute = { state: false, reason: "Invalid secure header" }; - } - } - } + // Secure header check + if (functionData.secure_header) { + if (!ctr.headers.has("x-secure-header")) { + permissionToExecute = { state: false, reason: "Missing secure header" }; + } else { + const secureHeader = ctr.headers.get("x-secure-header"); + if (secureHeader !== functionData.secure_header) { + permissionToExecute = { state: false, reason: "Invalid secure header" }; + } + } + } - // API key check - if (ctr.headers.has("x-access-key")) { - const accessKey = ctr.headers.get("x-access-key") || ""; - const authState = await checkAuthentication(null, accessKey); - if (authState.success && authState.method === "apiKey") { - if (authState.user.id === functionData.userId) { - permissionToExecute = { - state: true, - reason: "Provided API Key and owns the function", - }; - } else { - permissionToExecute = { - state: false, - reason: "Provided Access Token, but does not own the function", - }; - } - } else { - permissionToExecute = { - state: false, - reason: "Provided Access Token, but it's invalid", - }; - } - } + // API key check + if (ctr.headers.has("x-access-key")) { + const accessKey = ctr.headers.get("x-access-key") || ""; + const authState = await checkAuthentication(null, accessKey); + if (authState.success && authState.method === "apiKey") { + if (authState.user.id === functionData.userId) { + permissionToExecute = { + state: true, + reason: "Provided API Key and owns the function", + }; + } else { + permissionToExecute = { + state: false, + reason: "Provided Access Token, but does not own the function", + }; + } + } else { + permissionToExecute = { + state: false, + reason: "Provided Access Token, but it's invalid", + }; + } + } - // Guest user logic - const guests = await prisma.guestUser.findMany({ - where: { - permittedFunctions: { array_contains: [functionData.id] }, - guestOwnerId: functionData.userId, - }, - }); - if (guests.length > 0) { - permissionToExecute = { - state: false, - reason: - "Function has guest users assigned, authentication required now", - }; - // Check for guest cookie - const guestCookie = ctr.cookies.get( - `shsf_guest_${namespaceId}_${functionId}` - ); - if (guestCookie) { - const guestSession = await prisma.guestSession.findFirst({ - where: { hash: guestCookie }, - include: { guestUser: true }, - }); - if (guestSession) { - const now = new Date(); - if (guestSession.expiresAt < now) { - permissionToExecute = { - state: false, - reason: "Guest session has expired", - }; - await prisma.guestSession.delete({ where: { id: guestSession.id } }); - ctr.cookies.set( - `shsf_guest_${namespaceId}_${functionId}`, - new Cookie("", { - domain: REACT_APP_API_URL.replace("https://", "") - .replace("http://", "") - .replace("/", ""), - expires: new Date(Date.now()), - }) - ); - permissionToExecute.redirect = undefined; - } else if ( - !guests.map((g) => g.id).includes(guestSession.guestUser.id) - ) { - permissionToExecute = { - state: false, - reason: - "Guest user does not have permission to access this function. [FORCE RELOAD]", - }; - ctr.cookies.set( - `shsf_guest_${namespaceId}_${functionId}`, - new Cookie("", { - domain: REACT_APP_API_URL.replace("https://", "") - .replace("http://", "") - .replace("/", ""), - expires: new Date(Date.now()), - }) - ); - permissionToExecute.redirect = `${REACT_APP_API_URL}/api/exec/${namespaceId}/${functionId}`; - } else { - permissionToExecute = { state: true, reason: "Valid guest cookie" }; - } - } else { - permissionToExecute = { state: false, reason: "Invalid guest cookie" }; - ctr.cookies.set( - `shsf_guest_${namespaceId}_${functionId}`, - new Cookie("", { - domain: REACT_APP_API_URL.replace("https://", "") - .replace("http://", "") - .replace("/", ""), - expires: new Date(Date.now()), - }) - ); - } - } else { - permissionToExecute = { state: false, reason: "Missing guest cookie" }; - permissionToExecute.redirect = - UI_URL + - "/guest-access?nsp=" + - functionData.namespaceId + - "&func=" + - functionData.executionId; - } - } + // Guest user logic + const guests = await prisma.guestUser.findMany({ + where: { + permittedFunctions: { array_contains: [functionData.id] }, + guestOwnerId: functionData.userId, + }, + }); + if (guests.length > 0) { + permissionToExecute = { + state: false, + reason: "Function has guest users assigned, authentication required now", + }; + // Check for guest cookie + const guestCookie = ctr.cookies.get( + `shsf_guest_${namespaceId}_${functionId}`, + ); + if (guestCookie) { + const guestSession = await prisma.guestSession.findFirst({ + where: { hash: guestCookie }, + include: { guestUser: true }, + }); + if (guestSession) { + const now = new Date(); + if (guestSession.expiresAt < now) { + permissionToExecute = { + state: false, + reason: "Guest session has expired", + }; + await prisma.guestSession.delete({ where: { id: guestSession.id } }); + ctr.cookies.set( + `shsf_guest_${namespaceId}_${functionId}`, + new Cookie("", { + domain: REACT_APP_API_URL.replace("https://", "") + .replace("http://", "") + .replace("/", ""), + expires: new Date(Date.now()), + }), + ); + permissionToExecute.redirect = undefined; + } else if (!guests.map((g) => g.id).includes(guestSession.guestUser.id)) { + permissionToExecute = { + state: false, + reason: + "Guest user does not have permission to access this function. [FORCE RELOAD]", + }; + ctr.cookies.set( + `shsf_guest_${namespaceId}_${functionId}`, + new Cookie("", { + domain: REACT_APP_API_URL.replace("https://", "") + .replace("http://", "") + .replace("/", ""), + expires: new Date(Date.now()), + }), + ); + permissionToExecute.redirect = `${REACT_APP_API_URL}/api/exec/${namespaceId}/${functionId}`; + } else { + permissionToExecute = { state: true, reason: "Valid guest cookie" }; + } + } else { + permissionToExecute = { state: false, reason: "Invalid guest cookie" }; + ctr.cookies.set( + `shsf_guest_${namespaceId}_${functionId}`, + new Cookie("", { + domain: REACT_APP_API_URL.replace("https://", "") + .replace("http://", "") + .replace("/", ""), + expires: new Date(Date.now()), + }), + ); + } + } else { + permissionToExecute = { state: false, reason: "Missing guest cookie" }; + permissionToExecute.redirect = + UI_URL + + "/guest-access?nsp=" + + functionData.namespaceId + + "&func=" + + functionData.executionId; + } + } - return permissionToExecute; + return permissionToExecute; } export = new fileRouter.Path("/") - .http("POST", "/api/function", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - name: z.string().min(1).max(128), - description: z.string().min(3).max(128), - image: z.enum(Images as any), - startup_file: z.string().min(1).max(256).optional(), - docker_mount: z.boolean().optional(), - ffmpeg_install: z.boolean().optional(), - executionAlias: z.string().min(8).max(128).regex(/^[a-zA-Z0-9-_]+$/).optional(), // Only allow alphanumeric, hyphens, and underscores - settings: z - .object({ - max_ram: z.number().min(128).max(1024).optional(), - timeout: z.number().positive().min(1).max(300).optional(), // Increased max timeout to 300 seconds : 5 minutes - allow_http: z.boolean().optional(), - secure_header: z.string().min(1).max(256).optional(), - tags: z.array(z.string().min(1).max(32)).optional(), - retry_on_failure: z.boolean().optional(), - retry_count: z.number().min(1).max(10).positive().optional(), - }) - .optional(), - environment: z - .array( - z - .object({ - name: z.string().min(1).max(128), - value: z.string().min(1).max(256), - }) - .optional() - ) - .optional(), - namespaceId: z.number(), - cors_origins: z.string().max(2048).optional(), // Accept CORS origins as string - }) - ); - - if (!data) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: error.toString(), - }); - } - - if (!Images.includes(data.image)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid image", - }); - } - - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const namespace = await prisma.namespace.findFirst({ - where: { - id: data.namespaceId, - userId: authCheck.user.id, - }, - }); - if (!namespace) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Namespace not found", - }); - } - - const existingFunction = await prisma.function.findFirst({ - where: { - name: data.name, - namespaceId: data.namespaceId, - userId: authCheck.user.id, - }, - }); - if (existingFunction) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Function with this name already exists in this namespace", - }); - } - - // Check for duplicate executionAlias before creating - if (data.executionAlias) { - const aliasExists = await prisma.function.findFirst({ - where: { - executionAlias: data.executionAlias, - }, - }); - if (aliasExists) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Function with this executionAlias already exists", - }); - } - } - - const out = await prisma.function.create({ - data: { - description: data.description, - namespaceId: data.namespaceId, - name: data.name, - image: data.image, - startup_file: data.startup_file, - tags: data.settings?.tags?.join(",") || "", - allow_http: data.settings?.allow_http, - max_ram: data.settings?.max_ram, - timeout: data.settings?.timeout, - secure_header: data.settings?.secure_header, - retry_on_failure: data.settings?.retry_on_failure, - max_retries: data.settings?.retry_count, - env: data.environment - ? JSON.stringify( - data.environment.map((env) => ({ - name: env!.name, - value: env!.value, - })) - ) - : undefined, - userId: authCheck.user.id, - executionId: randomUUID(), - docker_mount: data.docker_mount || false, - ffmpeg_install: data.ffmpeg_install || false, - cors_origins: data.cors_origins, - executionAlias: data.executionAlias, - }, - }); - - return ctr.print({ - status: "OK", - data: { - id: out.id, - }, - }); - }) - ) - .http("DELETE", "/api/function/{id}", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - // Delete the function from database - await prisma.function.delete({ - where: { - id: functionData.id, - }, - }); - - // Clean up the container and associated files - await cleanupFunctionContainer(functionId); - - return ctr.print({ - status: "OK", - message: "Function deleted", - }); - }) - ) - .http("GET", "/api/functions", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const functions = await prisma.function.findMany({ - where: { - userId: authCheck.user.id, - }, - include: { - namespace: { - select: { - name: true, - id: true, - }, - }, - }, - }); - - return ctr.print({ - status: "OK", - data: functions, - }); - }) - ) - .http("GET", "/api/function/{id}", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - include: { - namespace: { - select: { - name: true, - id: true, - }, - }, - }, - }); - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - return ctr.print({ - status: "OK", - data: functionData, - }); - }) - ) - .http("GET", "/api/function/{id}/logs", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const logs = await prisma.triggerLog.findMany({ - where: { - functionId: functionId, - createdAt: { - gte: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), // 7 days - }, - }, - orderBy: { - createdAt: "desc", - }, - }); - if (!logs) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "No logs found", - }); - } - - return ctr.print({ - status: "OK", - data: logs, - }); - }) - ) - .http("PATCH", "/api/function/{id}", (http) => - http.onRequest(async (ctr) => { - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const [data, error] = await ctr.bindBody((z) => - z.object({ - name: z.string().min(1).max(128).optional(), - description: z.string().min(3).max(128).optional(), - image: z.enum(Images as any).optional(), - startup_file: z.string().min(1).max(256).optional(), - executionAlias: z.string().min(8).max(128).regex(/^[a-zA-Z0-9-_]+$/).optional(), // Only allow alphanumeric, hyphens, and underscores - docker_mount: z.boolean().optional(), - ffmpeg_install: z.boolean().optional(), - settings: z - .object({ - max_ram: z.number().min(128).max(1024).optional(), - timeout: z.number().positive().min(1).max(500).optional(), - allow_http: z.boolean().optional(), - secure_header: z.string().min(1).max(256).optional().or(z.null()), - tags: z.array(z.string().min(1).max(32)).optional(), - retry_on_failure: z.boolean().optional(), - retry_count: z.number().min(1).max(10).positive().optional(), - }) - .optional(), - environment: z - .array( - z - .object({ - name: z.string().min(1).max(128), - value: z.string().min(1).max(256), - }) - .optional() - ) - .optional(), - cors_origins: z.string().max(2048).optional(), - }) - ); - - if (!data) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: error.toString(), - }); - } - - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const existingFunction = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - - if (!existingFunction) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - // Check for duplicate executionAlias before updating - if (data.executionAlias !== undefined) { - const aliasExists = await prisma.function.findFirst({ - where: { - executionAlias: data.executionAlias, - // Exclude current function - NOT: { id: functionId }, - }, - }); - if (aliasExists) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Another function with this executionAlias already exists", - }); - } - } - - const updatedData: any = { - ...(data.name && { name: data.name }), - ...(data.description && { description: data.description }), - ...(data.image && { image: data.image }), - ...(data.startup_file && { startup_file: data.startup_file }), - ...(data.settings?.tags && { - tags: data.settings.tags.join(","), - }), - ...(data.settings?.allow_http !== undefined && { - allow_http: data.settings.allow_http, - }), - ...(data.settings?.max_ram && { max_ram: data.settings.max_ram }), - ...(data.settings?.timeout && { timeout: data.settings.timeout }), - ...(data.settings?.secure_header !== undefined && { - secure_header: data.settings.secure_header, - }), - ...(data.settings?.retry_on_failure !== undefined && { - retry_on_failure: data.settings.retry_on_failure, - }), - ...(data.settings?.retry_count && { - max_retries: data.settings.retry_count, - }), - ...(data.environment && { - env: JSON.stringify( - data.environment.map((env) => ({ - name: env!.name, - value: env!.value, - })) - ), - }), - ...(data.docker_mount !== undefined && { - docker_mount: data.docker_mount, - }), - ...(data.ffmpeg_install !== undefined && { - ffmpeg_install: data.ffmpeg_install, - }), - ...(data.cors_origins !== undefined && { - cors_origins: data.cors_origins, - }), - ...(data.executionAlias !== undefined && { - executionAlias: data.executionAlias, - }), - }; - - // Track if relaunch is triggered - let relaunchTriggered = false; - - // If image is being changed, we need to recreate the container; Or docker_mount/ffmpeg_install changed - if ( - (data.image && data.image !== existingFunction.image) || - (data.docker_mount !== undefined && - data.docker_mount !== existingFunction.docker_mount) || - (data.ffmpeg_install !== undefined && - data.ffmpeg_install !== existingFunction.ffmpeg_install) - ) { - relaunchTriggered = true; // Set flag regardless of container existence - // Check if a container exists for this function before cleanup - try { - const containers = await docker.listContainers({ - all: true, - filters: { - label: [`functionId=${functionId}`], - }, - }); - if (containers.length > 0) { - if (data.image && data.image !== existingFunction.image) { - console.log( - `[SHSF] Function ${functionId} image changing from ${existingFunction.image} to ${data.image}, container will be recreated` - ); - } else if ( - data.docker_mount !== undefined && - data.docker_mount !== existingFunction.docker_mount - ) { - console.log( - `[SHSF] Function ${functionId} docker_mount changing from ${existingFunction.docker_mount} to ${data.docker_mount}, container will be recreated` - ); - } else if ( - data.ffmpeg_install !== undefined && - data.ffmpeg_install !== existingFunction.ffmpeg_install - ) { - console.log( - `[SHSF] Function ${functionId} ffmpeg_install changing from ${existingFunction.ffmpeg_install} to ${data.ffmpeg_install}, container will be recreated` - ); - } - // Clean up existing container to force recreation with new image, docker_mount, or ffmpeg_install change - await cleanupFunctionContainer(functionId); - // On the next run, the container will be recreated with the new image and new mounts. - } - } catch (err) { - console.error( - `[SHSF] Error checking/cleaning up container for function ${functionId}:`, - err - ); - } - } - - const updatedFunction = await prisma.function.update({ - where: { - id: functionId, - }, - data: updatedData, - }); - - // UI confirmation: inform if relaunch started - type PatchFunctionResponse = { - status: string; - data: typeof updatedFunction; - relaunch?: string; - }; - - const response: PatchFunctionResponse = { - status: "OK", - data: updatedFunction, - ...(relaunchTriggered && { - relaunch: - "Container relaunch started (will be recreated on next execution).", - }), - }; - - return ctr.print(response); - }) - ) - .http("POST", "/api/function/{id}/execute", (http) => - http.onRequest(async (ctr) => { - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - // Extract optional run parameter from request body - const [runData] = await ctr.bindBody((z) => - z - .object({ - run: z.any().optional(), - }) - .optional() - ); - - // Convert run data to string for passing to executeFunction - const runPayload = JSON.stringify({ - // if there is a .method we will remove it - body: runData?.run ? runData.run : {}, - headers: Object.fromEntries(ctr.headers.entries()), - queries: Object.fromEntries(ctr.queries.entries()), - raw_body: await ctr.$body().text(), - source_ip: ctr.client.ip.usual(), - route: runData?.run - ? runData.run.route - ? runData.run.route - : "default" - : "default", - method: runData?.run - ? runData.run.method - ? runData.run.method - : "POST" - : "POST", - }); - - const functionData = await prisma.function.findFirst({ - where: { - id: functionId, - }, - }); - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: "Unauthorized", - }); - } - - const files = await prisma.functionFile.findMany({ - where: { - functionId: functionData.id, - }, - }); - if (!files || files.length === 0) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function has no files", - }); - } - - // Check execution mode from query parameter - const streamMode = ctr.queries.get("stream") !== "false"; - - try { - if (streamMode) { - // Streaming mode - return ctr.printChunked( - (print) => - new Promise((end) => { - let output = ""; - executeFunction( - functionId, - functionData, - files, - { - enabled: true, - onChunk: async (text) => { - output += text; - // Ensure text is properly stringified before sending - await print( - JSON.stringify({ - type: "output", - content: text, - }) - ); - }, - }, - runPayload - ) - .then(async (result) => { - // Successfully completed - include result if available - await print( - JSON.stringify({ - type: "end", - exitCode: 0, - output: output, - result: result?.result, - took: result?.tooks, - }) - ); - end(); - }) - .catch(async (error) => { - // Handle errors - await print( - JSON.stringify({ - type: "error", - error: error.message || "Execution failed", - }) - ); - end(); - }); - - ctr.$abort(() => { - // Handle abort, nothing specific needed as Runner.ts handles cleanup - end(); - }); - }) - ); - } else { - // Synchronous mode - const result = await executeFunction( - functionId, - functionData, - files, - { enabled: false }, - runPayload - ); - - return ctr.print({ - status: "OK", - data: { - output: result?.logs || "No output", - exitCode: result?.exit_code || 0, - result: result?.result, - took: result?.tooks, - }, - }); - } - } catch (error: any) { - if (error.message === "Timeout") { - return ctr.status(ctr.$status.REQUEST_TIMEOUT).print({ - status: 408, - message: "Code execution timed out", - }); - } - return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ - status: 500, - message: "Failed to execute code", - error: error.message, - }); - } - }) - ) - .http("GET", "/api/exec/{namespaceId}/{functionId}", (http) => - http - .ratelimit((limit) => - limit - .hits(2) - .window(parseInt(env.RATELIMIT!) || 2000) - .penalty(1000) - ) - .onRequest(async (ctr) => { - const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); - const functionId = ctr.params.get("functionId") || ""; - - if (isNaN(namespaceId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid namespace", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - executionId: functionId, - namespaceId: namespaceId, - }, - include: { - namespace: { select: { name: true, id: true } }, - files: true, - }, - }); - - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - if (!functionData.allow_http) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: "HTTP execution is not allowed for this function", - }); - } - - // --- streamlined permission check --- - const permissionToExecute = await checkHttpExecutionPermission( - ctr, - functionData, - namespaceId, - functionId - ); - - if (permissionToExecute.redirect) { - return ctr - .status(ctr.$status.TEMPORARY_REDIRECT) - .redirect(permissionToExecute.redirect); - } - if (!permissionToExecute.state) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: permissionToExecute.reason, - }); - } - // --- end streamlined --- - - // Build the payload from GET request - const payload = await buildPayloadFromGET(ctr); - - // Execute with run parameter instead of inject.json - const result = await executeFunction( - functionData.id, - functionData, - functionData.files, - { enabled: false }, - JSON.stringify(payload) - ); - - // we might be able to do magic here - if (typeof result?.result === "object" && result?.result !== null) { - const out = result.result; // quicker to write and access - - if ("_shsf" in out) { - const version: "v2" = out._shsf; // always v2 currently - const headers: { key: string; value: any }[] | null = - "_headers" in out - ? Object.entries(out._headers).map(([key, value]) => ({ - key, - value, - })) - : null; - const response_code: number | null = - "_code" in out ? out._code : null; - const response: any | null = "_res" in out ? out._res : null; - - if (response_code === 301 || response_code === 302) { - // Handle redirects - ctr.status(response_code); - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - const link = "_location" in out ? out._location : "/"; - return ctr.redirect(link); - } - - ctr.status(response_code || 200); - - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - - if (response) { - return ctr.print(response); - } else { - return ctr.print("No Function Result :("); - } - } - } - - // Return result if available from main function, otherwise output OK - return ctr.print(result?.result ?? "No Function Result :("); - }) - ) - .http("POST", "/api/exec/{namespaceId}/{functionId}", (http) => - http - .ratelimit((limit) => - limit - .hits(2) - .window(parseInt(env.RATELIMIT!) || 2000) - .penalty(1000) - ) - .onRequest(async (ctr) => { - const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); - const functionId = ctr.params.get("functionId") || ""; - - if (isNaN(namespaceId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid namespace", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - executionId: functionId, - namespaceId: namespaceId, - }, - include: { - namespace: { select: { name: true, id: true } }, - files: true, - }, - }); - - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - if (!functionData.allow_http) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: "HTTP execution is not allowed for this function", - }); - } - - // --- streamlined permission check --- - const permissionToExecute = await checkHttpExecutionPermission( - ctr, - functionData, - namespaceId, - functionId - ); - - if (permissionToExecute.redirect) { - return ctr - .status(ctr.$status.TEMPORARY_REDIRECT) - .redirect(permissionToExecute.redirect); - } - if (!permissionToExecute.state) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: permissionToExecute.reason, - }); - } - // --- end streamlined --- - - // Build the payload from POST request - const payload = await buildPayloadFromPOST(ctr); - - const result = await executeFunction( - functionData.id, - functionData, - functionData.files, - { enabled: false }, - JSON.stringify(payload) - ); - - // we might be able to do magic here - if (typeof result?.result === "object" && result?.result !== null) { - const out = result.result; // quicker to write and access - - if ("_shsf" in out) { - const version: "v2" = out._shsf; // always v2 currently - const headers: { key: string; value: any }[] | null = - "_headers" in out - ? Object.entries(out._headers).map(([key, value]) => ({ - key, - value, - })) - : null; - const response_code: number | null = - "_code" in out ? out._code : null; - const response: any | null = "_res" in out ? out._res : null; - - if (response_code === 301 || response_code === 302) { - // Handle redirects - ctr.status(response_code); - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - const link = "_location" in out ? out._location : "/"; - return ctr.redirect(link); - } - - ctr.status(response_code || 200); - - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - - if (response) { - return ctr.print(response); - } else { - return ctr.print("No Function Result :("); - } - } - } - - return ctr.print(result?.result ?? "No Function Result :("); - }) - ) - .http("GET", "/exec/{executionAlias}", (http) => - http - .ratelimit((limit) => - limit - .hits(2) - .window(parseInt(env.RATELIMIT!) || 2000) - .penalty(1000) - ) - .onRequest(async (ctr) => { - const executionAlias = ctr.params.get("executionAlias") || ""; - - if (!executionAlias) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid execution alias", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - executionAlias: executionAlias, - }, - include: { - namespace: { select: { name: true, id: true } }, - files: true, - }, - }); - - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - if (!functionData.allow_http) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: "HTTP execution is not allowed for this function", - }); - } - - // --- streamlined permission check --- - const permissionToExecute = await checkHttpExecutionPermission( - ctr, - functionData, - functionData.namespaceId, - String(functionData.id) - ); - - if (permissionToExecute.redirect) { - return ctr - .status(ctr.$status.TEMPORARY_REDIRECT) - .redirect(permissionToExecute.redirect); - } - if (!permissionToExecute.state) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: permissionToExecute.reason, - }); - } - // --- end streamlined --- - - // Build the payload from GET request - const payload = await buildPayloadFromGET(ctr); - - // Execute with run parameter instead of inject.json - const result = await executeFunction( - functionData.id, - functionData, - functionData.files, - { enabled: false }, - JSON.stringify(payload) - ); - - // we might be able to do magic here - if (typeof result?.result === "object" && result?.result !== null) { - const out = result.result; // quicker to write and access - - if ("_shsf" in out) { - const version: "v2" = out._shsf; // always v2 currently - const headers: { key: string; value: any }[] | null = - "_headers" in out - ? Object.entries(out._headers).map(([key, value]) => ({ - key, - value, - })) - : null; - const response_code: number | null = - "_code" in out ? out._code : null; - const response: any | null = "_res" in out ? out._res : null; - - if (response_code === 301 || response_code === 302) { - // Handle redirects - ctr.status(response_code); - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - const link = "_location" in out ? out._location : "/"; - return ctr.redirect(link); - } - - ctr.status(response_code || 200); - - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - - if (response) { - return ctr.print(response); - } else { - return ctr.print("No Function Result :("); - } - } - } - - // Return result if available from main function, otherwise output OK - return ctr.print(result?.result ?? "No Function Result :("); - }) - ) - .http("POST", "/exec/{executionAlias}", (http) => - http - .ratelimit((limit) => - limit - .hits(2) - .window(parseInt(env.RATELIMIT!) || 2000) - .penalty(1000) - ) - .onRequest(async (ctr) => { - const executionAlias = ctr.params.get("executionAlias") || ""; - - if (!executionAlias) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid execution alias", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - executionAlias: executionAlias, - }, - include: { - namespace: { select: { name: true, id: true } }, - files: true, - }, - }); - - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - if (!functionData.allow_http) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: "HTTP execution is not allowed for this function", - }); - } - - // --- streamlined permission check --- - const permissionToExecute = await checkHttpExecutionPermission( - ctr, - functionData, - functionData.namespaceId, - String(functionData.id) - ); - - if (permissionToExecute.redirect) { - return ctr - .status(ctr.$status.TEMPORARY_REDIRECT) - .redirect(permissionToExecute.redirect); - } - if (!permissionToExecute.state) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: permissionToExecute.reason, - }); - } - // --- end streamlined --- - - // Build the payload from POST request - const payload = await buildPayloadFromPOST(ctr); - - const result = await executeFunction( - functionData.id, - functionData, - functionData.files, - { enabled: false }, - JSON.stringify(payload) - ); - - // we might be able to do magic here - if (typeof result?.result === "object" && result?.result !== null) { - const out = result.result; // quicker to write and access - - if ("_shsf" in out) { - const version: "v2" = out._shsf; // always v2 currently - const headers: { key: string; value: any }[] | null = - "_headers" in out - ? Object.entries(out._headers).map(([key, value]) => ({ - key, - value, - })) - : null; - const response_code: number | null = - "_code" in out ? out._code : null; - const response: any | null = "_res" in out ? out._res : null; - - if (response_code === 301 || response_code === 302) { - // Handle redirects - ctr.status(response_code); - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - const link = "_location" in out ? out._location : "/"; - return ctr.redirect(link); - } - - ctr.status(response_code || 200); - - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - - if (response) { - return ctr.print(response); - } else { - return ctr.print("No Function Result :("); - } - } - } - - return ctr.print(result?.result ?? "No Function Result :("); - }) - ) - .http("GET", "/api/exec/{namespaceId}/{functionId}/{route}", (http) => - http - .ratelimit((limit) => - limit - .hits(2) - .window(parseInt(env.RATELIMIT!) || 2000) - .penalty(1000) - ) - .onRequest(async (ctr) => { - const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); - const functionId = ctr.params.get("functionId") || ""; - - if (isNaN(namespaceId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid namespace", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - executionId: functionId, - namespaceId: namespaceId, - }, - include: { - namespace: { select: { name: true, id: true } }, - files: true, - }, - }); - - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - if (!functionData.allow_http) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: "HTTP execution is not allowed for this function", - }); - } - - // --- streamlined permission check --- - const permissionToExecute = await checkHttpExecutionPermission( - ctr, - functionData, - namespaceId, - functionId - ); - - if (permissionToExecute.redirect) { - return ctr - .status(ctr.$status.TEMPORARY_REDIRECT) - .redirect(permissionToExecute.redirect); - } - if (!permissionToExecute.state) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: permissionToExecute.reason, - }); - } - // --- end streamlined --- - - // Build the payload from GET request - const payload = await buildPayloadFromGET(ctr); - - // Execute with run parameter instead of inject.json - const result = await executeFunction( - functionData.id, - functionData, - functionData.files, - { enabled: false }, - JSON.stringify(payload) - ); - - // we might be able to do magic here - if (typeof result?.result === "object" && result?.result !== null) { - const out = result.result; // quicker to write and access - - if ("_shsf" in out) { - const version: "v2" = out._shsf; // always v2 currently - const headers: { key: string; value: any }[] | null = - "_headers" in out - ? Object.entries(out._headers).map(([key, value]) => ({ - key, - value, - })) - : null; - const response_code: number | null = - "_code" in out ? out._code : null; - const response: any | null = "_res" in out ? out._res : null; - - if (response_code === 301 || response_code === 302) { - // Handle redirects - ctr.status(response_code); - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - const link = "_location" in out ? out._location : "/"; - return ctr.redirect(link); - } - - ctr.status(response_code || 200); - - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - - if (response) { - return ctr.print(response); - } else { - return ctr.print("No Function Result :("); - } - } - } - - // Return result if available from main function, otherwise output OK - return ctr.print(result?.result ?? "No Function Result :("); - }) - ) - .http("POST", "/api/exec/{namespaceId}/{functionId}/{route}", (http) => - http - .ratelimit((limit) => - limit - .hits(2) - .window(parseInt(env.RATELIMIT!) || 2000) - .penalty(1000) - ) - .onRequest(async (ctr) => { - const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); - const functionId = ctr.params.get("functionId") || ""; - - if (isNaN(namespaceId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid namespace", - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - executionId: functionId, - namespaceId: namespaceId, - }, - include: { - namespace: { select: { name: true, id: true } }, - files: true, - }, - }); - - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - if (!functionData.allow_http) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: "HTTP execution is not allowed for this function", - }); - } - - // --- streamlined permission check --- - const permissionToExecute = await checkHttpExecutionPermission( - ctr, - functionData, - namespaceId, - functionId - ); - - if (permissionToExecute.redirect) { - return ctr - .status(ctr.$status.TEMPORARY_REDIRECT) - .redirect(permissionToExecute.redirect); - } - if (!permissionToExecute.state) { - return ctr.status(ctr.$status.FORBIDDEN).print({ - status: 403, - message: permissionToExecute.reason, - }); - } - // --- end streamlined --- - - // Build the payload from POST request - const payload = await buildPayloadFromPOST(ctr); - - const result = await executeFunction( - functionData.id, - functionData, - functionData.files, - { enabled: false }, - JSON.stringify(payload) - ); - - // we might be able to do magic here - if (typeof result?.result === "object" && result?.result !== null) { - const out = result.result; // quicker to write and access - - if ("_shsf" in out) { - const version: "v2" = out._shsf; // always v2 currently - const headers: { key: string; value: any }[] | null = - "_headers" in out - ? Object.entries(out._headers).map(([key, value]) => ({ - key, - value, - })) - : null; - const response_code: number | null = - "_code" in out ? out._code : null; - const response: any | null = "_res" in out ? out._res : null; - - if (response_code === 301 || response_code === 302) { - // Handle redirects - ctr.status(response_code); - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - const link = "_location" in out ? out._location : "/"; - return ctr.redirect(link); - } - - ctr.status(response_code || 200); - - if (headers) { - headers.forEach(({ key, value }) => { - ctr.headers.set(key, value); - }); - } - - if (response) { - return ctr.print(response); - } else { - return ctr.print("No Function Result :("); - } - } - } - - return ctr.print(result?.result ?? "No Function Result :("); - }) - ) - .http("POST", "/api/function/{id}/pip-install", (http) => - http.onRequest(async (ctr) => { - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const functionData = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - if (!functionData) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - const files = await prisma.functionFile.findMany({ - where: { - functionId: functionData.id, - }, - }); - if (!files || files.length === 0) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function has no files", - }); - } - - try { - const result = await installDependencies( - functionId, - functionData, - files - ); - - if (result === 404) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: - "Function has not been executed yet. Run it first and it will install dependencies automatically on its first ever run! After that, use Pip Install to update dependencies, if you have modified the requirements.txt file.", - }); - } else if (result === false) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Could not install dependencies or find requirements.txt!", - }); - } - - return ctr.print({ - status: "OK", - }); - } catch (error: any) { - if (error.message === "Timeout") { - return ctr.status(ctr.$status.REQUEST_TIMEOUT).print({ - status: 408, - message: "Pip install timed out", - }); - } - return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ - status: 500, - message: "Failed to install dependencies", - error: error.message, - }); - } - }) - ) - // New route to GET/PATCH CORS origins for a function - .http("GET", "/api/function/{id}/cors-origins", (http) => - http.onRequest(async (ctr) => { - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const fn = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - select: { cors_origins: true }, - }); - if (!fn) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - return ctr.print({ - status: "OK", - cors_origins: fn.cors_origins, - }); - }) - ) - .http("PATCH", "/api/function/{id}/cors-origins", (http) => - http.onRequest(async (ctr) => { - const id = ctr.params.get("id"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - - const [data, error] = await ctr.bindBody((z) => - z.object({ - cors_origins: z.string().max(2048).optional(), - }) - ); - if (!data) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: error.toString(), - }); - } - - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } - - const fn = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - if (!fn) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - - await prisma.function.update({ - where: { id: functionId }, - data: { cors_origins: data.cors_origins }, - }); - - return ctr.print({ - status: "OK", - cors_origins: data.cors_origins, - }); - }) - ); + .http("POST", "/api/function", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + name: z.string().min(1).max(128), + description: z.string().min(3).max(128), + image: z.enum(Images as any), + startup_file: z.string().min(1).max(256).optional(), + docker_mount: z.boolean().optional(), + ffmpeg_install: z.boolean().optional(), + executionAlias: z + .string() + .min(8) + .max(128) + .regex(/^[a-zA-Z0-9-_]+$/) + .optional(), // Only allow alphanumeric, hyphens, and underscores + settings: z + .object({ + max_ram: z.number().min(128).max(1024).optional(), + timeout: z.number().positive().min(1).max(300).optional(), // Increased max timeout to 300 seconds : 5 minutes + allow_http: z.boolean().optional(), + secure_header: z.string().min(1).max(256).optional(), + tags: z.array(z.string().min(1).max(32)).optional(), + retry_on_failure: z.boolean().optional(), + retry_count: z.number().min(1).max(10).positive().optional(), + }) + .optional(), + environment: z + .array( + z + .object({ + name: z.string().min(1).max(128), + value: z.string().min(1).max(256), + }) + .optional(), + ) + .optional(), + namespaceId: z.number(), + cors_origins: z.string().max(2048).optional(), // Accept CORS origins as string + }), + ); + + if (!data) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: error.toString(), + }); + } + + if (!Images.includes(data.image)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid image", + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const namespace = await prisma.namespace.findFirst({ + where: { + id: data.namespaceId, + userId: authCheck.user.id, + }, + }); + if (!namespace) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Namespace not found", + }); + } + + const existingFunction = await prisma.function.findFirst({ + where: { + name: data.name, + namespaceId: data.namespaceId, + userId: authCheck.user.id, + }, + }); + if (existingFunction) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Function with this name already exists in this namespace", + }); + } + + // Check for duplicate executionAlias before creating + if (data.executionAlias) { + const aliasExists = await prisma.function.findFirst({ + where: { + executionAlias: data.executionAlias, + }, + }); + if (aliasExists) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Function with this executionAlias already exists", + }); + } + } + + const out = await prisma.function.create({ + data: { + description: data.description, + namespaceId: data.namespaceId, + name: data.name, + image: data.image, + startup_file: data.startup_file, + tags: data.settings?.tags?.join(",") || "", + allow_http: data.settings?.allow_http, + max_ram: data.settings?.max_ram, + timeout: data.settings?.timeout, + secure_header: data.settings?.secure_header, + retry_on_failure: data.settings?.retry_on_failure, + max_retries: data.settings?.retry_count, + env: data.environment + ? JSON.stringify( + data.environment.map((env) => ({ + name: env!.name, + value: env!.value, + })), + ) + : undefined, + userId: authCheck.user.id, + executionId: randomUUID(), + docker_mount: data.docker_mount || false, + ffmpeg_install: data.ffmpeg_install || false, + cors_origins: data.cors_origins, + executionAlias: data.executionAlias, + }, + }); + + return ctr.print({ + status: "OK", + data: { + id: out.id, + }, + }); + }), + ) + .http("DELETE", "/api/function/{id}", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + // Delete the function from database + await prisma.function.delete({ + where: { + id: functionData.id, + }, + }); + + // Clean up the container and associated files + await cleanupFunctionContainer(functionId); + + return ctr.print({ + status: "OK", + message: "Function deleted", + }); + }), + ) + .http("GET", "/api/functions", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const functions = await prisma.function.findMany({ + where: { + userId: authCheck.user.id, + }, + include: { + namespace: { + select: { + name: true, + id: true, + }, + }, + }, + }); + + return ctr.print({ + status: "OK", + data: functions, + }); + }), + ) + .http("GET", "/api/function/{id}", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + include: { + namespace: { + select: { + name: true, + id: true, + }, + }, + }, + }); + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + return ctr.print({ + status: "OK", + data: functionData, + }); + }), + ) + .http("GET", "/api/function/{id}/logs", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const logs = await prisma.triggerLog.findMany({ + where: { + functionId: functionId, + createdAt: { + gte: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), // 7 days + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + if (!logs) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "No logs found", + }); + } + + return ctr.print({ + status: "OK", + data: logs, + }); + }), + ) + .http("PATCH", "/api/function/{id}", (http) => + http.onRequest(async (ctr) => { + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const [data, error] = await ctr.bindBody((z) => + z.object({ + name: z.string().min(1).max(128).optional(), + description: z.string().min(3).max(128).optional(), + image: z.enum(Images as any).optional(), + startup_file: z.string().min(1).max(256).optional(), + executionAlias: z + .string() + .min(8) + .max(128) + .regex(/^[a-zA-Z0-9-_]+$/) + .optional(), // Only allow alphanumeric, hyphens, and underscores + docker_mount: z.boolean().optional(), + ffmpeg_install: z.boolean().optional(), + settings: z + .object({ + max_ram: z.number().min(128).max(1024).optional(), + timeout: z.number().positive().min(1).max(500).optional(), + allow_http: z.boolean().optional(), + secure_header: z.string().min(1).max(256).optional().or(z.null()), + tags: z.array(z.string().min(1).max(32)).optional(), + retry_on_failure: z.boolean().optional(), + retry_count: z.number().min(1).max(10).positive().optional(), + }) + .optional(), + environment: z + .array( + z + .object({ + name: z.string().min(1).max(128), + value: z.string().min(1).max(256), + }) + .optional(), + ) + .optional(), + cors_origins: z.string().max(2048).optional(), + }), + ); + + if (!data) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: error.toString(), + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const existingFunction = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + + if (!existingFunction) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + // Check for duplicate executionAlias before updating + if (data.executionAlias !== undefined) { + const aliasExists = await prisma.function.findFirst({ + where: { + executionAlias: data.executionAlias, + // Exclude current function + NOT: { id: functionId }, + }, + }); + if (aliasExists) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Another function with this executionAlias already exists", + }); + } + } + + const updatedData: any = { + ...(data.name && { name: data.name }), + ...(data.description && { description: data.description }), + ...(data.image && { image: data.image }), + ...(data.startup_file && { startup_file: data.startup_file }), + ...(data.settings?.tags && { + tags: data.settings.tags.join(","), + }), + ...(data.settings?.allow_http !== undefined && { + allow_http: data.settings.allow_http, + }), + ...(data.settings?.max_ram && { max_ram: data.settings.max_ram }), + ...(data.settings?.timeout && { timeout: data.settings.timeout }), + ...(data.settings?.secure_header !== undefined && { + secure_header: data.settings.secure_header, + }), + ...(data.settings?.retry_on_failure !== undefined && { + retry_on_failure: data.settings.retry_on_failure, + }), + ...(data.settings?.retry_count && { + max_retries: data.settings.retry_count, + }), + ...(data.environment && { + env: JSON.stringify( + data.environment.map((env) => ({ + name: env!.name, + value: env!.value, + })), + ), + }), + ...(data.docker_mount !== undefined && { + docker_mount: data.docker_mount, + }), + ...(data.ffmpeg_install !== undefined && { + ffmpeg_install: data.ffmpeg_install, + }), + ...(data.cors_origins !== undefined && { + cors_origins: data.cors_origins, + }), + ...(data.executionAlias !== undefined && { + executionAlias: data.executionAlias, + }), + }; + + // Track if relaunch is triggered + let relaunchTriggered = false; + + // If image is being changed, we need to recreate the container; Or docker_mount/ffmpeg_install changed + if ( + (data.image && data.image !== existingFunction.image) || + (data.docker_mount !== undefined && + data.docker_mount !== existingFunction.docker_mount) || + (data.ffmpeg_install !== undefined && + data.ffmpeg_install !== existingFunction.ffmpeg_install) + ) { + relaunchTriggered = true; // Set flag regardless of container existence + // Check if a container exists for this function before cleanup + try { + const containers = await docker.listContainers({ + all: true, + filters: { + label: [`functionId=${functionId}`], + }, + }); + if (containers.length > 0) { + if (data.image && data.image !== existingFunction.image) { + console.log( + `[SHSF] Function ${functionId} image changing from ${existingFunction.image} to ${data.image}, container will be recreated`, + ); + } else if ( + data.docker_mount !== undefined && + data.docker_mount !== existingFunction.docker_mount + ) { + console.log( + `[SHSF] Function ${functionId} docker_mount changing from ${existingFunction.docker_mount} to ${data.docker_mount}, container will be recreated`, + ); + } else if ( + data.ffmpeg_install !== undefined && + data.ffmpeg_install !== existingFunction.ffmpeg_install + ) { + console.log( + `[SHSF] Function ${functionId} ffmpeg_install changing from ${existingFunction.ffmpeg_install} to ${data.ffmpeg_install}, container will be recreated`, + ); + } + // Clean up existing container to force recreation with new image, docker_mount, or ffmpeg_install change + await cleanupFunctionContainer(functionId); + // On the next run, the container will be recreated with the new image and new mounts. + } + } catch (err) { + console.error( + `[SHSF] Error checking/cleaning up container for function ${functionId}:`, + err, + ); + } + } + + const updatedFunction = await prisma.function.update({ + where: { + id: functionId, + }, + data: updatedData, + }); + + // UI confirmation: inform if relaunch started + type PatchFunctionResponse = { + status: string; + data: typeof updatedFunction; + relaunch?: string; + }; + + const response: PatchFunctionResponse = { + status: "OK", + data: updatedFunction, + ...(relaunchTriggered && { + relaunch: + "Container relaunch started (will be recreated on next execution).", + }), + }; + + return ctr.print(response); + }), + ) + .http("POST", "/api/function/{id}/execute", (http) => + http.onRequest(async (ctr) => { + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + // Extract optional run parameter from request body + const [runData] = await ctr.bindBody((z) => + z + .object({ + run: z.any().optional(), + }) + .optional(), + ); + + // Convert run data to string for passing to executeFunction + const runPayload = JSON.stringify({ + // if there is a .method we will remove it + body: runData?.run ? runData.run : {}, + headers: Object.fromEntries(ctr.headers.entries()), + queries: Object.fromEntries(ctr.queries.entries()), + raw_body: await ctr.$body().text(), + source_ip: ctr.client.ip.usual(), + route: runData?.run + ? runData.run.route + ? runData.run.route + : "default" + : "default", + method: runData?.run + ? runData.run.method + ? runData.run.method + : "POST" + : "POST", + }); + + const functionData = await prisma.function.findFirst({ + where: { + id: functionId, + }, + }); + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: "Unauthorized", + }); + } + + const files = await prisma.functionFile.findMany({ + where: { + functionId: functionData.id, + }, + }); + if (!files || files.length === 0) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function has no files", + }); + } + + // Check execution mode from query parameter + const streamMode = ctr.queries.get("stream") !== "false"; + + try { + if (streamMode) { + // Streaming mode + return ctr.printChunked( + (print) => + new Promise((end) => { + let output = ""; + executeFunction( + functionId, + functionData, + files, + { + enabled: true, + onChunk: async (text) => { + output += text; + // Ensure text is properly stringified before sending + await print( + JSON.stringify({ + type: "output", + content: text, + }), + ); + }, + }, + runPayload, + ) + .then(async (result) => { + // Successfully completed - include result if available + await print( + JSON.stringify({ + type: "end", + exitCode: 0, + output: output, + result: result?.result, + took: result?.tooks, + }), + ); + end(); + }) + .catch(async (error) => { + // Handle errors + await print( + JSON.stringify({ + type: "error", + error: error.message || "Execution failed", + }), + ); + end(); + }); + + ctr.$abort(() => { + // Handle abort, nothing specific needed as Runner.ts handles cleanup + end(); + }); + }), + ); + } else { + // Synchronous mode + const result = await executeFunction( + functionId, + functionData, + files, + { enabled: false }, + runPayload, + ); + + return ctr.print({ + status: "OK", + data: { + output: result?.logs || "No output", + exitCode: result?.exit_code || 0, + result: result?.result, + took: result?.tooks, + }, + }); + } + } catch (error: any) { + if (error.message === "Timeout") { + return ctr.status(ctr.$status.REQUEST_TIMEOUT).print({ + status: 408, + message: "Code execution timed out", + }); + } + return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + status: 500, + message: "Failed to execute code", + error: error.message, + }); + } + }), + ) + .http("GET", "/api/exec/{namespaceId}/{functionId}", (http) => + http + .ratelimit((limit) => + limit + .hits(2) + .window(parseInt(env.RATELIMIT!) || 2000) + .penalty(1000), + ) + .onRequest(async (ctr) => { + const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); + const functionId = ctr.params.get("functionId") || ""; + + if (isNaN(namespaceId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid namespace", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + executionId: functionId, + namespaceId: namespaceId, + }, + include: { + namespace: { select: { name: true, id: true } }, + files: true, + }, + }); + + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.allow_http) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: "HTTP execution is not allowed for this function", + }); + } + + // --- streamlined permission check --- + const permissionToExecute = await checkHttpExecutionPermission( + ctr, + functionData, + namespaceId, + functionId, + ); + + if (permissionToExecute.redirect) { + return ctr + .status(ctr.$status.TEMPORARY_REDIRECT) + .redirect(permissionToExecute.redirect); + } + if (!permissionToExecute.state) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: permissionToExecute.reason, + }); + } + // --- end streamlined --- + + // Build the payload from GET request + const payload = await buildPayloadFromGET(ctr); + + // Execute with run parameter instead of inject.json + const result = await executeFunction( + functionData.id, + functionData, + functionData.files, + { enabled: false }, + JSON.stringify(payload), + ); + + // we might be able to do magic here + if (typeof result?.result === "object" && result?.result !== null) { + const out = result.result; // quicker to write and access + + if ("_shsf" in out) { + const version: "v2" = out._shsf; // always v2 currently + const headers: { key: string; value: any }[] | null = + "_headers" in out + ? Object.entries(out._headers).map(([key, value]) => ({ + key, + value, + })) + : null; + const response_code: number | null = "_code" in out ? out._code : null; + const response: any | null = "_res" in out ? out._res : null; + + if (response_code === 301 || response_code === 302) { + // Handle redirects + ctr.status(response_code); + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + const link = "_location" in out ? out._location : "/"; + return ctr.redirect(link); + } + + ctr.status(response_code || 200); + + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + + if (response) { + return ctr.print(response); + } else { + return ctr.print("No Function Result :("); + } + } + } + + // Return result if available from main function, otherwise output OK + return ctr.print(result?.result ?? "No Function Result :("); + }), + ) + .http("POST", "/api/exec/{namespaceId}/{functionId}", (http) => + http + .ratelimit((limit) => + limit + .hits(2) + .window(parseInt(env.RATELIMIT!) || 2000) + .penalty(1000), + ) + .onRequest(async (ctr) => { + const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); + const functionId = ctr.params.get("functionId") || ""; + + if (isNaN(namespaceId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid namespace", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + executionId: functionId, + namespaceId: namespaceId, + }, + include: { + namespace: { select: { name: true, id: true } }, + files: true, + }, + }); + + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.allow_http) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: "HTTP execution is not allowed for this function", + }); + } + + // --- streamlined permission check --- + const permissionToExecute = await checkHttpExecutionPermission( + ctr, + functionData, + namespaceId, + functionId, + ); + + if (permissionToExecute.redirect) { + return ctr + .status(ctr.$status.TEMPORARY_REDIRECT) + .redirect(permissionToExecute.redirect); + } + if (!permissionToExecute.state) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: permissionToExecute.reason, + }); + } + // --- end streamlined --- + + // Build the payload from POST request + const payload = await buildPayloadFromPOST(ctr); + + const result = await executeFunction( + functionData.id, + functionData, + functionData.files, + { enabled: false }, + JSON.stringify(payload), + ); + + // we might be able to do magic here + if (typeof result?.result === "object" && result?.result !== null) { + const out = result.result; // quicker to write and access + + if ("_shsf" in out) { + const version: "v2" = out._shsf; // always v2 currently + const headers: { key: string; value: any }[] | null = + "_headers" in out + ? Object.entries(out._headers).map(([key, value]) => ({ + key, + value, + })) + : null; + const response_code: number | null = "_code" in out ? out._code : null; + const response: any | null = "_res" in out ? out._res : null; + + if (response_code === 301 || response_code === 302) { + // Handle redirects + ctr.status(response_code); + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + const link = "_location" in out ? out._location : "/"; + return ctr.redirect(link); + } + + ctr.status(response_code || 200); + + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + + if (response) { + return ctr.print(response); + } else { + return ctr.print("No Function Result :("); + } + } + } + + return ctr.print(result?.result ?? "No Function Result :("); + }), + ) + .http("GET", "/exec/{executionAlias}", (http) => + http + .ratelimit((limit) => + limit + .hits(2) + .window(parseInt(env.RATELIMIT!) || 2000) + .penalty(1000), + ) + .onRequest(async (ctr) => { + const executionAlias = ctr.params.get("executionAlias") || ""; + + if (!executionAlias) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid execution alias", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + executionAlias: executionAlias, + }, + include: { + namespace: { select: { name: true, id: true } }, + files: true, + }, + }); + + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.allow_http) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: "HTTP execution is not allowed for this function", + }); + } + + // --- streamlined permission check --- + const permissionToExecute = await checkHttpExecutionPermission( + ctr, + functionData, + functionData.namespaceId, + String(functionData.id), + ); + + if (permissionToExecute.redirect) { + return ctr + .status(ctr.$status.TEMPORARY_REDIRECT) + .redirect(permissionToExecute.redirect); + } + if (!permissionToExecute.state) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: permissionToExecute.reason, + }); + } + // --- end streamlined --- + + // Build the payload from GET request + const payload = await buildPayloadFromGET(ctr); + + // Execute with run parameter instead of inject.json + const result = await executeFunction( + functionData.id, + functionData, + functionData.files, + { enabled: false }, + JSON.stringify(payload), + ); + + // we might be able to do magic here + if (typeof result?.result === "object" && result?.result !== null) { + const out = result.result; // quicker to write and access + + if ("_shsf" in out) { + const version: "v2" = out._shsf; // always v2 currently + const headers: { key: string; value: any }[] | null = + "_headers" in out + ? Object.entries(out._headers).map(([key, value]) => ({ + key, + value, + })) + : null; + const response_code: number | null = "_code" in out ? out._code : null; + const response: any | null = "_res" in out ? out._res : null; + + if (response_code === 301 || response_code === 302) { + // Handle redirects + ctr.status(response_code); + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + const link = "_location" in out ? out._location : "/"; + return ctr.redirect(link); + } + + ctr.status(response_code || 200); + + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + + if (response) { + return ctr.print(response); + } else { + return ctr.print("No Function Result :("); + } + } + } + + // Return result if available from main function, otherwise output OK + return ctr.print(result?.result ?? "No Function Result :("); + }), + ) + .http("POST", "/exec/{executionAlias}", (http) => + http + .ratelimit((limit) => + limit + .hits(2) + .window(parseInt(env.RATELIMIT!) || 2000) + .penalty(1000), + ) + .onRequest(async (ctr) => { + const executionAlias = ctr.params.get("executionAlias") || ""; + + if (!executionAlias) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid execution alias", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + executionAlias: executionAlias, + }, + include: { + namespace: { select: { name: true, id: true } }, + files: true, + }, + }); + + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.allow_http) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: "HTTP execution is not allowed for this function", + }); + } + + // --- streamlined permission check --- + const permissionToExecute = await checkHttpExecutionPermission( + ctr, + functionData, + functionData.namespaceId, + String(functionData.id), + ); + + if (permissionToExecute.redirect) { + return ctr + .status(ctr.$status.TEMPORARY_REDIRECT) + .redirect(permissionToExecute.redirect); + } + if (!permissionToExecute.state) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: permissionToExecute.reason, + }); + } + // --- end streamlined --- + + // Build the payload from POST request + const payload = await buildPayloadFromPOST(ctr); + + const result = await executeFunction( + functionData.id, + functionData, + functionData.files, + { enabled: false }, + JSON.stringify(payload), + ); + + // we might be able to do magic here + if (typeof result?.result === "object" && result?.result !== null) { + const out = result.result; // quicker to write and access + + if ("_shsf" in out) { + const version: "v2" = out._shsf; // always v2 currently + const headers: { key: string; value: any }[] | null = + "_headers" in out + ? Object.entries(out._headers).map(([key, value]) => ({ + key, + value, + })) + : null; + const response_code: number | null = "_code" in out ? out._code : null; + const response: any | null = "_res" in out ? out._res : null; + + if (response_code === 301 || response_code === 302) { + // Handle redirects + ctr.status(response_code); + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + const link = "_location" in out ? out._location : "/"; + return ctr.redirect(link); + } + + ctr.status(response_code || 200); + + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + + if (response) { + return ctr.print(response); + } else { + return ctr.print("No Function Result :("); + } + } + } + + return ctr.print(result?.result ?? "No Function Result :("); + }), + ) + .http("GET", "/api/exec/{namespaceId}/{functionId}/{route}", (http) => + http + .ratelimit((limit) => + limit + .hits(2) + .window(parseInt(env.RATELIMIT!) || 2000) + .penalty(1000), + ) + .onRequest(async (ctr) => { + const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); + const functionId = ctr.params.get("functionId") || ""; + + if (isNaN(namespaceId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid namespace", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + executionId: functionId, + namespaceId: namespaceId, + }, + include: { + namespace: { select: { name: true, id: true } }, + files: true, + }, + }); + + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.allow_http) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: "HTTP execution is not allowed for this function", + }); + } + + // --- streamlined permission check --- + const permissionToExecute = await checkHttpExecutionPermission( + ctr, + functionData, + namespaceId, + functionId, + ); + + if (permissionToExecute.redirect) { + return ctr + .status(ctr.$status.TEMPORARY_REDIRECT) + .redirect(permissionToExecute.redirect); + } + if (!permissionToExecute.state) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: permissionToExecute.reason, + }); + } + // --- end streamlined --- + + // Build the payload from GET request + const payload = await buildPayloadFromGET(ctr); + + // Execute with run parameter instead of inject.json + const result = await executeFunction( + functionData.id, + functionData, + functionData.files, + { enabled: false }, + JSON.stringify(payload), + ); + + // we might be able to do magic here + if (typeof result?.result === "object" && result?.result !== null) { + const out = result.result; // quicker to write and access + + if ("_shsf" in out) { + const version: "v2" = out._shsf; // always v2 currently + const headers: { key: string; value: any }[] | null = + "_headers" in out + ? Object.entries(out._headers).map(([key, value]) => ({ + key, + value, + })) + : null; + const response_code: number | null = "_code" in out ? out._code : null; + const response: any | null = "_res" in out ? out._res : null; + + if (response_code === 301 || response_code === 302) { + // Handle redirects + ctr.status(response_code); + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + const link = "_location" in out ? out._location : "/"; + return ctr.redirect(link); + } + + ctr.status(response_code || 200); + + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + + if (response) { + return ctr.print(response); + } else { + return ctr.print("No Function Result :("); + } + } + } + + // Return result if available from main function, otherwise output OK + return ctr.print(result?.result ?? "No Function Result :("); + }), + ) + .http("POST", "/api/exec/{namespaceId}/{functionId}/{route}", (http) => + http + .ratelimit((limit) => + limit + .hits(2) + .window(parseInt(env.RATELIMIT!) || 2000) + .penalty(1000), + ) + .onRequest(async (ctr) => { + const namespaceId = parseInt(ctr.params.get("namespaceId") || ""); + const functionId = ctr.params.get("functionId") || ""; + + if (isNaN(namespaceId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid namespace", + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + executionId: functionId, + namespaceId: namespaceId, + }, + include: { + namespace: { select: { name: true, id: true } }, + files: true, + }, + }); + + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.allow_http) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: "HTTP execution is not allowed for this function", + }); + } + + // --- streamlined permission check --- + const permissionToExecute = await checkHttpExecutionPermission( + ctr, + functionData, + namespaceId, + functionId, + ); + + if (permissionToExecute.redirect) { + return ctr + .status(ctr.$status.TEMPORARY_REDIRECT) + .redirect(permissionToExecute.redirect); + } + if (!permissionToExecute.state) { + return ctr.status(ctr.$status.FORBIDDEN).print({ + status: 403, + message: permissionToExecute.reason, + }); + } + // --- end streamlined --- + + // Build the payload from POST request + const payload = await buildPayloadFromPOST(ctr); + + const result = await executeFunction( + functionData.id, + functionData, + functionData.files, + { enabled: false }, + JSON.stringify(payload), + ); + + // we might be able to do magic here + if (typeof result?.result === "object" && result?.result !== null) { + const out = result.result; // quicker to write and access + + if ("_shsf" in out) { + const version: "v2" = out._shsf; // always v2 currently + const headers: { key: string; value: any }[] | null = + "_headers" in out + ? Object.entries(out._headers).map(([key, value]) => ({ + key, + value, + })) + : null; + const response_code: number | null = "_code" in out ? out._code : null; + const response: any | null = "_res" in out ? out._res : null; + + if (response_code === 301 || response_code === 302) { + // Handle redirects + ctr.status(response_code); + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + const link = "_location" in out ? out._location : "/"; + return ctr.redirect(link); + } + + ctr.status(response_code || 200); + + if (headers) { + headers.forEach(({ key, value }) => { + ctr.headers.set(key, value); + }); + } + + if (response) { + return ctr.print(response); + } else { + return ctr.print("No Function Result :("); + } + } + } + + return ctr.print(result?.result ?? "No Function Result :("); + }), + ) + .http("POST", "/api/function/{id}/pip-install", (http) => + http.onRequest(async (ctr) => { + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + const files = await prisma.functionFile.findMany({ + where: { + functionId: functionData.id, + }, + }); + if (!files || files.length === 0) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function has no files", + }); + } + + try { + const result = await installDependencies(functionId, functionData, files); + + if (result === 404) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: + "Function has not been executed yet. Run it first and it will install dependencies automatically on its first ever run! After that, use Pip Install to update dependencies, if you have modified the requirements.txt file.", + }); + } else if (result === false) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Could not install dependencies or find requirements.txt!", + }); + } + + return ctr.print({ + status: "OK", + }); + } catch (error: any) { + if (error.message === "Timeout") { + return ctr.status(ctr.$status.REQUEST_TIMEOUT).print({ + status: 408, + message: "Pip install timed out", + }); + } + return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + status: 500, + message: "Failed to install dependencies", + error: error.message, + }); + } + }), + ) + // New route to GET/PATCH CORS origins for a function + .http("GET", "/api/function/{id}/cors-origins", (http) => + http.onRequest(async (ctr) => { + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const fn = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + select: { cors_origins: true }, + }); + if (!fn) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + return ctr.print({ + status: "OK", + cors_origins: fn.cors_origins, + }); + }), + ) + .http("PATCH", "/api/function/{id}/cors-origins", (http) => + http.onRequest(async (ctr) => { + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const [data, error] = await ctr.bindBody((z) => + z.object({ + cors_origins: z.string().max(2048).optional(), + }), + ); + if (!data) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: error.toString(), + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const fn = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!fn) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + await prisma.function.update({ + where: { id: functionId }, + data: { cors_origins: data.cors_origins }, + }); + + return ctr.print({ + status: "OK", + cors_origins: data.cors_origins, + }); + }), + ); diff --git a/Backend/src/routes/api/namespaces.ts b/Backend/src/routes/api/namespaces.ts index 4c13d5c..4e1998c 100644 --- a/Backend/src/routes/api/namespaces.ts +++ b/Backend/src/routes/api/namespaces.ts @@ -8,7 +8,7 @@ export = new fileRouter.Path("/") const [data, error] = await ctr.bindBody((z) => z.object({ name: z.string().min(1).max(128), - }) + }), ); if (!data) @@ -16,7 +16,7 @@ export = new fileRouter.Path("/") const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -39,13 +39,13 @@ export = new fileRouter.Path("/") id: namespace.id, }, }); - }) + }), ) .http("GET", "/api/namespaces", (http) => http.onRequest(async (ctr) => { const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -68,13 +68,13 @@ export = new fileRouter.Path("/") status: "OK", data: namespaces, }); - }) + }), ) .http("GET", "/api/namespace/{id}", (http) => http.onRequest(async (ctr) => { const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -86,15 +86,11 @@ export = new fileRouter.Path("/") const id = ctr.params.get("id"); if (!id) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Missing namespace id"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Missing namespace id"); } const namespaceId = parseInt(id); if (isNaN(namespaceId)) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Invalid namespace id"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Invalid namespace id"); } const namespace = await prisma.namespace.findFirst({ @@ -112,27 +108,23 @@ export = new fileRouter.Path("/") status: "OK", data: namespace, }); - }) + }), ) .http("PATCH", "/api/namespace/{id}", (http) => http.onRequest(async (ctr) => { const id = ctr.params.get("id"); if (!id) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Missing namespace id"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Missing namespace id"); } const namespaceId = parseInt(id); if (isNaN(namespaceId)) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Invalid namespace id"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Invalid namespace id"); } const [data, error] = await ctr.bindBody((z) => z.object({ name: z.string().min(1).max(128).optional(), - }) + }), ); if (!data) @@ -140,7 +132,7 @@ export = new fileRouter.Path("/") const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -174,13 +166,13 @@ export = new fileRouter.Path("/") status: "OK", data: updatedNamespace, }); - }) + }), ) .http("DELETE", "/api/namespace/{id}", (http) => http.onRequest(async (ctr) => { const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -192,15 +184,11 @@ export = new fileRouter.Path("/") const id = ctr.params.get("id"); if (!id) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Missing namespace id"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Missing namespace id"); } const namespaceId = parseInt(id); if (isNaN(namespaceId)) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print("Invalid namespace id"); + return ctr.status(ctr.$status.BAD_REQUEST).print("Invalid namespace id"); } const namespace = await prisma.namespace.findFirst({ @@ -230,12 +218,12 @@ export = new fileRouter.Path("/") where: { id: namespaceId, }, - include: { functions: true } + include: { functions: true }, }); return ctr.print({ status: "OK", message: "Namespace deleted successfully", }); - }) + }), ); diff --git a/Backend/src/routes/api/storage.ts b/Backend/src/routes/api/storage.ts index 610e6cc..cd1f175 100644 --- a/Backend/src/routes/api/storage.ts +++ b/Backend/src/routes/api/storage.ts @@ -3,400 +3,400 @@ import { checkAuthentication } from "../../lib/Authentication"; // Helper to delete expired item, returns true if deleted async function deleteExpiredItem( - item: { id: number; expiresAt: Date | null } | null, - now: Date + item: { id: number; expiresAt: Date | null } | null, + now: Date, ): Promise { - if (item && item.expiresAt && item.expiresAt < now) { - await prisma.functionStorageItem.delete({ where: { id: item.id } }); - return true; - } - return false; + if (item && item.expiresAt && item.expiresAt < now) { + await prisma.functionStorageItem.delete({ where: { id: item.id } }); + return true; + } + return false; } export = new fileRouter.Path("/") - // ------ Function Storages ------ - // Create new storage - .http("POST", "/api/storage", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - name: z - .string() - .min(1) - .max(128) - .regex(/^[a-zA-Z0-9]+$/, "Name must be alphanumeric"), - purpose: z.string().min(1).max(256), - }) - ); - if (!data) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: error.toString() }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.create({ - data: { - name: data.name, - purpose: data.purpose, - user: authCheck.user.id, - }, - }); - return ctr.print({ status: "OK", data: storage }); - }) - ) + // ------ Function Storages ------ + // Create new storage + .http("POST", "/api/storage", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + name: z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9]+$/, "Name must be alphanumeric"), + purpose: z.string().min(1).max(256), + }), + ); + if (!data) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: error.toString() }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.create({ + data: { + name: data.name, + purpose: data.purpose, + user: authCheck.user.id, + }, + }); + return ctr.print({ status: "OK", data: storage }); + }), + ) - // List all storages for user - .http("GET", "/api/storage", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storages = await prisma.functionStorage.findMany({ - where: { user: authCheck.user.id }, - include: { items: false }, - }); - return ctr.print({ status: "OK", data: storages }); - }) - ) + // List all storages for user + .http("GET", "/api/storage", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storages = await prisma.functionStorage.findMany({ + where: { user: authCheck.user.id }, + include: { items: false }, + }); + return ctr.print({ status: "OK", data: storages }); + }), + ) - // Delete a storage (and all items) by name - .http("DELETE", "/api/storage/{storageName}", (http) => - http.onRequest(async (ctr) => { - const storageName = ctr.params.get("storageName"); - if (!storageName) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: "Invalid storage name" }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.findFirst({ - where: { name: storageName, user: authCheck.user.id }, - }); - if (!storage) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Storage not found" }); - } - await prisma.functionStorage.delete({ where: { id: storage.id } }); - return ctr.print({ status: "OK", message: "Storage deleted" }); - }) - ) + // Delete a storage (and all items) by name + .http("DELETE", "/api/storage/{storageName}", (http) => + http.onRequest(async (ctr) => { + const storageName = ctr.params.get("storageName"); + if (!storageName) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: "Invalid storage name" }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.findFirst({ + where: { name: storageName, user: authCheck.user.id }, + }); + if (!storage) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Storage not found" }); + } + await prisma.functionStorage.delete({ where: { id: storage.id } }); + return ctr.print({ status: "OK", message: "Storage deleted" }); + }), + ) - // Clear all items in storage by name - .http("DELETE", "/api/storage/{storageName}/items", (http) => - http.onRequest(async (ctr) => { - const storageName = ctr.params.get("storageName"); - if (!storageName) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: "Invalid storage name" }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.findFirst({ - where: { name: storageName, user: authCheck.user.id }, - }); - if (!storage) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Storage not found" }); - } - await prisma.functionStorageItem.deleteMany({ - where: { storageId: storage.id }, - }); - return ctr.print({ status: "OK", message: "All items cleared" }); - }) - ) + // Clear all items in storage by name + .http("DELETE", "/api/storage/{storageName}/items", (http) => + http.onRequest(async (ctr) => { + const storageName = ctr.params.get("storageName"); + if (!storageName) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: "Invalid storage name" }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.findFirst({ + where: { name: storageName, user: authCheck.user.id }, + }); + if (!storage) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Storage not found" }); + } + await prisma.functionStorageItem.deleteMany({ + where: { storageId: storage.id }, + }); + return ctr.print({ status: "OK", message: "All items cleared" }); + }), + ) - // ------ Function Storage Items ------ - // Set (create/update) item by storage name - .http("POST", "/api/storage/{storageName}/item", (http) => - http.onRequest(async (ctr) => { - const storageName = ctr.params.get("storageName"); - if (!storageName) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: "Invalid storage name" }); - } - const [data, error] = await ctr.bindBody((z) => - z.object({ - key: z - .string() - .min(1) - .max(256) - .regex( - /^[a-zA-Z0-9_\-]+$/, - "Key must be alphanumeric with underscores or hyphens" - ), - value: z.any(), - expiresAt: z.union([z.string().datetime(), z.number()]).optional(), - }) - ); - if (!data) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: error.toString() }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.findFirst({ - where: { name: storageName, user: authCheck.user.id }, - }); - if (!storage) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Storage not found" }); - } - // Accept any type for value, store as string (JSON if not string) - let storeValue: string; - if (typeof data.value === "string") { - storeValue = data.value; - } else { - storeValue = JSON.stringify(data.value); - } - // Handle expiresAt as ISO string or hours (number) - let expiresAt: Date | undefined = undefined; - if (typeof data.expiresAt === "string") { - expiresAt = new Date(data.expiresAt); - } else if (typeof data.expiresAt === "number") { - expiresAt = new Date(Date.now() + data.expiresAt * 60 * 60 * 1000); - } - // Remove expired item if exists using helper - const now = new Date(); - const existing = await prisma.functionStorageItem.findFirst({ - where: { storageId: storage.id, key: data.key }, - }); - await deleteExpiredItem(existing, now); + // ------ Function Storage Items ------ + // Set (create/update) item by storage name + .http("POST", "/api/storage/{storageName}/item", (http) => + http.onRequest(async (ctr) => { + const storageName = ctr.params.get("storageName"); + if (!storageName) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: "Invalid storage name" }); + } + const [data, error] = await ctr.bindBody((z) => + z.object({ + key: z + .string() + .min(1) + .max(256) + .regex( + /^[a-zA-Z0-9_\-]+$/, + "Key must be alphanumeric with underscores or hyphens", + ), + value: z.any(), + expiresAt: z.union([z.string().datetime(), z.number()]).optional(), + }), + ); + if (!data) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: error.toString() }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.findFirst({ + where: { name: storageName, user: authCheck.user.id }, + }); + if (!storage) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Storage not found" }); + } + // Accept any type for value, store as string (JSON if not string) + let storeValue: string; + if (typeof data.value === "string") { + storeValue = data.value; + } else { + storeValue = JSON.stringify(data.value); + } + // Handle expiresAt as ISO string or hours (number) + let expiresAt: Date | undefined = undefined; + if (typeof data.expiresAt === "string") { + expiresAt = new Date(data.expiresAt); + } else if (typeof data.expiresAt === "number") { + expiresAt = new Date(Date.now() + data.expiresAt * 60 * 60 * 1000); + } + // Remove expired item if exists using helper + const now = new Date(); + const existing = await prisma.functionStorageItem.findFirst({ + where: { storageId: storage.id, key: data.key }, + }); + await deleteExpiredItem(existing, now); - let item; - const stillExists = - existing && (!existing.expiresAt || existing.expiresAt >= now); - if (stillExists) { - item = await prisma.functionStorageItem.update({ - where: { id: existing.id }, - data: { - value: storeValue, - expiresAt, - }, - }); - } else { - item = await prisma.functionStorageItem.create({ - data: { - key: data.key, - value: storeValue, - expiresAt, - storageId: storage.id, - }, - }); - } - return ctr.print({ status: "OK", data: item }); - }) - ) + let item; + const stillExists = + existing && (!existing.expiresAt || existing.expiresAt >= now); + if (stillExists) { + item = await prisma.functionStorageItem.update({ + where: { id: existing.id }, + data: { + value: storeValue, + expiresAt, + }, + }); + } else { + item = await prisma.functionStorageItem.create({ + data: { + key: data.key, + value: storeValue, + expiresAt, + storageId: storage.id, + }, + }); + } + return ctr.print({ status: "OK", data: item }); + }), + ) - // Get item by key and storage name - .http("GET", "/api/storage/{storageName}/item/{key}", (http) => - http.onRequest(async (ctr) => { - const storageName = ctr.params.get("storageName"); - const key = ctr.params.get("key"); - if (!storageName || !key) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: "Invalid storage name or key" }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.findFirst({ - where: { name: storageName, user: authCheck.user.id }, - }); - if (!storage) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Storage not found" }); - } - const item = await prisma.functionStorageItem.findFirst({ - where: { storageId: storage.id, key }, - }); - if (!item) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Item not found" }); - } - // Check expiration using helper - const now = new Date(); - if (await deleteExpiredItem(item, now)) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Item expired" }); - } - let parsedItem = item; - try { - parsedItem.value = JSON.parse(item.value); - } catch { - // Intentionally ignore JSON parse errors; value remains as string if not valid JSON - } - return ctr.print({ status: "OK", data: parsedItem }); - }) - ) + // Get item by key and storage name + .http("GET", "/api/storage/{storageName}/item/{key}", (http) => + http.onRequest(async (ctr) => { + const storageName = ctr.params.get("storageName"); + const key = ctr.params.get("key"); + if (!storageName || !key) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: "Invalid storage name or key" }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.findFirst({ + where: { name: storageName, user: authCheck.user.id }, + }); + if (!storage) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Storage not found" }); + } + const item = await prisma.functionStorageItem.findFirst({ + where: { storageId: storage.id, key }, + }); + if (!item) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Item not found" }); + } + // Check expiration using helper + const now = new Date(); + if (await deleteExpiredItem(item, now)) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Item expired" }); + } + let parsedItem = item; + try { + parsedItem.value = JSON.parse(item.value); + } catch { + // Intentionally ignore JSON parse errors; value remains as string if not valid JSON + } + return ctr.print({ status: "OK", data: parsedItem }); + }), + ) - // Get all items in storage by name (filter out expired) - .http("GET", "/api/storage/{storageName}/items", (http) => - http.onRequest(async (ctr) => { - const storageName = ctr.params.get("storageName"); - if (!storageName) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: "Invalid storage name" }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.findFirst({ - where: { name: storageName, user: authCheck.user.id }, - }); - if (!storage) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Storage not found" }); - } - const items = await prisma.functionStorageItem.findMany({ - where: { storageId: storage.id }, - }); - const now = new Date(); - // Collect expired item IDs and filter valid items - const expiredItemIds: number[] = []; - const validItems = []; - for (const item of items) { - if (item.expiresAt && item.expiresAt < now) { - expiredItemIds.push(item.id); - } else { - // Parse value if possible, do not mutate Prisma object - let parsedValue: any; - try { - parsedValue = JSON.parse(item.value); - } catch { - parsedValue = item.value; - } - validItems.push({ ...item, value: parsedValue }); - } - } - if (expiredItemIds.length > 0) { - await prisma.functionStorageItem.deleteMany({ - where: { id: { in: expiredItemIds } }, - }); - } - return ctr.print({ status: "OK", data: validItems }); - }) - ) + // Get all items in storage by name (filter out expired) + .http("GET", "/api/storage/{storageName}/items", (http) => + http.onRequest(async (ctr) => { + const storageName = ctr.params.get("storageName"); + if (!storageName) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: "Invalid storage name" }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.findFirst({ + where: { name: storageName, user: authCheck.user.id }, + }); + if (!storage) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Storage not found" }); + } + const items = await prisma.functionStorageItem.findMany({ + where: { storageId: storage.id }, + }); + const now = new Date(); + // Collect expired item IDs and filter valid items + const expiredItemIds: number[] = []; + const validItems = []; + for (const item of items) { + if (item.expiresAt && item.expiresAt < now) { + expiredItemIds.push(item.id); + } else { + // Parse value if possible, do not mutate Prisma object + let parsedValue: any; + try { + parsedValue = JSON.parse(item.value); + } catch { + parsedValue = item.value; + } + validItems.push({ ...item, value: parsedValue }); + } + } + if (expiredItemIds.length > 0) { + await prisma.functionStorageItem.deleteMany({ + where: { id: { in: expiredItemIds } }, + }); + } + return ctr.print({ status: "OK", data: validItems }); + }), + ) - // Delete item by key and storage name - .http("DELETE", "/api/storage/{storageName}/item/{key}", (http) => - http.onRequest(async (ctr) => { - const storageName = ctr.params.get("storageName"); - const key = ctr.params.get("key"); - if (!storageName || !key) { - return ctr - .status(ctr.$status.BAD_REQUEST) - .print({ status: 400, message: "Invalid storage name or key" }); - } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ status: 401, message: authCheck.message }); - } - if (authCheck.method === "apiKey") { - if (authCheck.apiKey.name.startsWith("token_exec_")) { - ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function - } - } - const storage = await prisma.functionStorage.findFirst({ - where: { name: storageName, user: authCheck.user.id }, - }); - if (!storage) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Storage not found" }); - } - const item = await prisma.functionStorageItem.findFirst({ - where: { storageId: storage.id, key }, - }); - if (!item) { - return ctr - .status(ctr.$status.NOT_FOUND) - .print({ status: 404, message: "Item not found" }); - } - await prisma.functionStorageItem.delete({ where: { id: item.id } }); - return ctr.print({ status: "OK", message: "Item deleted" }); - }) - ); + // Delete item by key and storage name + .http("DELETE", "/api/storage/{storageName}/item/{key}", (http) => + http.onRequest(async (ctr) => { + const storageName = ctr.params.get("storageName"); + const key = ctr.params.get("key"); + if (!storageName || !key) { + return ctr + .status(ctr.$status.BAD_REQUEST) + .print({ status: 400, message: "Invalid storage name or key" }); + } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ status: 401, message: authCheck.message }); + } + if (authCheck.method === "apiKey") { + if (authCheck.apiKey.name.startsWith("token_exec_")) { + ctr.skipRateLimit(); // Skip ratelimit as this is a action by a function + } + } + const storage = await prisma.functionStorage.findFirst({ + where: { name: storageName, user: authCheck.user.id }, + }); + if (!storage) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Storage not found" }); + } + const item = await prisma.functionStorageItem.findFirst({ + where: { storageId: storage.id, key }, + }); + if (!item) { + return ctr + .status(ctr.$status.NOT_FOUND) + .print({ status: 404, message: "Item not found" }); + } + await prisma.functionStorageItem.delete({ where: { id: item.id } }); + return ctr.print({ status: "OK", message: "Item deleted" }); + }), + ); diff --git a/Backend/src/routes/api/triggers.ts b/Backend/src/routes/api/triggers.ts index a37a066..b715314 100644 --- a/Backend/src/routes/api/triggers.ts +++ b/Backend/src/routes/api/triggers.ts @@ -10,8 +10,8 @@ export = new fileRouter.Path("/") description: z.string().min(4).max(256), cron: z.string().max(128).optional(), data: z.string().optional(), - enabled: z.boolean().optional(), - }) + enabled: z.boolean().optional(), + }), ); if (!data) { @@ -38,7 +38,7 @@ export = new fileRouter.Path("/") const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -69,7 +69,7 @@ export = new fileRouter.Path("/") description: data.description, cron: data.cron || "{}", data: data.data, - enabled: data.enabled, + enabled: data.enabled, }, }); @@ -79,13 +79,13 @@ export = new fileRouter.Path("/") id: trigger.id, }, }); - }) + }), ) .http("GET", "/api/functions/{functionId}/triggers", (http) => http.onRequest(async (ctr) => { const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) + ctr.headers.get(API_KEY_HEADER), ); if (!authCheck.success) { @@ -132,255 +132,255 @@ export = new fileRouter.Path("/") status: "OK", data: triggers, }); - }) + }), ) - .http("GET", "/api/functions/{functionId}/triggers/{triggerId}", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); + .http("GET", "/api/functions/{functionId}/triggers/{triggerId}", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const id = ctr.params.get("functionId"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - const triggerId = ctr.params.get("triggerId"); - if (!triggerId) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing trigger id", - }); - } - const triggerIdInt = parseInt(triggerId); - if (isNaN(triggerIdInt)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid trigger id", - }); - } - const func = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - if (!func) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - const trigger = await prisma.functionTrigger.findFirst({ - where: { - id: triggerIdInt, - functionId: func.id, - }, - }); - if (!trigger) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Trigger not found", - }); - } - return ctr.print({ - status: "OK", - data: trigger, - }); - }) - ) - .http("DELETE", "/api/functions/{functionId}/triggers/{triggerId}", (http) => - http.onRequest(async (ctr) => { - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); + const id = ctr.params.get("functionId"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + const triggerId = ctr.params.get("triggerId"); + if (!triggerId) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing trigger id", + }); + } + const triggerIdInt = parseInt(triggerId); + if (isNaN(triggerIdInt)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid trigger id", + }); + } + const func = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!func) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + const trigger = await prisma.functionTrigger.findFirst({ + where: { + id: triggerIdInt, + functionId: func.id, + }, + }); + if (!trigger) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Trigger not found", + }); + } + return ctr.print({ + status: "OK", + data: trigger, + }); + }), + ) + .http("DELETE", "/api/functions/{functionId}/triggers/{triggerId}", (http) => + http.onRequest(async (ctr) => { + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const id = ctr.params.get("functionId"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - const triggerId = ctr.params.get("triggerId"); - if (!triggerId) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing trigger id", - }); - } - const triggerIdInt = parseInt(triggerId); - if (isNaN(triggerIdInt)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid trigger id", - }); - } - const func = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - if (!func) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - const trigger = await prisma.functionTrigger.findFirst({ - where: { - id: triggerIdInt, - functionId: func.id, - }, - }); - if (!trigger) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Trigger not found", - }); - } - await prisma.functionTrigger.delete({ - where: { - id: trigger.id, - }, - }); - return ctr.print({ - status: "OK", - message: "Trigger deleted", - }); - }) - ) - .http("PUT", "/api/functions/{functionId}/triggers/{triggerId}", (http) => - http.onRequest(async (ctr) => { - const [data, error] = await ctr.bindBody((z) => - z.object({ - name: z.string().min(4).max(128), - description: z.string().min(4).max(256), - cron: z.string().max(128).optional(), - data: z.string().optional(), - enabled: z.boolean().optional(), - }) - ); + const id = ctr.params.get("functionId"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + const triggerId = ctr.params.get("triggerId"); + if (!triggerId) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing trigger id", + }); + } + const triggerIdInt = parseInt(triggerId); + if (isNaN(triggerIdInt)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid trigger id", + }); + } + const func = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!func) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + const trigger = await prisma.functionTrigger.findFirst({ + where: { + id: triggerIdInt, + functionId: func.id, + }, + }); + if (!trigger) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Trigger not found", + }); + } + await prisma.functionTrigger.delete({ + where: { + id: trigger.id, + }, + }); + return ctr.print({ + status: "OK", + message: "Trigger deleted", + }); + }), + ) + .http("PUT", "/api/functions/{functionId}/triggers/{triggerId}", (http) => + http.onRequest(async (ctr) => { + const [data, error] = await ctr.bindBody((z) => + z.object({ + name: z.string().min(4).max(128), + description: z.string().min(4).max(256), + cron: z.string().max(128).optional(), + data: z.string().optional(), + enabled: z.boolean().optional(), + }), + ); - if (!data) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: error.toString(), - }); - } + if (!data) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: error.toString(), + }); + } - const authCheck = await checkAuthentication( - ctr.cookies.get(COOKIE), - ctr.headers.get(API_KEY_HEADER) - ); - if (!authCheck.success) { - return ctr.print({ - status: 401, - message: authCheck.message, - }); - } + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } - const id = ctr.params.get("functionId"); - if (!id) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing function id", - }); - } - const functionId = parseInt(id); - if (isNaN(functionId)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid function id", - }); - } - const triggerId = ctr.params.get("triggerId"); - if (!triggerId) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Missing trigger id", - }); - } - const triggerIdInt = parseInt(triggerId); - if (isNaN(triggerIdInt)) { - return ctr.status(ctr.$status.BAD_REQUEST).print({ - status: 400, - message: "Invalid trigger id", - }); - } - const func = await prisma.function.findFirst({ - where: { - id: functionId, - userId: authCheck.user.id, - }, - }); - if (!func) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Function not found", - }); - } - const trigger = await prisma.functionTrigger.findFirst({ - where: { - id: triggerIdInt, - functionId: func.id, - }, - }); - if (!trigger) { - return ctr.status(ctr.$status.NOT_FOUND).print({ - status: 404, - message: "Trigger not found", - }); - } - const updatedTrigger = await prisma.functionTrigger.update({ - where: { - id: trigger.id, - }, - data: { - name: data.name, - description: data.description, - cron: data.cron, - data: data.data, - nextRun:null, // Reset nextRun to null when updating the trigger - enabled: data.enabled, - }, - }); - return ctr.print({ - status: "OK", - data: updatedTrigger, - }); - }) - ); + const id = ctr.params.get("functionId"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + const triggerId = ctr.params.get("triggerId"); + if (!triggerId) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing trigger id", + }); + } + const triggerIdInt = parseInt(triggerId); + if (isNaN(triggerIdInt)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid trigger id", + }); + } + const func = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!func) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + const trigger = await prisma.functionTrigger.findFirst({ + where: { + id: triggerIdInt, + functionId: func.id, + }, + }); + if (!trigger) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Trigger not found", + }); + } + const updatedTrigger = await prisma.functionTrigger.update({ + where: { + id: trigger.id, + }, + data: { + name: data.name, + description: data.description, + cron: data.cron, + data: data.data, + nextRun: null, // Reset nextRun to null when updating the trigger + enabled: data.enabled, + }, + }); + return ctr.print({ + status: "OK", + data: updatedTrigger, + }); + }), + ); diff --git a/Backend/src/routes/health.ts b/Backend/src/routes/health.ts index 8b32d91..df21e5b 100644 --- a/Backend/src/routes/health.ts +++ b/Backend/src/routes/health.ts @@ -1,11 +1,11 @@ import { fileRouter } from ".."; export = new fileRouter.Path("/").http("GET", "/health", (http) => - http - .ratelimit((limit) => limit.hits(1).window(2000).penalty(100)) - .onRequest(async (ctr) => { - return ctr.print({ - status: "OK" - }); - }) + http + .ratelimit((limit) => limit.hits(1).window(2000).penalty(100)) + .onRequest(async (ctr) => { + return ctr.print({ + status: "OK", + }); + }), ); diff --git a/Backend/src/routes/logout.ts b/Backend/src/routes/logout.ts index 198effd..a7e5389 100644 --- a/Backend/src/routes/logout.ts +++ b/Backend/src/routes/logout.ts @@ -34,5 +34,5 @@ export = new fileRouter.Path("/").http("PATCH", "/api/logout", (http) => status: "OK", message: "Logged out successfully!", }); - }) + }), ); diff --git a/Backend/tsconfig.json b/Backend/tsconfig.json index fa39275..2c7b8b5 100644 --- a/Backend/tsconfig.json +++ b/Backend/tsconfig.json @@ -19,5 +19,5 @@ "allowUnreachableCode": false, "noFallthroughCasesInSwitch": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts"] } diff --git a/CLI/README.md b/CLI/README.md index 5f3ecec..7e48e41 100644 --- a/CLI/README.md +++ b/CLI/README.md @@ -1,65 +1,67 @@ -# SHSF CLI - -## Installation & Usage - -### Run directly (no install): - -``` -npx shsf-cli --mode [options] -``` - -or with pnpm: - -``` -pnpm dlx shsf-cli --mode [options] -``` - -### Install globally: - -``` -npm install -g shsf-cli -# or -pnpm add -g shsf-cli -``` - -Then use anywhere: - -``` -shsf-cli --mode [options] -``` - ---- - -# New CLI Commands - -## Update Execution Alias - -Update the execution alias for a function via the API: - -``` -npx shsf-cli --mode update-alias --project --link --alias -``` - -- ``: Path to your function project folder -- ``: Numeric function ID -- ``: New execution alias (8-128 chars, alphanumeric, hyphens, underscores) - -## Execute Function - -Invoke a function using the `/api/exec/{namespaceId}/{functionId}/{route}` endpoint: - -``` -npx shsf-cli --mode exec --project --link --route [--method ] [--body ] -``` - -- ``: Path to your function project folder -- ``: Numeric function ID -- ``: Route string (as defined in your function) -- `--method`: HTTP method (default: POST) -- `--body`: JSON string for POST body (optional) - -Both commands use `.meta.json` in your project folder to pull `namespaceId` and `executionId` automatically. - -See `--help` for all options. -# SHSF CLI -Command Line Interface for SHSF \ No newline at end of file +# SHSF CLI + +## Installation & Usage + +### Run directly (no install): + +``` +npx shsf-cli --mode [options] +``` + +or with pnpm: + +``` +pnpm dlx shsf-cli --mode [options] +``` + +### Install globally: + +``` +npm install -g shsf-cli +# or +pnpm add -g shsf-cli +``` + +Then use anywhere: + +``` +shsf-cli --mode [options] +``` + +--- + +# New CLI Commands + +## Update Execution Alias + +Update the execution alias for a function via the API: + +``` +npx shsf-cli --mode update-alias --project --link --alias +``` + +- ``: Path to your function project folder +- ``: Numeric function ID +- ``: New execution alias (8-128 chars, alphanumeric, hyphens, underscores) + +## Execute Function + +Invoke a function using the `/api/exec/{namespaceId}/{functionId}/{route}` endpoint: + +``` +npx shsf-cli --mode exec --project --link --route [--method ] [--body ] +``` + +- ``: Path to your function project folder +- ``: Numeric function ID +- ``: Route string (as defined in your function) +- `--method`: HTTP method (default: POST) +- `--body`: JSON string for POST body (optional) + +Both commands use `.meta.json` in your project folder to pull `namespaceId` and `executionId` automatically. + +See `--help` for all options. + +# SHSF CLI + +Command Line Interface for SHSF diff --git a/CLI/index.js b/CLI/index.js index 99d797b..26966c9 100755 --- a/CLI/index.js +++ b/CLI/index.js @@ -1,992 +1,1100 @@ -#!/usr/bin/env node - -const chokidar = require('chokidar'); -const axios = require('axios'); -const fs = require('fs').promises; -const fsSync = require('fs'); -const path = require('path'); -const { Command } = require('commander'); -const chalk = require('chalk'); -const os = require('os'); -const readline = require('readline'); -const { exec } = require('child_process'); -const util = require('util'); - -const execPromise = util.promisify(exec); - -const program = new Command(); -const VERSION = 'b1.0.0'; - -program - .name('shsf-cli') - .description('SHSF CLI for serverless functions') - .version(VERSION) - .requiredOption('--mode ', 'Mode to run: push, pull, watchdog, settings, exec, set-key, set-url, or ignore') // updated help - .option('--project ', 'Project folder path') - .option('--link ', 'Function link ID') - .option('--key ', 'SHSF session key (or use saved config)') // used for set-key too - .option('--file ', 'File pattern to ignore (for ignore mode)') - .option('--list', 'List ignored patterns (for ignore mode)') - .option('--remove', 'Remove pattern from ignore list (for ignore mode)') - .option('--url ', 'Set SHSF instance base URL (for set-url mode)') - .parse(process.argv); - -const opts = program.opts(); - -// Config file path in user's home directory -const CONFIG_PATH = path.join(os.homedir(), '.shsf-cli-config.json'); - -// Load or create config -function loadConfig() { - try { - if (fsSync.existsSync(CONFIG_PATH)) { - const data = fsSync.readFileSync(CONFIG_PATH, 'utf-8'); - return JSON.parse(data); - } - } catch (err) { - console.error(chalk.yellow('⚠ Failed to load config, using defaults')); - } - return { token: null, base_url: null }; -} - -function saveConfig(config) { - try { - fsSync.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); - return true; - } catch (err) { - console.error(chalk.red('✗ Failed to save config:'), err.message); - return false; - } -} - -const config = loadConfig(); - -// AppState -const AppState = { - mode: opts.mode, - link: opts.link ? parseInt(opts.link) : null, - project: opts.project ? path.resolve(opts.project) : null, - shsf_key: opts.key || config.token, - base_url: config.base_url, - headers: {} -}; - -// Settings mode doesn't require authentication initially -if (AppState.mode !== 'settings' && AppState.mode !== 'ignore') { - // Validate SHSF key - if (!AppState.shsf_key) { - console.error(chalk.red('Error: SHSF session token not found.')); - console.error(chalk.yellow('Run with --mode settings to configure your token, or use --key ')); - process.exit(1); - } - - AppState.headers = { - 'X-Access-Key': `${AppState.shsf_key}`, - 'Content-Type': 'application/json', - "User-Agent": `shsf-cli/${VERSION}` - }; - - console.log(chalk.blue(`Running in ${AppState.mode} mode...`)); - console.log(chalk.gray(`Using SHSF Key: ${AppState.shsf_key.substring(0, 8)}...`)); -} - -// Create readline interface for user input -function createReadlineInterface() { - return readline.createInterface({ - input: process.stdin, - output: process.stdout - }); -} - -function question(rl, prompt) { - return new Promise((resolve) => { - rl.question(prompt, (answer) => { - resolve(answer); - }); - }); -} - -// Settings mode handler -async function handleSettings() { - console.log(chalk.blue('\n⚙️ SHSF CLI Settings\n')); - - const rl = createReadlineInterface(); - - try { - console.log(chalk.cyan('What would you like to do?')); - console.log('1. View current settings'); - console.log('2. Change session token'); - console.log('3. Exit\n'); - - const choice = await question(rl, chalk.yellow('Enter your choice (1-3): ')); - - if (choice === '1') { - // View settings - console.log(chalk.cyan('\n📋 Current Settings:')); - console.log(chalk.gray('─'.repeat(50))); - - if (config.token) { - console.log(chalk.white('Session Token: ') + chalk.green(`${config.token.substring(0, 16)}...`)); - console.log(chalk.gray(`(Full token: ${config.token.length} characters)`)); - } else { - console.log(chalk.white('Session Token: ') + chalk.red('Not set')); - } - - console.log(chalk.gray('─'.repeat(50))); - console.log(chalk.gray(`\nConfig file location: ${CONFIG_PATH}\n`)); - - } else if (choice === '2') { - // Change token - console.log(chalk.cyan('\n🔑 Change Session Token\n')); - - if (config.token) { - console.log(chalk.gray(`Current token: ${config.token.substring(0, 16)}...\n`)); - } - - const newToken = await question(rl, chalk.yellow('Enter new session token (or press Enter to cancel): ')); - - if (newToken.trim()) { - config.token = newToken.trim(); - if (saveConfig(config)) { - console.log(chalk.green('\n✓ Session token saved successfully!')); - console.log(chalk.gray(`Saved to: ${CONFIG_PATH}\n`)); - } - } else { - console.log(chalk.yellow('\n⚠ Token change cancelled\n')); - } - - } else if (choice === '3') { - console.log(chalk.gray('\nExiting settings...\n')); - } else { - console.log(chalk.red('\n✗ Invalid choice\n')); - } - - } finally { - rl.close(); - } -} - -// Ignore file management functions -function getIgnorePath(projectPath) { - return path.join(projectPath, '.shsf.ignore'); -} - -async function loadIgnorePatterns(projectPath) { - const ignorePath = getIgnorePath(projectPath); - - try { - if (fsSync.existsSync(ignorePath)) { - const content = await fs.readFile(ignorePath, 'utf-8'); - return content - .split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); - } - } catch (err) { - console.error(chalk.yellow('⚠ Failed to load .shsf.ignore')); - } - - return []; -} - -async function saveIgnorePatterns(projectPath, patterns) { - const ignorePath = getIgnorePath(projectPath); - const content = patterns.join('\n') + '\n'; - - try { - await fs.writeFile(ignorePath, content, 'utf-8'); - return true; - } catch (err) { - console.error(chalk.red('✗ Failed to save .shsf.ignore:'), err.message); - return false; - } -} - -function matchesPattern(filename, pattern) { - // Simple glob matching - if (pattern.includes('*')) { - const regexPattern = pattern - .replace(/\./g, '\\.') - .replace(/\*/g, '.*') - .replace(/\?/g, '.'); - const regex = new RegExp(`^${regexPattern}$`); - return regex.test(filename); - } - - // Exact match or directory match - return filename === pattern || filename.startsWith(pattern + '/'); -} - -function isIgnored(filename, patterns) { - return patterns.some(pattern => matchesPattern(filename, pattern)); -} - -async function handleIgnoreMode() { - if (!AppState.project) { - console.error(chalk.red('Error: --project parameter is required for ignore mode')); - process.exit(1); - } - - if (!fsSync.existsSync(AppState.project)) { - console.error(chalk.red(`Error: Project folder '${AppState.project}' does not exist.`)); - process.exit(1); - } - - const patterns = await loadIgnorePatterns(AppState.project); - - // List patterns - if (opts.list) { - console.log(chalk.blue('\n📋 Ignored Patterns:\n')); - - if (patterns.length === 0) { - console.log(chalk.gray('No patterns configured')); - } else { - patterns.forEach((pattern, index) => { - console.log(chalk.white(`${index + 1}. `) + chalk.cyan(pattern)); - }); - } - - console.log(chalk.gray(`\nIgnore file: ${getIgnorePath(AppState.project)}\n`)); - return; - } - - // Add or remove pattern - if (!opts.file) { - console.error(chalk.red('Error: --file parameter is required to add/remove patterns')); - console.log(chalk.yellow('\nUsage:')); - console.log(chalk.white(' Add pattern: ') + chalk.cyan('--mode ignore --project --file ')); - console.log(chalk.white(' Remove pattern: ') + chalk.cyan('--mode ignore --project --file --remove')); - console.log(chalk.white(' List patterns: ') + chalk.cyan('--mode ignore --project --list')); - console.log(chalk.gray('\nExamples:')); - console.log(chalk.white(' *.log, test_*, node_modules, .vscode\n')); - process.exit(1); - } - - const filePattern = opts.file.trim(); - - if (opts.remove) { - // Remove pattern - if (patterns.includes(filePattern)) { - const newPatterns = patterns.filter(p => p !== filePattern); - if (await saveIgnorePatterns(AppState.project, newPatterns)) { - console.log(chalk.green(`\n✓ Removed pattern: ${filePattern}\n`)); - } - } else { - console.log(chalk.yellow(`\n⚠ Pattern not found: ${filePattern}\n`)); - } - } else { - // Add pattern - if (patterns.includes(filePattern)) { - console.log(chalk.yellow(`\n⚠ Pattern already exists: ${filePattern}\n`)); - } else { - patterns.push(filePattern); - if (await saveIgnorePatterns(AppState.project, patterns)) { - console.log(chalk.green(`\n✓ Added pattern: ${filePattern}\n`)); - } - } - } -} - -// API Helper Functions -async function apiRequest(method, endpoint, data = null) { - try { - const config = { - method, - url: `${AppState.base_url}${endpoint}`, - headers: AppState.headers, - }; - - if (data) { - config.data = data; - } - - const response = await axios(config); - if (response.status !== 200) { - console.error(chalk.red(`Error: ${response.statusText}`)); - return null; - } - return response.data; - } catch (error) { - console.error(chalk.red(`API Error: ${error.message}`)); - if (error.response) { - console.error(chalk.red(`Status: ${error.response.status}`)); - console.error(chalk.red(`Response: ${JSON.stringify(error.response.data)}`)); - } - throw error; - } -} - -// Pull metadata and save to .meta.json -async function pullMetadata() { - console.log(chalk.cyan('Pulling metadata...')); - const data = await apiRequest('GET', `/function/${AppState.link}`); - - if (data.status === 'OK') { - const metaPath = path.join(AppState.project, '.meta.json'); - await fs.writeFile(metaPath, JSON.stringify(data.data, null, 2), 'utf-8'); - console.log(chalk.green('✓ Metadata saved to .meta.json')); - return data.data; - } else { - throw new Error('Failed to retrieve metadata'); - } -} - -// Push metadata from .meta.json -async function pushMetadata() { - const metaPath = path.join(AppState.project, '.meta.json'); - - if (!fsSync.existsSync(metaPath)) { - console.log(chalk.yellow('⚠ No .meta.json found, skipping metadata push')); - return; - } - - console.log(chalk.cyan('Checking metadata changes...')); - - // Read local metadata - const localMeta = JSON.parse(await fs.readFile(metaPath, 'utf-8')); - - // Fetch remote metadata to compare - const remoteData = await apiRequest('GET', `/function/${AppState.link}`); - const remoteMeta = remoteData.data; - - // Compare relevant fields - const hasChanges = - localMeta.name !== remoteMeta.name || - localMeta.description !== remoteMeta.description || - localMeta.image !== remoteMeta.image || - localMeta.startup_file !== remoteMeta.startup_file || - localMeta.max_ram !== remoteMeta.max_ram || - localMeta.timeout !== remoteMeta.timeout || - localMeta.allow_http !== remoteMeta.allow_http || - localMeta.secure_header !== remoteMeta.secure_header || - localMeta.retry_on_failure !== remoteMeta.retry_on_failure || - localMeta.executionAlias !== remoteMeta.executionAlias || - localMeta.max_retries !== remoteMeta.max_retries || - JSON.stringify(localMeta.tags) !== JSON.stringify(remoteMeta.tags); - - if (!hasChanges) { - console.log(chalk.gray('No metadata changes detected')); - return; - } - - console.log(chalk.cyan('Pushing metadata changes...')); - - // Build payload with all supported fields - const payload = {}; - - // Top-level fields - if (localMeta.name !== remoteMeta.name) { - payload.name = localMeta.name; - } - if (localMeta.description !== remoteMeta.description) { - payload.description = localMeta.description; - } - if (localMeta.image !== remoteMeta.image) { - payload.image = localMeta.image; - } - if (localMeta.startup_file !== remoteMeta.startup_file) { - payload.startup_file = localMeta.startup_file; - } - - // Settings object - only include if there are changes - const settings = {}; - let hasSettingsChanges = false; - - if (localMeta.max_ram !== remoteMeta.max_ram) { - settings.max_ram = localMeta.max_ram; - hasSettingsChanges = true; - } - if (localMeta.timeout !== remoteMeta.timeout) { - settings.timeout = localMeta.timeout; - hasSettingsChanges = true; - } - if (localMeta.allow_http !== remoteMeta.allow_http) { - settings.allow_http = localMeta.allow_http; - hasSettingsChanges = true; - } - if (localMeta.secure_header !== remoteMeta.secure_header) { - settings.secure_header = localMeta.secure_header; - hasSettingsChanges = true; - } - if (localMeta.retry_on_failure !== remoteMeta.retry_on_failure) { - settings.retry_on_failure = localMeta.retry_on_failure; - hasSettingsChanges = true; - } - if (localMeta.max_retries !== remoteMeta.max_retries) { - settings.retry_count = localMeta.max_retries; // Note: API expects 'retry_count', meta stores as 'max_retries' - hasSettingsChanges = true; - } - if (localMeta.executionAlias !== remoteMeta.executionAlias) { - settings.executionAlias = localMeta.executionAlias; - hasSettingsChanges = true; - } - - // Handle tags (parse from string if needed) - let localTags = localMeta.tags; - if (typeof localTags === 'string') { - // Tags might be stored as comma-separated string - localTags = localTags ? localTags.split(',').map(t => t.trim()).filter(t => t) : []; - } else if (!Array.isArray(localTags)) { - localTags = []; - } - - let remoteTags = remoteMeta.tags; - if (typeof remoteTags === 'string') { - remoteTags = remoteTags ? remoteTags.split(',').map(t => t.trim()).filter(t => t) : []; - } else if (!Array.isArray(remoteTags)) { - remoteTags = []; - } - - if (JSON.stringify(localTags) !== JSON.stringify(remoteTags)) { - settings.tags = localTags; - hasSettingsChanges = true; - } - - if (hasSettingsChanges) { - payload.settings = settings; - } - - // Only send request if there are actual changes - if (Object.keys(payload).length === 0) { - console.log(chalk.gray('No metadata changes to push')); - return; - } - - const result = await apiRequest('PATCH', `/function/${AppState.link}`, payload); - - if (result.status === 'OK') { - console.log(chalk.green('✓ Metadata updated successfully')); - // Update local .meta.json with server response - await fs.writeFile(metaPath, JSON.stringify(result.data, null, 2), 'utf-8'); - } -} - -// Pull all files -async function pullFiles() { - console.log(chalk.cyan(`Pulling files for link ID ${AppState.link}...`)); - - const data = await apiRequest('GET', `/function/${AppState.link}/files`); - - if (data.status === 'OK') { - const files = data.data; - - for (const file of files) { - const filePath = path.join(AppState.project, file.name); - await fs.writeFile(filePath, file.content, 'utf-8'); - console.log(chalk.green(`✓ Written file: ${file.name}`)); - } - - // Delete local files not on remote (except .env and .meta.json) - const existingFiles = await fs.readdir(AppState.project); - const remoteFileNames = files.map(f => f.name); - - for (const existingFile of existingFiles) { - if (!remoteFileNames.includes(existingFile) && - !existingFile.startsWith('.')) { - await fs.unlink(path.join(AppState.project, existingFile)); - console.log(chalk.yellow(`✓ Deleted file: ${existingFile} (not on remote)`)); - } - } - } -} - -// Pull environment variables -async function pullEnv() { - console.log(chalk.cyan('Pulling environment data...')); - - const data = await apiRequest('GET', `/function/${AppState.link}`); - - if (data.status === 'OK' && data.data.env) { - const env = JSON.parse(data.data.env); - const envPath = path.join(AppState.project, '.env'); - - const envContent = env.map(item => `${item.name}=${item.value}`).join('\n') + '\n'; - await fs.writeFile(envPath, envContent, 'utf-8'); - console.log(chalk.green('✓ Environment variables saved to .env')); - } else { - console.log(chalk.yellow('⚠ No environment data found')); - } -} - -// Push environment variables -async function pushEnv() { - const envPath = path.join(AppState.project, '.env'); - - if (!fsSync.existsSync(envPath)) { - console.log(chalk.gray('No .env file found, skipping')); - return; - } - - console.log(chalk.cyan('Pushing environment variables...')); - - const content = await fs.readFile(envPath, 'utf-8'); - const lines = content.split('\n').filter(line => line.trim() && line.includes('=')); - - const env = lines.map(line => { - const [name, ...valueParts] = line.split('='); - const value = valueParts.join('='); - return { name: name.trim(), value: value.trim() }; - }).filter(item => - item.name.length >= 1 && item.name.length <= 128 && - item.value.length >= 1 && item.value.length <= 256 - ); - - const result = await apiRequest('PATCH', `/function/${AppState.link}`, { - environment: env - }); - - if (result.status === 'OK') { - console.log(chalk.green('✓ Environment variables updated')); - } -} - -// Push single file -async function pushFile(filename, content) { - if (filename.length > 256) { - throw new Error('Filename exceeds 256 characters'); - } - if (!content || content.length === 0) { - throw new Error('File content is empty'); - } - if (content.length > 1000000) { // 1MB limit - throw new Error('File content exceeds 1MB limit'); - } - - await apiRequest('PUT', `/function/${AppState.link}/file`, { - filename, - code: content - }); -} - -// Push all files -async function pushFiles() { - console.log(chalk.cyan('Syncing files...')); - - // Load ignore patterns - const ignorePatterns = await loadIgnorePatterns(AppState.project); - - // Get remote files - const remoteData = await apiRequest('GET', `/function/${AppState.link}/files`); - const remoteFiles = remoteData.status === 'OK' ? remoteData.data : []; - const remoteFileNames = remoteFiles.map(f => f.name); - - // Get local files - const allFiles = await fs.readdir(AppState.project); - const localFiles = []; - - for (const file of allFiles) { - const filePath = path.join(AppState.project, file); - const stat = await fs.stat(filePath); - - if (stat.isFile() && !file.startsWith('.')) { - // Check if file is ignored - if (isIgnored(file, ignorePatterns)) { - console.log(chalk.gray(`⊝ Ignoring: ${file}`)); - continue; - } - localFiles.push(file); - } - } - - // Upload/update local files - for (const filename of localFiles) { - if (filename.length > 256) { - console.log(chalk.yellow(`⚠ Skipping ${filename} (name too long)`)); - continue; - } - - try { - const filePath = path.join(AppState.project, filename); - - // Validate file before syncing - const validation = await validateFileBeforeSync(filePath, filename); - if (!validation.valid) { - console.log(chalk.yellow(`⚠ Skipping ${filename} (${validation.type}): ${validation.message}`)); - continue; - } - - const content = await fs.readFile(filePath, 'utf-8'); - await pushFile(filename, content); - - const action = remoteFileNames.includes(filename) ? 'Updated' : 'Uploaded'; - console.log(chalk.green(`✓ ${action} file: ${filename}`)); - } catch (err) { - if (err.code === 'ERR_ENCODING') { - console.log(chalk.yellow(`⚠ Skipping ${filename} (binary file)`)); - } else { - console.error(chalk.red(`✗ Error with ${filename}: ${err.message}`)); - } - } - } - - // Delete remote files not in local - for (const remoteFile of remoteFiles) { - if (!localFiles.includes(remoteFile.name)) { - try { - await apiRequest('DELETE', `/function/${AppState.link}/file/${remoteFile.id}`); - console.log(chalk.yellow(`✓ Deleted remote file: ${remoteFile.name}`)); - } catch (err) { - console.error(chalk.red(`✗ Failed to delete ${remoteFile.name}`)); - } - } - } -} - -// Validate Python file syntax -async function validatePythonSyntax(filePath) { - try { - // Use Python's compile to check syntax without executing - await execPromise(`python -m py_compile "${filePath}"`, { - timeout: 5000 // 5 second timeout - }); - return { valid: true }; - } catch (error) { - return { - valid: false, - error: error.stderr || error.message - }; - } -} - -// Check if a file should be validated before syncing -function shouldValidateFile(filename) { - const ext = path.extname(filename).toLowerCase(); - return ext === '.py'; -} - -// Validate file before syncing (extensible for other file types) -async function validateFileBeforeSync(filePath, filename) { - if (shouldValidateFile(filename)) { - const ext = path.extname(filename).toLowerCase(); - - if (ext === '.py') { - const validation = await validatePythonSyntax(filePath); - if (!validation.valid) { - return { - valid: false, - message: `Python syntax error: ${validation.error}`, - type: 'syntax' - }; - } - } - } - - return { valid: true }; -} - -// Handle set-url mode -async function handleSetUrl() { - if (!opts.url) { - console.error(chalk.red('Error: --url parameter is required for set-url mode')); - process.exit(1); - } - let url = opts.url.trim(); - // Remove trailing slash if present - if (url.endsWith('/')) url = url.slice(0, -1); - // Add /api if not present - if (!url.endsWith('/api')) url = url + '/api'; - config.base_url = url; - if (saveConfig(config)) { - console.log(chalk.green('\n✓ Instance URL saved successfully!')); - console.log(chalk.gray(`Saved to: ${CONFIG_PATH}`)); - console.log(chalk.gray(`Current instance URL: ${config.base_url}\n`)); - } -} - -// Execute function and log result -async function execFunctionAndLog(namespaceId, route) { - if (!opts.link) { - console.error(chalk.red('Error: --link parameter is required for exec mode')); - process.exit(1); - } - console.log(chalk.cyan(`Executing function (link ID ${AppState.link})...`)); - try { - const data = await apiRequest('POST', `/exec/${namespaceId}/${AppState.link}/${route}`); - if (data.type === 'output') { - const output = [ - `Output:\n${data.output}`, - `Exit Code: ${data.exitCode}`, - `Result: ${JSON.stringify(data.result)}`, - `Took: ${data.took}ms` - ].join('\n'); - console.log(chalk.green('\n=== Execution Result ===\n')); - console.log(output); - - // Log to .last-exec.log in project directory - const logPath = path.join(AppState.project, '.last-exec.log'); - await fs.writeFile(logPath, output + '\n', 'utf-8'); - console.log(chalk.gray(`\n✓ Execution result logged to ${logPath}\n`)); - } else { - console.log(chalk.red('✗ Execution failed:'), data.message || 'Unknown error'); - } - } catch (err) { - console.error(chalk.red('✗ Execution error:'), err.message); - } -} - -// Main execution -async function main() { - // Handle set-key mode - if (AppState.mode === 'set-key') { - if (!opts.key) { - console.error(chalk.red('Error: --key parameter is required for set-key mode')); - process.exit(1); - } - config.token = opts.key.trim(); - if (saveConfig(config)) { - console.log(chalk.green('\n✓ Session token saved successfully!')); - console.log(chalk.gray(`Saved to: ${CONFIG_PATH}\n`)); - } - return; - } - - // Handle set-url mode - if (AppState.mode === 'set-url') { - await handleSetUrl(); - return; - } - - // Handle settings mode first - if (AppState.mode === 'settings') { - await handleSettings(); - return; - } - - // Handle ignore mode - if (AppState.mode === 'ignore') { - await handleIgnoreMode(); - return; - } - - // Validate required parameters for non-settings modes - if (!AppState.project && AppState.mode != 'exec') { - console.error(chalk.red('Error: --project parameter is required')); - process.exit(1); - } - - if (!AppState.link) { - console.error(chalk.red('Error: --link parameter is required')); - process.exit(1); - } - - // Ensure project directory exists - if (!fsSync.existsSync(AppState.project) && AppState.mode !== 'exec') { - if (AppState.mode === 'pull') { - await fs.mkdir(AppState.project, { recursive: true }); - console.log(chalk.green(`✓ Created project directory: ${AppState.project}`)); - } else { - console.error(chalk.red(`Error: Project folder '${AppState.project}' does not exist.`)); - process.exit(1); - } - } - - if (AppState.mode === 'pull') { - await pullMetadata(); - await pullFiles(); - await pullEnv(); - console.log(chalk.green('\n✅ Pull completed successfully!')); - - } else if (AppState.mode === 'push') { - await pushMetadata(); - await pushEnv(); - await pushFiles(); - console.log(chalk.green('\n✅ Push completed successfully!')); - - } else if (AppState.mode === 'watchdog') { - console.log(chalk.blue(`\n👀 Watching project '${AppState.project}' for changes...\n`)); - - // Load ignore patterns - const ignorePatterns = await loadIgnorePatterns(AppState.project); - if (ignorePatterns.length > 0) { - console.log(chalk.gray(`Ignoring patterns: ${ignorePatterns.join(', ')}\n`)); - } - - // Debounce mechanism to prevent multiple syncs - const pendingChanges = new Map(); - const DEBOUNCE_DELAY = 1000; // 1 second delay - - const watcher = chokidar.watch(AppState.project, { - ignored: /(^|[\/\\])\../, // ignore dotfiles - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 1000, - pollInterval: 200 - } - }); - - const handleChange = async (filePath) => { - const filename = path.basename(filePath); - - // Skip dotfiles - if (filename.startsWith('.')) return; - - // Check if file is ignored - if (isIgnored(filename, ignorePatterns)) { - console.log(chalk.gray(`\n⊝ Ignoring change: ${filename}`)); - return; - } - - // Clear existing timeout for this file - if (pendingChanges.has(filename)) { - clearTimeout(pendingChanges.get(filename)); - } - - // Set new timeout - const timeoutId = setTimeout(async () => { - pendingChanges.delete(filename); - - try { - console.log(chalk.cyan(`\n📝 Detected change: ${filename}`)); - - // Validate file before syncing - const validation = await validateFileBeforeSync(filePath, filename); - if (!validation.valid) { - console.log(chalk.yellow(`⚠ Skipping ${filename} (incomplete or invalid): ${validation.message}`)); - return; - } - - const content = await fs.readFile(filePath, 'utf-8'); - await pushFile(filename, content); - console.log(chalk.green(`✓ Pushed: ${filename}`)); - } catch (err) { - console.error(chalk.red(`✗ Failed to push ${filename}: ${err.message}`)); - } - }, DEBOUNCE_DELAY); - - pendingChanges.set(filename, timeoutId); - }; - - watcher - .on('add', handleChange) - .on('change', handleChange) - .on('unlink', async (filePath) => { - const filename = path.basename(filePath); - if (filename.startsWith('.')) return; - - // Check if file is ignored - if (isIgnored(filename, ignorePatterns)) { - return; - } - - // Clear pending change if exists - if (pendingChanges.has(filename)) { - clearTimeout(pendingChanges.get(filename)); - pendingChanges.delete(filename); - } - - try { - console.log(chalk.yellow(`\n🗑️ Detected deletion: ${filename}`)); - // Get file ID from remote - const remoteData = await apiRequest('GET', `/function/${AppState.link}/files`); - const remoteFile = remoteData.data.find(f => f.name === filename); - - if (remoteFile) { - await apiRequest('DELETE', `/function/${AppState.link}/file/${remoteFile.id}`); - console.log(chalk.green(`✓ Deleted: ${filename}`)); - } - } catch (err) { - console.error(chalk.red(`✗ Failed to delete ${filename}: ${err.message}`)); - } - }) - .on('error', error => console.error(chalk.red('Watcher error:'), error)); - - // Graceful shutdown - process.on('SIGINT', () => { - console.log(chalk.yellow('\n\n👋 Stopping watchdog...')); - // Clear all pending timeouts - for (const timeoutId of pendingChanges.values()) { - clearTimeout(timeoutId); - } - watcher.close(); - process.exit(0); - }); - - } else if (AppState.mode === 'update-alias') { - // Update execution alias via PATCH /function/{id} - const metaPath = path.join(AppState.project, '.meta.json'); - if (!fsSync.existsSync(metaPath)) { - console.error(chalk.red('Error: .meta.json not found in project directory.')); - process.exit(1); - } - const localMeta = JSON.parse(await fs.readFile(metaPath, 'utf-8')); - if (!opts.alias) { - console.error(chalk.red('Error: --alias parameter is required for update-alias mode')); - process.exit(1); - } - const payload = { executionAlias: opts.alias }; - const result = await apiRequest('PATCH', `/function/${AppState.link}`, payload); - if (result.status === 'OK') { - console.log(chalk.green('✓ Execution alias updated successfully')); - localMeta.executionAlias = opts.alias; - await fs.writeFile(metaPath, JSON.stringify(localMeta, null, 2), 'utf-8'); - } else { - console.error(chalk.red('✗ Failed to update execution alias')); - } - return; - } else if (AppState.mode === 'exec') { - if (!AppState.project || !fsSync.existsSync(AppState.project)) { - console.error(chalk.red('Error: --project parameter is required for exec mode')); - process.exit(1); - } - // Run function via /api/exec/{namespaceId}/{functionId}/{route} - const metaPath = path.join(AppState.project, '.meta.json'); - if (!fsSync.existsSync(metaPath)) { - console.error(chalk.red('Error: .meta.json not found in project directory.')); - process.exit(1); - } - const localMeta = JSON.parse(await fs.readFile(metaPath, 'utf-8')); - const namespaceId = localMeta.namespaceId; - const functionId = localMeta.executionId; - if (!namespaceId || !functionId) { - console.error(chalk.red('Error: namespaceId or executionId missing in .meta.json')); - process.exit(1); - } - if (opts.route && opts.route.startsWith('/')) { - console.error(chalk.red('Error: --route is not allowed to start with a leading slash')); - process.exit(1); - } - if (opts.route && opts.route.length > 256) { - console.error(chalk.red('Error: --route exceeds maximum length of 256 characters')); - process.exit(1); - } - - // Route validation allows empty string (default route), while executionAlias requires at least one character. - // If you want stricter validation, use /^[a-zA-Z0-9-_]+$/ instead. - if (opts.route && !/^[a-zA-Z0-9-_]*$/.test(opts.route)) { // Only allow alphanumeric, underscore, hyphen; empty route is valid - console.error(chalk.red('Error: --route contains invalid characters')); - process.exit(1); - } - - - const method = opts.method ? opts.method.toUpperCase() : 'POST'; - const route = opts.route ?? "" - let endpoint = `/exec/${namespaceId}/${functionId}/${route}`; - let result; - if (method === 'GET') { - result = await apiRequest('GET', endpoint); - } else { - let body = {}; - if (opts.body) { - try { - body = JSON.parse(opts.body); - } catch (e) { - console.error(chalk.red('Error: --body must be valid JSON')); - process.exit(1); - } - } - result = await apiRequest('POST', endpoint, body); - } - console.log(chalk.green('=== Function Execution Result ===')); - console.log(result); - return; - } else { - console.error(chalk.red(`Error: Unknown mode '${AppState.mode}'. Use: pull, push, watchdog, settings, ignore, update-alias, exec`)); - process.exit(1); - } -} - -main().catch(err => { - console.error(chalk.red('\n❌ Fatal error:'), err.message); - process.exit(1); -}); \ No newline at end of file +#!/usr/bin/env node + +const chokidar = require("chokidar"); +const axios = require("axios"); +const fs = require("fs").promises; +const fsSync = require("fs"); +const path = require("path"); +const { Command } = require("commander"); +const chalk = require("chalk"); +const os = require("os"); +const readline = require("readline"); +const { exec } = require("child_process"); +const util = require("util"); + +const execPromise = util.promisify(exec); + +const program = new Command(); +const VERSION = "b1.0.0"; + +program + .name("shsf-cli") + .description("SHSF CLI for serverless functions") + .version(VERSION) + .requiredOption( + "--mode ", + "Mode to run: push, pull, watchdog, settings, exec, set-key, set-url, or ignore", + ) // updated help + .option("--project ", "Project folder path") + .option("--link ", "Function link ID") + .option("--key ", "SHSF session key (or use saved config)") // used for set-key too + .option("--file ", "File pattern to ignore (for ignore mode)") + .option("--list", "List ignored patterns (for ignore mode)") + .option("--remove", "Remove pattern from ignore list (for ignore mode)") + .option("--url ", "Set SHSF instance base URL (for set-url mode)") + .parse(process.argv); + +const opts = program.opts(); + +// Config file path in user's home directory +const CONFIG_PATH = path.join(os.homedir(), ".shsf-cli-config.json"); + +// Load or create config +function loadConfig() { + try { + if (fsSync.existsSync(CONFIG_PATH)) { + const data = fsSync.readFileSync(CONFIG_PATH, "utf-8"); + return JSON.parse(data); + } + } catch (err) { + console.error(chalk.yellow("⚠ Failed to load config, using defaults")); + } + return { token: null, base_url: null }; +} + +function saveConfig(config) { + try { + fsSync.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8"); + return true; + } catch (err) { + console.error(chalk.red("✗ Failed to save config:"), err.message); + return false; + } +} + +const config = loadConfig(); + +// AppState +const AppState = { + mode: opts.mode, + link: opts.link ? parseInt(opts.link) : null, + project: opts.project ? path.resolve(opts.project) : null, + shsf_key: opts.key || config.token, + base_url: config.base_url, + headers: {}, +}; + +// Settings mode doesn't require authentication initially +if (AppState.mode !== "settings" && AppState.mode !== "ignore") { + // Validate SHSF key + if (!AppState.shsf_key) { + console.error(chalk.red("Error: SHSF session token not found.")); + console.error( + chalk.yellow( + "Run with --mode settings to configure your token, or use --key ", + ), + ); + process.exit(1); + } + + AppState.headers = { + "X-Access-Key": `${AppState.shsf_key}`, + "Content-Type": "application/json", + "User-Agent": `shsf-cli/${VERSION}`, + }; + + console.log(chalk.blue(`Running in ${AppState.mode} mode...`)); + console.log( + chalk.gray(`Using SHSF Key: ${AppState.shsf_key.substring(0, 8)}...`), + ); +} + +// Create readline interface for user input +function createReadlineInterface() { + return readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); +} + +function question(rl, prompt) { + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + resolve(answer); + }); + }); +} + +// Settings mode handler +async function handleSettings() { + console.log(chalk.blue("\n⚙️ SHSF CLI Settings\n")); + + const rl = createReadlineInterface(); + + try { + console.log(chalk.cyan("What would you like to do?")); + console.log("1. View current settings"); + console.log("2. Change session token"); + console.log("3. Exit\n"); + + const choice = await question(rl, chalk.yellow("Enter your choice (1-3): ")); + + if (choice === "1") { + // View settings + console.log(chalk.cyan("\n📋 Current Settings:")); + console.log(chalk.gray("─".repeat(50))); + + if (config.token) { + console.log( + chalk.white("Session Token: ") + + chalk.green(`${config.token.substring(0, 16)}...`), + ); + console.log(chalk.gray(`(Full token: ${config.token.length} characters)`)); + } else { + console.log(chalk.white("Session Token: ") + chalk.red("Not set")); + } + + console.log(chalk.gray("─".repeat(50))); + console.log(chalk.gray(`\nConfig file location: ${CONFIG_PATH}\n`)); + } else if (choice === "2") { + // Change token + console.log(chalk.cyan("\n🔑 Change Session Token\n")); + + if (config.token) { + console.log( + chalk.gray(`Current token: ${config.token.substring(0, 16)}...\n`), + ); + } + + const newToken = await question( + rl, + chalk.yellow("Enter new session token (or press Enter to cancel): "), + ); + + if (newToken.trim()) { + config.token = newToken.trim(); + if (saveConfig(config)) { + console.log(chalk.green("\n✓ Session token saved successfully!")); + console.log(chalk.gray(`Saved to: ${CONFIG_PATH}\n`)); + } + } else { + console.log(chalk.yellow("\n⚠ Token change cancelled\n")); + } + } else if (choice === "3") { + console.log(chalk.gray("\nExiting settings...\n")); + } else { + console.log(chalk.red("\n✗ Invalid choice\n")); + } + } finally { + rl.close(); + } +} + +// Ignore file management functions +function getIgnorePath(projectPath) { + return path.join(projectPath, ".shsf.ignore"); +} + +async function loadIgnorePatterns(projectPath) { + const ignorePath = getIgnorePath(projectPath); + + try { + if (fsSync.existsSync(ignorePath)) { + const content = await fs.readFile(ignorePath, "utf-8"); + return content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + } + } catch (err) { + console.error(chalk.yellow("⚠ Failed to load .shsf.ignore")); + } + + return []; +} + +async function saveIgnorePatterns(projectPath, patterns) { + const ignorePath = getIgnorePath(projectPath); + const content = patterns.join("\n") + "\n"; + + try { + await fs.writeFile(ignorePath, content, "utf-8"); + return true; + } catch (err) { + console.error(chalk.red("✗ Failed to save .shsf.ignore:"), err.message); + return false; + } +} + +function matchesPattern(filename, pattern) { + // Simple glob matching + if (pattern.includes("*")) { + const regexPattern = pattern + .replace(/\./g, "\\.") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(filename); + } + + // Exact match or directory match + return filename === pattern || filename.startsWith(pattern + "/"); +} + +function isIgnored(filename, patterns) { + return patterns.some((pattern) => matchesPattern(filename, pattern)); +} + +async function handleIgnoreMode() { + if (!AppState.project) { + console.error( + chalk.red("Error: --project parameter is required for ignore mode"), + ); + process.exit(1); + } + + if (!fsSync.existsSync(AppState.project)) { + console.error( + chalk.red(`Error: Project folder '${AppState.project}' does not exist.`), + ); + process.exit(1); + } + + const patterns = await loadIgnorePatterns(AppState.project); + + // List patterns + if (opts.list) { + console.log(chalk.blue("\n📋 Ignored Patterns:\n")); + + if (patterns.length === 0) { + console.log(chalk.gray("No patterns configured")); + } else { + patterns.forEach((pattern, index) => { + console.log(chalk.white(`${index + 1}. `) + chalk.cyan(pattern)); + }); + } + + console.log( + chalk.gray(`\nIgnore file: ${getIgnorePath(AppState.project)}\n`), + ); + return; + } + + // Add or remove pattern + if (!opts.file) { + console.error( + chalk.red("Error: --file parameter is required to add/remove patterns"), + ); + console.log(chalk.yellow("\nUsage:")); + console.log( + chalk.white(" Add pattern: ") + + chalk.cyan("--mode ignore --project --file "), + ); + console.log( + chalk.white(" Remove pattern: ") + + chalk.cyan("--mode ignore --project --file --remove"), + ); + console.log( + chalk.white(" List patterns: ") + + chalk.cyan("--mode ignore --project --list"), + ); + console.log(chalk.gray("\nExamples:")); + console.log(chalk.white(" *.log, test_*, node_modules, .vscode\n")); + process.exit(1); + } + + const filePattern = opts.file.trim(); + + if (opts.remove) { + // Remove pattern + if (patterns.includes(filePattern)) { + const newPatterns = patterns.filter((p) => p !== filePattern); + if (await saveIgnorePatterns(AppState.project, newPatterns)) { + console.log(chalk.green(`\n✓ Removed pattern: ${filePattern}\n`)); + } + } else { + console.log(chalk.yellow(`\n⚠ Pattern not found: ${filePattern}\n`)); + } + } else { + // Add pattern + if (patterns.includes(filePattern)) { + console.log(chalk.yellow(`\n⚠ Pattern already exists: ${filePattern}\n`)); + } else { + patterns.push(filePattern); + if (await saveIgnorePatterns(AppState.project, patterns)) { + console.log(chalk.green(`\n✓ Added pattern: ${filePattern}\n`)); + } + } + } +} + +// API Helper Functions +async function apiRequest(method, endpoint, data = null) { + try { + const config = { + method, + url: `${AppState.base_url}${endpoint}`, + headers: AppState.headers, + }; + + if (data) { + config.data = data; + } + + const response = await axios(config); + if (response.status !== 200) { + console.error(chalk.red(`Error: ${response.statusText}`)); + return null; + } + return response.data; + } catch (error) { + console.error(chalk.red(`API Error: ${error.message}`)); + if (error.response) { + console.error(chalk.red(`Status: ${error.response.status}`)); + console.error(chalk.red(`Response: ${JSON.stringify(error.response.data)}`)); + } + throw error; + } +} + +// Pull metadata and save to .meta.json +async function pullMetadata() { + console.log(chalk.cyan("Pulling metadata...")); + const data = await apiRequest("GET", `/function/${AppState.link}`); + + if (data.status === "OK") { + const metaPath = path.join(AppState.project, ".meta.json"); + await fs.writeFile(metaPath, JSON.stringify(data.data, null, 2), "utf-8"); + console.log(chalk.green("✓ Metadata saved to .meta.json")); + return data.data; + } else { + throw new Error("Failed to retrieve metadata"); + } +} + +// Push metadata from .meta.json +async function pushMetadata() { + const metaPath = path.join(AppState.project, ".meta.json"); + + if (!fsSync.existsSync(metaPath)) { + console.log(chalk.yellow("⚠ No .meta.json found, skipping metadata push")); + return; + } + + console.log(chalk.cyan("Checking metadata changes...")); + + // Read local metadata + const localMeta = JSON.parse(await fs.readFile(metaPath, "utf-8")); + + // Fetch remote metadata to compare + const remoteData = await apiRequest("GET", `/function/${AppState.link}`); + const remoteMeta = remoteData.data; + + // Compare relevant fields + const hasChanges = + localMeta.name !== remoteMeta.name || + localMeta.description !== remoteMeta.description || + localMeta.image !== remoteMeta.image || + localMeta.startup_file !== remoteMeta.startup_file || + localMeta.max_ram !== remoteMeta.max_ram || + localMeta.timeout !== remoteMeta.timeout || + localMeta.allow_http !== remoteMeta.allow_http || + localMeta.secure_header !== remoteMeta.secure_header || + localMeta.retry_on_failure !== remoteMeta.retry_on_failure || + localMeta.executionAlias !== remoteMeta.executionAlias || + localMeta.max_retries !== remoteMeta.max_retries || + JSON.stringify(localMeta.tags) !== JSON.stringify(remoteMeta.tags); + + if (!hasChanges) { + console.log(chalk.gray("No metadata changes detected")); + return; + } + + console.log(chalk.cyan("Pushing metadata changes...")); + + // Build payload with all supported fields + const payload = {}; + + // Top-level fields + if (localMeta.name !== remoteMeta.name) { + payload.name = localMeta.name; + } + if (localMeta.description !== remoteMeta.description) { + payload.description = localMeta.description; + } + if (localMeta.image !== remoteMeta.image) { + payload.image = localMeta.image; + } + if (localMeta.startup_file !== remoteMeta.startup_file) { + payload.startup_file = localMeta.startup_file; + } + + // Settings object - only include if there are changes + const settings = {}; + let hasSettingsChanges = false; + + if (localMeta.max_ram !== remoteMeta.max_ram) { + settings.max_ram = localMeta.max_ram; + hasSettingsChanges = true; + } + if (localMeta.timeout !== remoteMeta.timeout) { + settings.timeout = localMeta.timeout; + hasSettingsChanges = true; + } + if (localMeta.allow_http !== remoteMeta.allow_http) { + settings.allow_http = localMeta.allow_http; + hasSettingsChanges = true; + } + if (localMeta.secure_header !== remoteMeta.secure_header) { + settings.secure_header = localMeta.secure_header; + hasSettingsChanges = true; + } + if (localMeta.retry_on_failure !== remoteMeta.retry_on_failure) { + settings.retry_on_failure = localMeta.retry_on_failure; + hasSettingsChanges = true; + } + if (localMeta.max_retries !== remoteMeta.max_retries) { + settings.retry_count = localMeta.max_retries; // Note: API expects 'retry_count', meta stores as 'max_retries' + hasSettingsChanges = true; + } + if (localMeta.executionAlias !== remoteMeta.executionAlias) { + settings.executionAlias = localMeta.executionAlias; + hasSettingsChanges = true; + } + + // Handle tags (parse from string if needed) + let localTags = localMeta.tags; + if (typeof localTags === "string") { + // Tags might be stored as comma-separated string + localTags = localTags + ? localTags + .split(",") + .map((t) => t.trim()) + .filter((t) => t) + : []; + } else if (!Array.isArray(localTags)) { + localTags = []; + } + + let remoteTags = remoteMeta.tags; + if (typeof remoteTags === "string") { + remoteTags = remoteTags + ? remoteTags + .split(",") + .map((t) => t.trim()) + .filter((t) => t) + : []; + } else if (!Array.isArray(remoteTags)) { + remoteTags = []; + } + + if (JSON.stringify(localTags) !== JSON.stringify(remoteTags)) { + settings.tags = localTags; + hasSettingsChanges = true; + } + + if (hasSettingsChanges) { + payload.settings = settings; + } + + // Only send request if there are actual changes + if (Object.keys(payload).length === 0) { + console.log(chalk.gray("No metadata changes to push")); + return; + } + + const result = await apiRequest( + "PATCH", + `/function/${AppState.link}`, + payload, + ); + + if (result.status === "OK") { + console.log(chalk.green("✓ Metadata updated successfully")); + // Update local .meta.json with server response + await fs.writeFile(metaPath, JSON.stringify(result.data, null, 2), "utf-8"); + } +} + +// Pull all files +async function pullFiles() { + console.log(chalk.cyan(`Pulling files for link ID ${AppState.link}...`)); + + const data = await apiRequest("GET", `/function/${AppState.link}/files`); + + if (data.status === "OK") { + const files = data.data; + + for (const file of files) { + const filePath = path.join(AppState.project, file.name); + await fs.writeFile(filePath, file.content, "utf-8"); + console.log(chalk.green(`✓ Written file: ${file.name}`)); + } + + // Delete local files not on remote (except .env and .meta.json) + const existingFiles = await fs.readdir(AppState.project); + const remoteFileNames = files.map((f) => f.name); + + for (const existingFile of existingFiles) { + if ( + !remoteFileNames.includes(existingFile) && + !existingFile.startsWith(".") + ) { + await fs.unlink(path.join(AppState.project, existingFile)); + console.log( + chalk.yellow(`✓ Deleted file: ${existingFile} (not on remote)`), + ); + } + } + } +} + +// Pull environment variables +async function pullEnv() { + console.log(chalk.cyan("Pulling environment data...")); + + const data = await apiRequest("GET", `/function/${AppState.link}`); + + if (data.status === "OK" && data.data.env) { + const env = JSON.parse(data.data.env); + const envPath = path.join(AppState.project, ".env"); + + const envContent = + env.map((item) => `${item.name}=${item.value}`).join("\n") + "\n"; + await fs.writeFile(envPath, envContent, "utf-8"); + console.log(chalk.green("✓ Environment variables saved to .env")); + } else { + console.log(chalk.yellow("⚠ No environment data found")); + } +} + +// Push environment variables +async function pushEnv() { + const envPath = path.join(AppState.project, ".env"); + + if (!fsSync.existsSync(envPath)) { + console.log(chalk.gray("No .env file found, skipping")); + return; + } + + console.log(chalk.cyan("Pushing environment variables...")); + + const content = await fs.readFile(envPath, "utf-8"); + const lines = content + .split("\n") + .filter((line) => line.trim() && line.includes("=")); + + const env = lines + .map((line) => { + const [name, ...valueParts] = line.split("="); + const value = valueParts.join("="); + return { name: name.trim(), value: value.trim() }; + }) + .filter( + (item) => + item.name.length >= 1 && + item.name.length <= 128 && + item.value.length >= 1 && + item.value.length <= 256, + ); + + const result = await apiRequest("PATCH", `/function/${AppState.link}`, { + environment: env, + }); + + if (result.status === "OK") { + console.log(chalk.green("✓ Environment variables updated")); + } +} + +// Push single file +async function pushFile(filename, content) { + if (filename.length > 256) { + throw new Error("Filename exceeds 256 characters"); + } + if (!content || content.length === 0) { + throw new Error("File content is empty"); + } + if (content.length > 1000000) { + // 1MB limit + throw new Error("File content exceeds 1MB limit"); + } + + await apiRequest("PUT", `/function/${AppState.link}/file`, { + filename, + code: content, + }); +} + +// Push all files +async function pushFiles() { + console.log(chalk.cyan("Syncing files...")); + + // Load ignore patterns + const ignorePatterns = await loadIgnorePatterns(AppState.project); + + // Get remote files + const remoteData = await apiRequest("GET", `/function/${AppState.link}/files`); + const remoteFiles = remoteData.status === "OK" ? remoteData.data : []; + const remoteFileNames = remoteFiles.map((f) => f.name); + + // Get local files + const allFiles = await fs.readdir(AppState.project); + const localFiles = []; + + for (const file of allFiles) { + const filePath = path.join(AppState.project, file); + const stat = await fs.stat(filePath); + + if (stat.isFile() && !file.startsWith(".")) { + // Check if file is ignored + if (isIgnored(file, ignorePatterns)) { + console.log(chalk.gray(`⊝ Ignoring: ${file}`)); + continue; + } + localFiles.push(file); + } + } + + // Upload/update local files + for (const filename of localFiles) { + if (filename.length > 256) { + console.log(chalk.yellow(`⚠ Skipping ${filename} (name too long)`)); + continue; + } + + try { + const filePath = path.join(AppState.project, filename); + + // Validate file before syncing + const validation = await validateFileBeforeSync(filePath, filename); + if (!validation.valid) { + console.log( + chalk.yellow( + `⚠ Skipping ${filename} (${validation.type}): ${validation.message}`, + ), + ); + continue; + } + + const content = await fs.readFile(filePath, "utf-8"); + await pushFile(filename, content); + + const action = remoteFileNames.includes(filename) ? "Updated" : "Uploaded"; + console.log(chalk.green(`✓ ${action} file: ${filename}`)); + } catch (err) { + if (err.code === "ERR_ENCODING") { + console.log(chalk.yellow(`⚠ Skipping ${filename} (binary file)`)); + } else { + console.error(chalk.red(`✗ Error with ${filename}: ${err.message}`)); + } + } + } + + // Delete remote files not in local + for (const remoteFile of remoteFiles) { + if (!localFiles.includes(remoteFile.name)) { + try { + await apiRequest( + "DELETE", + `/function/${AppState.link}/file/${remoteFile.id}`, + ); + console.log(chalk.yellow(`✓ Deleted remote file: ${remoteFile.name}`)); + } catch (err) { + console.error(chalk.red(`✗ Failed to delete ${remoteFile.name}`)); + } + } + } +} + +// Validate Python file syntax +async function validatePythonSyntax(filePath) { + try { + // Use Python's compile to check syntax without executing + await execPromise(`python -m py_compile "${filePath}"`, { + timeout: 5000, // 5 second timeout + }); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error.stderr || error.message, + }; + } +} + +// Check if a file should be validated before syncing +function shouldValidateFile(filename) { + const ext = path.extname(filename).toLowerCase(); + return ext === ".py"; +} + +// Validate file before syncing (extensible for other file types) +async function validateFileBeforeSync(filePath, filename) { + if (shouldValidateFile(filename)) { + const ext = path.extname(filename).toLowerCase(); + + if (ext === ".py") { + const validation = await validatePythonSyntax(filePath); + if (!validation.valid) { + return { + valid: false, + message: `Python syntax error: ${validation.error}`, + type: "syntax", + }; + } + } + } + + return { valid: true }; +} + +// Handle set-url mode +async function handleSetUrl() { + if (!opts.url) { + console.error( + chalk.red("Error: --url parameter is required for set-url mode"), + ); + process.exit(1); + } + let url = opts.url.trim(); + // Remove trailing slash if present + if (url.endsWith("/")) url = url.slice(0, -1); + // Add /api if not present + if (!url.endsWith("/api")) url = url + "/api"; + config.base_url = url; + if (saveConfig(config)) { + console.log(chalk.green("\n✓ Instance URL saved successfully!")); + console.log(chalk.gray(`Saved to: ${CONFIG_PATH}`)); + console.log(chalk.gray(`Current instance URL: ${config.base_url}\n`)); + } +} + +// Execute function and log result +async function execFunctionAndLog(namespaceId, route) { + if (!opts.link) { + console.error(chalk.red("Error: --link parameter is required for exec mode")); + process.exit(1); + } + console.log(chalk.cyan(`Executing function (link ID ${AppState.link})...`)); + try { + const data = await apiRequest( + "POST", + `/exec/${namespaceId}/${AppState.link}/${route}`, + ); + if (data.type === "output") { + const output = [ + `Output:\n${data.output}`, + `Exit Code: ${data.exitCode}`, + `Result: ${JSON.stringify(data.result)}`, + `Took: ${data.took}ms`, + ].join("\n"); + console.log(chalk.green("\n=== Execution Result ===\n")); + console.log(output); + + // Log to .last-exec.log in project directory + const logPath = path.join(AppState.project, ".last-exec.log"); + await fs.writeFile(logPath, output + "\n", "utf-8"); + console.log(chalk.gray(`\n✓ Execution result logged to ${logPath}\n`)); + } else { + console.log( + chalk.red("✗ Execution failed:"), + data.message || "Unknown error", + ); + } + } catch (err) { + console.error(chalk.red("✗ Execution error:"), err.message); + } +} + +// Main execution +async function main() { + // Handle set-key mode + if (AppState.mode === "set-key") { + if (!opts.key) { + console.error( + chalk.red("Error: --key parameter is required for set-key mode"), + ); + process.exit(1); + } + config.token = opts.key.trim(); + if (saveConfig(config)) { + console.log(chalk.green("\n✓ Session token saved successfully!")); + console.log(chalk.gray(`Saved to: ${CONFIG_PATH}\n`)); + } + return; + } + + // Handle set-url mode + if (AppState.mode === "set-url") { + await handleSetUrl(); + return; + } + + // Handle settings mode first + if (AppState.mode === "settings") { + await handleSettings(); + return; + } + + // Handle ignore mode + if (AppState.mode === "ignore") { + await handleIgnoreMode(); + return; + } + + // Validate required parameters for non-settings modes + if (!AppState.project && AppState.mode != "exec") { + console.error(chalk.red("Error: --project parameter is required")); + process.exit(1); + } + + if (!AppState.link) { + console.error(chalk.red("Error: --link parameter is required")); + process.exit(1); + } + + // Ensure project directory exists + if (!fsSync.existsSync(AppState.project) && AppState.mode !== "exec") { + if (AppState.mode === "pull") { + await fs.mkdir(AppState.project, { recursive: true }); + console.log(chalk.green(`✓ Created project directory: ${AppState.project}`)); + } else { + console.error( + chalk.red(`Error: Project folder '${AppState.project}' does not exist.`), + ); + process.exit(1); + } + } + + if (AppState.mode === "pull") { + await pullMetadata(); + await pullFiles(); + await pullEnv(); + console.log(chalk.green("\n✅ Pull completed successfully!")); + } else if (AppState.mode === "push") { + await pushMetadata(); + await pushEnv(); + await pushFiles(); + console.log(chalk.green("\n✅ Push completed successfully!")); + } else if (AppState.mode === "watchdog") { + console.log( + chalk.blue(`\n👀 Watching project '${AppState.project}' for changes...\n`), + ); + + // Load ignore patterns + const ignorePatterns = await loadIgnorePatterns(AppState.project); + if (ignorePatterns.length > 0) { + console.log(chalk.gray(`Ignoring patterns: ${ignorePatterns.join(", ")}\n`)); + } + + // Debounce mechanism to prevent multiple syncs + const pendingChanges = new Map(); + const DEBOUNCE_DELAY = 1000; // 1 second delay + + const watcher = chokidar.watch(AppState.project, { + ignored: /(^|[\/\\])\../, // ignore dotfiles + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 1000, + pollInterval: 200, + }, + }); + + const handleChange = async (filePath) => { + const filename = path.basename(filePath); + + // Skip dotfiles + if (filename.startsWith(".")) return; + + // Check if file is ignored + if (isIgnored(filename, ignorePatterns)) { + console.log(chalk.gray(`\n⊝ Ignoring change: ${filename}`)); + return; + } + + // Clear existing timeout for this file + if (pendingChanges.has(filename)) { + clearTimeout(pendingChanges.get(filename)); + } + + // Set new timeout + const timeoutId = setTimeout(async () => { + pendingChanges.delete(filename); + + try { + console.log(chalk.cyan(`\n📝 Detected change: ${filename}`)); + + // Validate file before syncing + const validation = await validateFileBeforeSync(filePath, filename); + if (!validation.valid) { + console.log( + chalk.yellow( + `⚠ Skipping ${filename} (incomplete or invalid): ${validation.message}`, + ), + ); + return; + } + + const content = await fs.readFile(filePath, "utf-8"); + await pushFile(filename, content); + console.log(chalk.green(`✓ Pushed: ${filename}`)); + } catch (err) { + console.error(chalk.red(`✗ Failed to push ${filename}: ${err.message}`)); + } + }, DEBOUNCE_DELAY); + + pendingChanges.set(filename, timeoutId); + }; + + watcher + .on("add", handleChange) + .on("change", handleChange) + .on("unlink", async (filePath) => { + const filename = path.basename(filePath); + if (filename.startsWith(".")) return; + + // Check if file is ignored + if (isIgnored(filename, ignorePatterns)) { + return; + } + + // Clear pending change if exists + if (pendingChanges.has(filename)) { + clearTimeout(pendingChanges.get(filename)); + pendingChanges.delete(filename); + } + + try { + console.log(chalk.yellow(`\n🗑️ Detected deletion: ${filename}`)); + // Get file ID from remote + const remoteData = await apiRequest( + "GET", + `/function/${AppState.link}/files`, + ); + const remoteFile = remoteData.data.find((f) => f.name === filename); + + if (remoteFile) { + await apiRequest( + "DELETE", + `/function/${AppState.link}/file/${remoteFile.id}`, + ); + console.log(chalk.green(`✓ Deleted: ${filename}`)); + } + } catch (err) { + console.error(chalk.red(`✗ Failed to delete ${filename}: ${err.message}`)); + } + }) + .on("error", (error) => console.error(chalk.red("Watcher error:"), error)); + + // Graceful shutdown + process.on("SIGINT", () => { + console.log(chalk.yellow("\n\n👋 Stopping watchdog...")); + // Clear all pending timeouts + for (const timeoutId of pendingChanges.values()) { + clearTimeout(timeoutId); + } + watcher.close(); + process.exit(0); + }); + } else if (AppState.mode === "update-alias") { + // Update execution alias via PATCH /function/{id} + const metaPath = path.join(AppState.project, ".meta.json"); + if (!fsSync.existsSync(metaPath)) { + console.error( + chalk.red("Error: .meta.json not found in project directory."), + ); + process.exit(1); + } + const localMeta = JSON.parse(await fs.readFile(metaPath, "utf-8")); + if (!opts.alias) { + console.error( + chalk.red("Error: --alias parameter is required for update-alias mode"), + ); + process.exit(1); + } + const payload = { executionAlias: opts.alias }; + const result = await apiRequest( + "PATCH", + `/function/${AppState.link}`, + payload, + ); + if (result.status === "OK") { + console.log(chalk.green("✓ Execution alias updated successfully")); + localMeta.executionAlias = opts.alias; + await fs.writeFile(metaPath, JSON.stringify(localMeta, null, 2), "utf-8"); + } else { + console.error(chalk.red("✗ Failed to update execution alias")); + } + return; + } else if (AppState.mode === "exec") { + if (!AppState.project || !fsSync.existsSync(AppState.project)) { + console.error( + chalk.red("Error: --project parameter is required for exec mode"), + ); + process.exit(1); + } + // Run function via /api/exec/{namespaceId}/{functionId}/{route} + const metaPath = path.join(AppState.project, ".meta.json"); + if (!fsSync.existsSync(metaPath)) { + console.error( + chalk.red("Error: .meta.json not found in project directory."), + ); + process.exit(1); + } + const localMeta = JSON.parse(await fs.readFile(metaPath, "utf-8")); + const namespaceId = localMeta.namespaceId; + const functionId = localMeta.executionId; + if (!namespaceId || !functionId) { + console.error( + chalk.red("Error: namespaceId or executionId missing in .meta.json"), + ); + process.exit(1); + } + if (opts.route && opts.route.startsWith("/")) { + console.error( + chalk.red("Error: --route is not allowed to start with a leading slash"), + ); + process.exit(1); + } + if (opts.route && opts.route.length > 256) { + console.error( + chalk.red("Error: --route exceeds maximum length of 256 characters"), + ); + process.exit(1); + } + + // Route validation allows empty string (default route), while executionAlias requires at least one character. + // If you want stricter validation, use /^[a-zA-Z0-9-_]+$/ instead. + if (opts.route && !/^[a-zA-Z0-9-_]*$/.test(opts.route)) { + // Only allow alphanumeric, underscore, hyphen; empty route is valid + console.error(chalk.red("Error: --route contains invalid characters")); + process.exit(1); + } + + const method = opts.method ? opts.method.toUpperCase() : "POST"; + const route = opts.route ?? ""; + let endpoint = `/exec/${namespaceId}/${functionId}/${route}`; + let result; + if (method === "GET") { + result = await apiRequest("GET", endpoint); + } else { + let body = {}; + if (opts.body) { + try { + body = JSON.parse(opts.body); + } catch (e) { + console.error(chalk.red("Error: --body must be valid JSON")); + process.exit(1); + } + } + result = await apiRequest("POST", endpoint, body); + } + console.log(chalk.green("=== Function Execution Result ===")); + console.log(result); + return; + } else { + console.error( + chalk.red( + `Error: Unknown mode '${AppState.mode}'. Use: pull, push, watchdog, settings, ignore, update-alias, exec`, + ), + ); + process.exit(1); + } +} + +main().catch((err) => { + console.error(chalk.red("\n❌ Fatal error:"), err.message); + process.exit(1); +}); diff --git a/CLI/package.json b/CLI/package.json index 0d8b76d..29c76a1 100644 --- a/CLI/package.json +++ b/CLI/package.json @@ -1,26 +1,26 @@ { - "name": "shsf-cli", - "version": "1.0.5", - "description": "CLI tool to sync folder changes to an API", - "main": "index.js", - "bin": { - "shsf-cli": "./index.js" - }, - "scripts": { - "start": "node index.js" - }, - "keywords": [ - "sync", - "file-watcher", - "cli", - "shsf" - ], - "author": "Space Banane", - "license": "MIT", - "dependencies": { - "axios": "^1.13.1", - "chalk": "^4.1.2", - "chokidar": "^3.6.0", - "commander": "^11.1.0" - } -} \ No newline at end of file + "name": "shsf-cli", + "version": "1.0.5", + "description": "CLI tool to sync folder changes to an API", + "main": "index.js", + "bin": { + "shsf-cli": "./index.js" + }, + "scripts": { + "start": "node index.js" + }, + "keywords": [ + "sync", + "file-watcher", + "cli", + "shsf" + ], + "author": "Space Banane", + "license": "MIT", + "dependencies": { + "axios": "^1.13.1", + "chalk": "^4.1.2", + "chokidar": "^3.6.0", + "commander": "^11.1.0" + } +} diff --git a/README.md b/README.md index b832f69..2f9cece 100644 --- a/README.md +++ b/README.md @@ -35,24 +35,27 @@ Before installing SHSF, ensure you have: ### Quick Start 1. **Clone the repository** + ```bash git clone https://github.com/Space-Banane/shsf.git cd shsf ``` 2. **Configure environment** + ```bash cp .env.example .env nano .env # Edit configuration to your needs ``` 3. **Start the services** + ```bash docker-compose up -d ``` 4. **Access the interface** - + Open your browser and navigate to `http://localhost:3000` (or your configured port) ## 📖 Usage @@ -79,6 +82,7 @@ Before installing SHSF, ensure you have: ### Function Structure Python function example: + ```python def main(args): # Your function logic here @@ -89,6 +93,7 @@ def main(args): ## 🔧 API Reference ### Function Execution + ```bash # HTTP trigger POST FUNCTION_URL/exec @@ -101,7 +106,6 @@ Content-Type: application/json } ``` - ## 🗺️ Roadmap - [ ] SHSF ACTION(soon) runtime support @@ -113,6 +117,7 @@ Content-Type: application/json ## 🤝 Contributing I'm welcome contributions! + ## 📄 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. @@ -123,4 +128,4 @@ This README, except this part, was fully generated by Github Copilot. You'll not --- -Made with ❤️ for the self-hosting community \ No newline at end of file +Made with ❤️ for the self-hosting community diff --git a/UI/package.json b/UI/package.json index 41bcf45..243229e 100644 --- a/UI/package.json +++ b/UI/package.json @@ -1,60 +1,60 @@ { - "name": "shsf", - "version": "1.0.1", - "private": true, - "dependencies": { - "@monaco-editor/react": "^4.7.0", - "@tailwindcss/cli": "^4.1.16", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.5.2", - "@types/node": "^16.18.126", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@types/react-router-dom": "^5.3.3", - "@types/react-syntax-highlighter": "^15.5.13", - "motion": "^12.23.24", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-router-dom": "^7.9.5", - "react-scripts": "5.0.1", - "react-syntax-highlighter": "^15.6.6", - "serve": "^14.2.5", - "tailwindcss": "^4.1.16", - "typescript": "^4.9.5", - "web-vitals": "^2.1.4" - }, - "scripts": { - "dev": "PORT=443 react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "serve": "serve -s build", - "tailwind:watch": "npx @tailwindcss/cli -i ./src/index.css -o ./public/styles.css --watch", - "tailwind:build": "npx @tailwindcss/cli -i ./src/index.css -o ./public/styles.css" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "devDependencies": { - "eslint": "^8.57.1", - "eslint-config-react-app": "^7.0.1" - } + "name": "shsf", + "version": "1.0.1", + "private": true, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/cli": "^4.1.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.126", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@types/react-router-dom": "^5.3.3", + "@types/react-syntax-highlighter": "^15.5.13", + "motion": "^12.23.24", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.5", + "react-scripts": "5.0.1", + "react-syntax-highlighter": "^15.6.6", + "serve": "^14.2.5", + "tailwindcss": "^4.1.16", + "typescript": "^4.9.5", + "web-vitals": "^2.1.4" + }, + "scripts": { + "dev": "PORT=443 react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "serve": "serve -s build", + "tailwind:watch": "npx @tailwindcss/cli -i ./src/index.css -o ./public/styles.css --watch", + "tailwind:build": "npx @tailwindcss/cli -i ./src/index.css -o ./public/styles.css" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "eslint": "^8.57.1", + "eslint-config-react-app": "^7.0.1" + } } diff --git a/UI/pnpm-workspace.yaml b/UI/pnpm-workspace.yaml index 733797d..032163c 100644 --- a/UI/pnpm-workspace.yaml +++ b/UI/pnpm-workspace.yaml @@ -1,4 +1,4 @@ onlyBuiltDependencies: - - '@tailwindcss/oxide' - - core-js - - core-js-pure + - "@tailwindcss/oxide" + - core-js + - core-js-pure diff --git a/UI/public/index.html b/UI/public/index.html index 17eda6a..fed52bb 100644 --- a/UI/public/index.html +++ b/UI/public/index.html @@ -1,20 +1,20 @@ - + - - - - - - - - - SHSF - Serverless Functions for you - - - -
- + + + + + + + + + SHSF - Serverless Functions for you + + + +
+ diff --git a/UI/public/manifest.json b/UI/public/manifest.json index e4a6a70..b33f6fd 100644 --- a/UI/public/manifest.json +++ b/UI/public/manifest.json @@ -1,15 +1,15 @@ { - "short_name": "SHSF", - "name": "A Selfhostable Cloud-Function Service", - "icons": [ - { - "src": "small.ico", - "sizes": "128x128", - "type": "image/x-icon" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" + "short_name": "SHSF", + "name": "A Selfhostable Cloud-Function Service", + "icons": [ + { + "src": "small.ico", + "sizes": "128x128", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" } diff --git a/UI/src/App.tsx b/UI/src/App.tsx index 7e72607..11336eb 100644 --- a/UI/src/App.tsx +++ b/UI/src/App.tsx @@ -5,7 +5,6 @@ import { User } from "./types/Prisma"; import { BASE_URL } from "."; import { TextScramble } from "./utils/TextScramble"; - // Create a context for user data export const UserContext = createContext<{ user: User | null; @@ -73,16 +72,17 @@ function App() { {/* Logo */} {/* Navigation Links */}