diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 000000000..a27ff4dad --- /dev/null +++ b/install.ps1 @@ -0,0 +1,203 @@ +#!/usr/bin/env pwsh +# Self-hosted Puter — one-shot installer (PowerShell port of install.sh). +# +# Usage (interactive): +# .\install.ps1 +# +# Usage (one-liner, like curl|sh): +# irm https://raw.githubusercontent.com/HeyPuter/puter/main/install.ps1 | iex +# (when piped through iex, params can't be passed; use env vars below) +# +# What this does, in order: +# 1. Checks that docker (with the compose plugin) exists. +# 2. Creates ./puter-selfhosted/ (override with $env:PUTER_DIR). +# 3. Downloads docker-compose.yml + nginx.conf from the OSS repo. +# 4. Generates fresh secrets and writes .env + puter/config/config.json. +# 5. Runs `docker compose up -d` and prints how to find the admin password. +# +# Re-running in an already-initialised directory is a no-op for config +# (it won't clobber existing .env / config.json) and just refreshes the +# compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite. +# +# Tunable env vars (or pass as -Parameters when running the file directly): +# PUTER_DIR install directory (default: ./puter-selfhosted) +# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch) +# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost) +# PUTER_PORT HTTP port for nginx (default: 80) +# PUTER_FORCE set to 1 to overwrite existing .env / config.json + +[CmdletBinding()] +param( + [string]$PuterDir = $(if ($env:PUTER_DIR) { $env:PUTER_DIR } else { 'puter-selfhosted' }), + [string]$PuterUrl = $(if ($env:PUTER_URL) { $env:PUTER_URL } else { 'https://raw.githubusercontent.com/HeyPuter/puter/main' }), + [string]$PuterDomain = $(if ($env:PUTER_DOMAIN) { $env:PUTER_DOMAIN } else { 'puter.localhost' }), + [int] $PuterPort = $(if ($env:PUTER_PORT) { [int]$env:PUTER_PORT } else { 80 }), + [switch]$Force = $($env:PUTER_FORCE -eq '1') +) + +$ErrorActionPreference = 'Stop' + +function Write-Log { param($Msg) Write-Host "[puter-install] $Msg" -ForegroundColor Cyan } +function Write-Warn { param($Msg) Write-Host "[puter-install] $Msg" -ForegroundColor Yellow } +function Die { param($Msg) Write-Host "[puter-install] $Msg" -ForegroundColor Red; exit 1 } + +function Test-Command { + param([string]$Name) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + Die "missing required command: $Name" + } +} + +function New-HexSecret { + param([int]$Bytes) + $buf = New-Object byte[] $Bytes + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($buf) } finally { $rng.Dispose() } + # BitConverter is portable across PS 5.1 and 7+ (ToHexString is 7+ only). + return [System.BitConverter]::ToString($buf).Replace('-', '').ToLowerInvariant() +} + +function Write-Utf8NoBomLF { + param([string]$Path, [string]$Content) + $full = if ([System.IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path (Get-Location) $Path } + $lf = $Content -replace "`r`n", "`n" + [System.IO.File]::WriteAllText($full, $lf, (New-Object System.Text.UTF8Encoding $false)) +} + +# ── Step 1: dependency check ──────────────────────────────────────── +Write-Log 'checking dependencies' +Test-Command docker +# curl + openssl aren't required on Windows; PowerShell + .NET cover both. + +$null = & docker compose version 2>&1 +if ($LASTEXITCODE -ne 0) { + Die "docker compose plugin not found — install Docker Desktop (or enable the v2 compose plugin)" +} + +# ── Step 2: install dir ───────────────────────────────────────────── +$null = New-Item -ItemType Directory -Force -Path $PuterDir +Set-Location $PuterDir +$null = New-Item -ItemType Directory -Force -Path 'puter/config', 'puter/data', 'puter/tls' +Write-Log "install dir: $((Get-Location).Path)" + +# ── Step 3: docker-compose.yml + nginx config ────────────────────── +Write-Log "downloading docker-compose.yml from $PuterUrl" +try { + Invoke-WebRequest -Uri "$PuterUrl/docker-compose.yml" -OutFile 'docker-compose.yml' -UseBasicParsing +} catch { + Die "could not fetch $PuterUrl/docker-compose.yml — $_" +} + +Write-Log "downloading nginx/nginx.conf from $PuterUrl" +$null = New-Item -ItemType Directory -Force -Path 'nginx' +# If the path was previously auto-created as a directory by a failed +# `compose up`, remove it so we can write the file there. +if (Test-Path 'nginx/nginx.conf' -PathType Container) { + Remove-Item 'nginx/nginx.conf' -Recurse -Force +} +try { + Invoke-WebRequest -Uri "$PuterUrl/nginx/nginx.conf" -OutFile 'nginx/nginx.conf' -UseBasicParsing +} catch { + Die "could not fetch $PuterUrl/nginx/nginx.conf — $_" +} + +# ── Step 4: secrets, .env, config.json ────────────────────────────── +$writeConfig = $true +if ((Test-Path '.env') -and (Test-Path 'puter/config/config.json') -and -not $Force) { + Write-Log ".env + config.json already present — keeping existing secrets (PUTER_FORCE=1 or -Force to overwrite)" + $writeConfig = $false +} + +if ($writeConfig) { + Write-Log 'generating secrets' + $mariadbRootPw = New-HexSecret 32 + $mariadbPw = New-HexSecret 32 + $s3SecretKey = New-HexSecret 32 + $jwtSecret = New-HexSecret 64 + $urlSigSecret = New-HexSecret 64 + + $envContent = @" +HTTP_PORT=$PuterPort +# HTTPS_PORT=443 # uncomment after enabling TLS (see doc/selfhosting/full-stack.md) + +MARIADB_ROOT_PASSWORD=$mariadbRootPw +MARIADB_DATABASE=puter +MARIADB_USER=puter +MARIADB_PASSWORD=$mariadbPw + +S3_ACCESS_KEY=puter +S3_SECRET_KEY=$s3SecretKey +S3_BUCKET=puter-local +"@ + Write-Utf8NoBomLF -Path '.env' -Content $envContent + + Write-Log 'writing puter/config/config.json' + $config = [ordered]@{ + domain = $PuterDomain + protocol = 'http' + pub_port = $PuterPort + env = 'prod' + static_hosting_domain = "site.$PuterDomain" + static_hosting_domain_alt = "host.$PuterDomain" + private_app_hosting_domain = "app.$PuterDomain" + private_app_hosting_domain_alt = "dev.$PuterDomain" + jwt_secret = $jwtSecret + url_signature_secret = $urlSigSecret + database = [ordered]@{ + engine = 'mysql' + host = 'mariadb' + port = 3306 + user = 'puter' + password = $mariadbPw + database = 'puter' + migrationPaths = @('/opt/puter/dist/src/backend/clients/database/migrations/mysql') + } + redis = [ordered]@{ + startupNodes = @( + [ordered]@{ host = 'valkey'; port = 6379 } + ) + tls = $false + } + dynamo = [ordered]@{ + endpoint = 'http://dynamo:8000' + bootstrapTables = $true + aws = [ordered]@{ + access_key = 'fake' + secret_key = 'fake' + region = 'us-east-1' + } + } + s3 = [ordered]@{ + s3Config = [ordered]@{ + endpoint = 'http://s3:9000' + publicEndpoint = "http://s3.$PuterDomain" + accessKeyId = 'puter' + secretAccessKey = $s3SecretKey + region = 'us-east-1' + forcePathStyle = $true + } + } + s3_bucket = 'puter-local' + s3_region = 'us-east-1' + providers = [ordered]@{ + ollama = [ordered]@{ enabled = $false } + } + trust_proxy = 1 + } + $configJson = $config | ConvertTo-Json -Depth 10 + Write-Utf8NoBomLF -Path 'puter/config/config.json' -Content $configJson +} + +# ── Step 5: bring it up ───────────────────────────────────────────── +Write-Log 'docker compose up -d' +& docker compose up -d +if ($LASTEXITCODE -ne 0) { Die 'docker compose up failed' } + +Write-Log '' +Write-Log 'stack starting. first boot takes ~30s while MariaDB initialises.' +Write-Log 'follow puter logs:' +Write-Log " cd $PuterDir; docker compose logs -f puter" +Write-Log '' +Write-Log "open http://${PuterDomain}:${PuterPort} once the puter container is healthy." +Write-Log 'first-boot admin password is logged once — grab it with:' +Write-Log " cd $PuterDir; docker compose logs puter | Select-String tmp_password" \ No newline at end of file diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index 01df0fa7b..01ae5e106 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -1001,20 +1001,14 @@ export class FSController extends PuterController { if (!rawPath.trim()) throw new HttpError(400, 'Missing `path`'); // Normalize first: expands `~`, collapses `..`, ensures leading `/` - // and no trailing `/`. Without this, `pathPosix.dirname(...)` below + // and no trailing `/`. Without this, parent-path derivation below // would compute a wrong parent for `~/...` inputs (e.g. dirname of // `/~/Documents/foo` is `/~/Documents`, not `//Documents`). const username = this.#getActorUsername(req); const path = this.#normalizePath(rawPath, username); if (path === '/') throw new HttpError(400, 'Cannot mkdir at root'); - // ACL: write on parent (or on target path if overwriting existing). - const parentPath = pathPosix.dirname(path); - await this.#assertAccess( - actor, - parentPath === '/' ? path : parentPath, - 'write', - ); + await this.#assertCanCreate(actor, path); const entry = await this.services.fs.mkdir(userId, { path, @@ -1223,6 +1217,52 @@ export class FSController extends PuterController { return entry; } + /** + * Authorize creation of a new entry at `targetPath`. The standard rule + * is write on the parent, but we also accept write on the target itself + * — this lets an app create its own `//AppData/` folder + * (parent `AppData` is off-limits, but the target is the app's own + * subtree per ACLService's short-circuit) and lets recipients of a + * direct share on a not-yet-existent path materialize it. + */ + async #assertCanCreate(actor: Actor, targetPath: string) { + const parent = pathPosix.dirname(targetPath); + const parentForCheck = parent === '/' ? targetPath : parent; + const fsService = this.services.fs; + + const makeDescriptor = (path: string) => { + let cache: Promise> | null = + null; + return { + path, + resolveAncestors() { + if (!cache) cache = fsService.getAncestorChain(path); + return cache; + }, + }; + }; + + if ( + await this.services.acl.check( + actor, + makeDescriptor(parentForCheck), + 'write', + ) + ) { + return; + } + if ( + await this.services.acl.check( + actor, + makeDescriptor(targetPath), + 'write', + ) + ) { + return; + } + await this.#assertAccess(actor, parentForCheck, 'write'); + } + async #assertAccess( actor: Actor, path: string, diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 35ffb77f3..a984e727a 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -34,6 +34,7 @@ import { FS_COSTS } from './costs.js'; import { asRecord, assertAccess, + assertCanCreate, getBoolean, getString, loadLegacyAssociatedApps, @@ -475,15 +476,14 @@ export class LegacyFSController extends PuterController { : `${parentPath.replace(/\/+$/, '')}/${rawPath}`; } - const parentPath = pathPosix.dirname( - targetPath.startsWith('/') ? targetPath : `/${targetPath}`, - ); - await assertAccess( + const normalizedTarget = targetPath.startsWith('/') + ? targetPath + : `/${targetPath}`; + await assertCanCreate( this.services.acl, this.services.fs, actor, - parentPath === '/' ? targetPath : parentPath, - 'write', + normalizedTarget, ); const entry = await this.services.fs.mkdir(userId, { diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index a02650aae..19b8c3334 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -210,6 +210,48 @@ export async function assertAccess( }); } +/** + * Authorize creation of a new entry at `targetPath`. The standard rule is + * write on the parent, but we also allow it when the actor has explicit + * write on the target itself — this covers an app creating its own + * `//AppData/` folder (parent `AppData` is off-limits, but + * the target is the app's own subtree per ACLService's short-circuit) and + * shares granted directly on a not-yet-created path. + * + * On failure, delegates to `assertAccess` on the parent so the error + * shape stays identical to the previous parent-only check. + */ +export async function assertCanCreate( + aclService: ACLService, + fsService: FSService, + actor: Actor, + targetPath: string, +): Promise { + const parent = pathPosix.dirname(targetPath); + const parentForCheck = parent === '/' ? targetPath : parent; + + const makeDescriptor = (path: string) => { + let cache: Promise> | null = null; + return { + path, + resolveAncestors() { + if (!cache) cache = fsService.getAncestorChain(path); + return cache; + }, + }; + }; + + if ( + await aclService.check(actor, makeDescriptor(parentForCheck), 'write') + ) { + return; + } + if (await aclService.check(actor, makeDescriptor(targetPath), 'write')) { + return; + } + await assertAccess(aclService, fsService, actor, parentForCheck, 'write'); +} + // ── Response shaping ──────────────────────────────────────────────── type AppRowLookup = { diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 78b1675aa..4c9026706 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -2741,8 +2741,12 @@ export class FSService extends PuterService { if (!this.#isUniqueViolation(err)) throw err; const insertedPath = parent.path === '/' ? `/${name}` : `${parent.path}/${name}`; - const raced = - await this.stores.fsEntry.getEntryByPath(insertedPath); + // The dup violation proves a row exists, so a replica miss here + // would surface the raw ER_DUP_ENTRY as a 500. Read primary too. + const raced = await this.stores.fsEntry.getEntryByPath( + insertedPath, + { useTryHardRead: true }, + ); if (raced?.isDir) return raced; if (raced) { throw new HttpError(