fix: fs issues [PUT-846] (#2918)

* fix: fs issues

* windows install script
This commit is contained in:
Daniel Salazar
2026-05-05 10:49:04 -07:00
committed by GitHub
parent 98bdaf0c42
commit 50fe867e0d
5 changed files with 305 additions and 16 deletions
+203
View File
@@ -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"
+48 -8
View File
@@ -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 `/<username>/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 `/<user>/AppData/<app_uid>` 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<Array<{ uid: string; path: string }>> | 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,
@@ -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, {
@@ -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
* `/<user>/AppData/<app_uid>` 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<void> {
const parent = pathPosix.dirname(targetPath);
const parentForCheck = parent === '/' ? targetPath : parent;
const makeDescriptor = (path: string) => {
let cache: Promise<Array<{ uid: string; path: string }>> | 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 = {
+6 -2
View File
@@ -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(