@echo off REM ========================================================== REM QuickSite - Post-Clone Setup (Windows) REM ========================================================== REM REM Run this after cloning, before your first page load. REM REM Usage: REM setup.bat (menu) REM setup.bat www.example.com (pre-answer the public-folder rename, REM then show the menu) REM set PHP=C:\php83\php.exe (then setup.bat: check that PHP's version REM rather than the php on the PATH; in REM PowerShell, $env:PHP = 'C:\php83\php.exe') REM REM A MENU, NOT A WALK. Every item is independent and re-runnable: come back REM later to change one thing without answering the other five again. The REM settings you choose are remembered in .quicksite.conf and read back the REM next time you run this. REM REM 1. Public folder name - match your vhost DocumentRoot (www, public_html) REM 2. Secure folder name - rename/nest the engine folder for obscurity REM 3. URL space - serve from a subdirectory (http://domain/web/) REM 4. Environment - production (default) or development REM 5. Self-registration - may visitors create their own accounts? REM 6. Setup token - read the first-run credential off disk REM 7. Storage quotas - per-account size and upload-rate ceilings REM 8. Command console - offer the raw command runner at /admin/command? REM 9. Default language - the language a new project starts in REM 10. Idle sign-out - how long the panel may go unused before it signs REM you out REM 11. Embed security page - show the embed policy's page in the panel? REM REM Items 1-3 rewrite init.php constants and the .htaccess routing. Items 4-5 REM and 7-11 write config files under the secure folder. Item 6 only reads. REM REM Before anything else, it checks the PHP version: older than 8.3, it stops. REM REM KNOWN LIMITS ON WINDOWS - what may not work, and when. One line per case; REM a case met later is added here. REM - Answers piped in (type answers.txt | setup.bat): cmd's set /p keeps the REM first line only, and every later question reads nothing. Type them, or REM redirect a file: setup.bat < answers.txt REM REM WHAT THIS SCRIPT CANNOT DO: create your account. No account exists yet, so REM there is nobody to be one. That happens in the browser at /admin/, and the REM setup token (item 6) is what authorises it - see the notes there. REM ========================================================== setlocal enabledelayedexpansion REM ========================================================== REM The PHP floor - checked before anything is written REM ========================================================== REM QuickSite needs PHP 8.3 or later. This script runs no PHP of its own, so it REM asks the php on the PATH - or the one the PHP variable names - for its REM version, and stops at once when it is older, before any file below is REM written. That is the command-line PHP: the web server's own may be a REM separate install, which is why the README asks for a patched release of REM the branch either way. Only the floor is checked here: patch numbers change REM with every advisory, so they live in the README. REM REM -n runs it without php.ini, so an extension that fails to load prints REM nothing here. The version is read with delayed expansion (!QS_PHP!), so a REM path holding parentheses (Program Files (x86)) cannot close the for /f. REM A version made of anything but digits is no version. set "QS_PHP=%PHP%" if not defined QS_PHP for /f "delims=" %%p in ('where php 2^>nul') do if not defined QS_PHP set "QS_PHP=%%p" set "QS_PHP_ID=" set "QS_PHP_VER=" if defined QS_PHP for /f "tokens=1,2" %%a in ('call "!QS_PHP!" -n -r "echo PHP_VERSION_ID, ' ', PHP_VERSION;" 2^>nul') do if not defined QS_PHP_ID ( set "QS_PHP_ID=%%a" set "QS_PHP_VER=%%b" ) if defined QS_PHP_ID for /f "delims=0123456789" %%x in ("!QS_PHP_ID!") do set "QS_PHP_ID=" if not defined QS_PHP_ID ( echo. if defined QS_PHP ( echo ^^! The PHP version could not be checked: !QS_PHP! answered no version. echo QuickSite needs PHP 8.3 or later. ) else ( echo ^^! The PHP version could not be checked: no php command was found. echo QuickSite needs PHP 8.3 or later. To have it checked, name one before setup.bat: echo Command Prompt: set PHP=C:\path\to\php.exe echo PowerShell: $env:PHP = 'C:\path\to\php.exe' ) ) else if !QS_PHP_ID! LSS 80300 ( echo. echo X QuickSite needs PHP 8.3 or later; the PHP found here ^(!QS_PHP!, !QS_PHP_VER!^) is older. echo Install a supported PHP, a patched release of its branch ^(see the README^), and run setup again. echo. set "QS_ANYKEY=" set /p "QS_ANYKEY= Press Enter to close... " exit /b 1 ) set "SCRIPT_DIR=%~dp0" set "PUBLIC_DIR=%SCRIPT_DIR%public" set "SECURE_DIR=%SCRIPT_DIR%secure" set "PUBLIC_FOLDER_NAME=public" set "SECURE_FOLDER_NAME=secure" set "PUBLIC_SPACE=" REM Read existing config if available (re-run detection) set "CONF_FILE=%SCRIPT_DIR%.quicksite.conf" REM Has setup ever run here? Captured BEFORE save_conf writes the file, because REM the landing-page drop below is a first-launch-only action. set "FIRST_LAUNCH=yes" if exist "%CONF_FILE%" set "FIRST_LAUNCH=no" if exist "%CONF_FILE%" ( for /f "usebackq tokens=1,2 delims==" %%a in ("%CONF_FILE%") do ( if "%%a"=="PUBLIC_FOLDER_NAME" set "PUBLIC_FOLDER_NAME=%%b" if "%%a"=="SECURE_FOLDER_NAME" set "SECURE_FOLDER_NAME=%%b" if "%%a"=="PUBLIC_SPACE" set "PUBLIC_SPACE=%%b" ) set "PUBLIC_DIR=!SCRIPT_DIR!!PUBLIC_FOLDER_NAME!" set "SECURE_DIR=!SCRIPT_DIR!!SECURE_FOLDER_NAME!" ) REM ---- Crash recovery: locate folders renamed without us ------------------- if not exist "!PUBLIC_DIR!" ( for /d %%d in ("%SCRIPT_DIR%*") do ( if exist "%%d\init.php" ( set "PUBLIC_FOLDER_NAME=%%~nxd" set "PUBLIC_DIR=!SCRIPT_DIR!%%~nxd" ) ) ) if not exist "!SECURE_DIR!" ( if exist "!PUBLIC_DIR!\init.php" ( for /f "tokens=2 delims='" %%a in ('findstr /c:"SECURE_FOLDER_NAME" "!PUBLIC_DIR!\init.php" 2^>nul') do ( if exist "%SCRIPT_DIR%%%a" ( set "SECURE_DIR=%SCRIPT_DIR%%%a" set "SECURE_FOLDER_NAME=%%a" ) ) ) ) echo. echo ======================================== echo QuickSite Setup (Windows) echo ======================================== call :maybe_place_landing_page call :maybe_create_embed_policy call :save_conf REM A public folder name given on the command line pre-answers item 1. if not "%~1"=="" ( set "ARG_PUBLIC=%~1" call :item_public call :save_conf ) REM ========================================================== REM The menu REM ========================================================== :menu call :read_state echo. echo Public folder: !PUBLIC_FOLDER_NAME! echo Secure folder: !SECURE_FOLDER_NAME! echo URL space: !SPACE_LABEL! echo Environment: !ENV_LABEL! echo Self-registration: !SELFREG_LABEL! echo Storage quotas: !QUOTA_LABEL! echo Command console: !CONSOLE_LABEL! echo Default language: !DEFLANG_LABEL! echo Idle sign-out: !IDLE_LABEL! echo Embed security: !EMBED_LABEL! echo. echo 1^) Rename the public folder echo 2^) Rename the secure folder echo 3^) Change the URL space / prefix echo 4^) Switch environment (development / production) echo 5^) Turn self-registration on / off echo 6^) Show my setup token echo 7^) Storage quotas (per user) echo 8^) Offer / withhold the command console ^(/admin/command^) echo 9^) Default language for new projects echo 10^) Idle sign-out ^(how long the panel may go unused^) echo 11^) Show / hide the Embed Security page ^(/admin/embed-security^) echo q^) Finish echo. set "CHOICE=" set /p "CHOICE= Choice: " if "!CHOICE!"=="1" goto :go_public if "!CHOICE!"=="2" goto :go_secure if "!CHOICE!"=="3" goto :go_space if "!CHOICE!"=="4" goto :go_env if "!CHOICE!"=="5" goto :go_selfreg if "!CHOICE!"=="6" goto :go_token if "!CHOICE!"=="7" goto :go_quota if "!CHOICE!"=="8" goto :go_console if "!CHOICE!"=="9" goto :go_deflang if "!CHOICE!"=="10" goto :go_idle if "!CHOICE!"=="11" goto :go_embed if /i "!CHOICE!"=="q" goto :finish if "!CHOICE!"=="" goto :finish echo Unknown choice: !CHOICE! goto :menu :go_public set "ARG_PUBLIC=" call :item_public call :save_conf call :pause_menu goto :menu :go_secure call :item_secure call :save_conf call :pause_menu goto :menu :go_space call :item_space call :save_conf call :pause_menu goto :menu :go_env call :item_environment call :pause_menu goto :menu :go_selfreg call :item_selfreg call :pause_menu goto :menu :go_token call :item_token call :pause_menu goto :menu :go_quota call :item_quota call :pause_menu goto :menu :go_console call :item_console call :pause_menu goto :menu :go_deflang call :item_deflang call :pause_menu goto :menu :go_idle call :item_idle call :pause_menu goto :menu :go_embed call :item_embed call :pause_menu goto :menu REM ========================================================== REM Finish REM ========================================================== :finish call :save_conf call :read_state echo. echo ======================================== echo Setup complete echo ======================================== echo. echo Public folder: !PUBLIC_FOLDER_NAME! echo Secure folder: !SECURE_FOLDER_NAME! echo URL space: !SPACE_LABEL! echo Environment: !ENV_LABEL! echo Self-registration: !SELFREG_LABEL! echo Storage quotas: !QUOTA_LABEL! echo Command console: !CONSOLE_LABEL! echo Default language: !DEFLANG_LABEL! echo Idle sign-out: !IDLE_LABEL! echo Embed security: !EMBED_LABEL! echo. echo Next steps: echo 1. Point your vhost DocumentRoot at the public folder echo 2. Restart your web server echo 3. Load http://your-domain/admin/ once - this creates the config echo files and mints your setup token echo 4. On nginx: the page that appears tells you which file to include echo in your server block; add it and reload nginx. On Apache: nothing echo to do. echo 5. Run setup.bat again and choose item 6 to read your setup token, echo then create your first account on the /admin/ page. echo. echo That first account becomes the owner of the starter project, so the echo panel has something to edit the moment you sign in. echo. pause goto :eof REM ========================================================== REM State readers - the menu shows what is actually on disk REM ========================================================== REM ========================================================== REM Item 7 - storage quotas REM ========================================================== REM Absent means THE DEFAULTS, and that is a real setting rather than a missing REM one: no storage ceiling and no upload rate, with the size caps and the write REM rate at the values the engine ships. A fresh install has no quota.php and must REM not refuse uploads out of the box. Leaving both prompts blank therefore REM leaves the file ABSENT — it does not write zeros. Setup also never deletes REM a quota.php somebody wrote; it says where the file is instead. :item_quota call :read_state echo. echo Storage quotas echo. echo Limits applied per ACCOUNT, on two independent axes. Any signed-in echo user can otherwise upload until the disk is full. echo. echo total bytes the combined size of everything the account owns. echo Refused with 507 before the write, not after. echo upload rate how many uploads it may perform per period. Bounds echo churn (upload, delete, repeat) rather than volume. echo. echo Currently: !QUOTA_LABEL! echo. echo Leave both blank for no storage ceiling and no upload rate; the size echo caps and the write rate still apply. Full documentation of every option echo lives in quota.php.example, which is never modified. echo. set "Q_MB=" set /p "Q_MB= Max total size per user, in MB [blank = unlimited]: " set "Q_UP=" set /p "Q_UP= Max uploads per period [blank = unlimited]: " REM Reject non-numeric input rather than coercing it: "2GB" would otherwise REM become 0, i.e. unlimited, which is the opposite of what was asked for. if not "!Q_MB!"=="" ( for /f "delims=0123456789" %%A in ("!Q_MB!") do ( echo X Not a whole number of MB: !Q_MB! goto :eof ) ) if not "!Q_UP!"=="" ( for /f "delims=0123456789" %%A in ("!Q_UP!") do ( echo X Not a whole number: !Q_UP! goto :eof ) ) set "Q_HOURS=" if not "!Q_UP!"=="" if not "!Q_UP!"=="0" ( set /p "Q_HOURS= ...per how many hours [1]: " if "!Q_HOURS!"=="" set "Q_HOURS=1" for /f "delims=0123456789" %%A in ("!Q_HOURS!") do ( echo X Not a whole number of hours: !Q_HOURS! goto :eof ) if "!Q_HOURS!"=="0" ( echo X Not a whole number of hours: 0 goto :eof ) ) set "Q_BYTES=0" set "Q_MAXUP=0" set "Q_PERIOD=3600" if not "!Q_MB!"=="" set /a Q_BYTES=!Q_MB! * 1024 * 1024 if not "!Q_UP!"=="" set "Q_MAXUP=!Q_UP!" if not "!Q_HOURS!"=="" set /a Q_PERIOD=!Q_HOURS! * 3600 if "!Q_BYTES!"=="0" if "!Q_MAXUP!"=="0" ( if not exist "!QUOTA_FILE!" ( echo. echo + No storage ceiling or upload rate - quota.php left absent, which echo is what a fresh install has. The size caps and the write rate apply at echo their defaults. goto :eof ) echo. echo quota.php already exists. To go back to the defaults, delete it: echo !QUOTA_FILE! echo Setup does not remove a config file you wrote. goto :eof ) REM quota.php.example is the shipped documentation and is never edited: the REM live file is a COPY with the numbers patched, so every explanatory comment REM survives into the file the deployer will actually read later. if not exist "!QUOTA_FILE!" ( if not exist "!CONFIG_DIR!\quota.php.example" ( echo X Error: quota.php.example is missing goto :eof ) copy /y "!CONFIG_DIR!\quota.php.example" "!QUOTA_FILE!" >nul ) set "PS_Q_TEMP=%TEMP%\qs_setup_quota.ps1" echo $f = '!QUOTA_FILE!' > "%PS_Q_TEMP%" echo $b = '!Q_BYTES!' >> "%PS_Q_TEMP%" echo $u = '!Q_MAXUP!' >> "%PS_Q_TEMP%" echo $p = '!Q_PERIOD!' >> "%PS_Q_TEMP%" echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_Q_TEMP%" REM Anchored per-line so the COMMENTED example value above each setting (those REM lines begin with //) is left alone. The '>' characters sit inside double REM quotes, which is what stops cmd reading them as redirects - no caret. echo $c = $c -replace "(?m)^(\s*)'max_total_bytes'\s*=>.*", ("${1}'max_total_bytes' => " + $b + ",") >> "%PS_Q_TEMP%" echo $c = $c -replace "(?m)^(\s*)'max_uploads'\s*=>.*", ("${1}'max_uploads' => " + $u + ",") >> "%PS_Q_TEMP%" echo $c = $c -replace "(?m)^(\s*)'period_seconds'\s*=>.*", ("${1}'period_seconds' => " + $p + ",") >> "%PS_Q_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_Q_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_Q_TEMP%" >nul 2>&1 call :read_state echo. echo + Quotas written to quota.php echo !QUOTA_LABEL! goto :eof :read_state set "CONFIG_DIR=!SECURE_DIR!\management\config" set "ENV_FILE=!CONFIG_DIR!\environment.php" set "AUTH_FILE=!CONFIG_DIR!\auth.php" set "TOKEN_FILE=!CONFIG_DIR!\setup-token.txt" if "!PUBLIC_SPACE!"=="" ( set "SPACE_LABEL=(none) - served from the domain root" ) else ( REM No caret before the '>' here: inside set "..." the quotes already stop REM cmd reading it as a redirect, and an escape it does not need survives REM into the value as a literal '^'. set "SPACE_LABEL=!PUBLIC_SPACE! -> http://your-domain/!PUBLIC_SPACE!/" ) set "ENV_LABEL=production (default - no environment.php yet)" if exist "!ENV_FILE!" ( set "ENV_LABEL=production (value unreadable - check environment.php)" findstr /c:"'environment' => 'production'" "!ENV_FILE!" >nul 2>&1 && set "ENV_LABEL=production" findstr /c:"'environment' => 'development'" "!ENV_FILE!" >nul 2>&1 && set "ENV_LABEL=development (may call localhost / LAN addresses)" ) set "QUOTA_FILE=!CONFIG_DIR!\quota.php" set "QUOTA_LABEL=size caps + write rate (defaults) (quota.php not created yet; no storage ceiling, no upload rate)" if exist "!QUOTA_FILE!" ( set "QUOTA_LABEL=storage or upload limits set (see quota.php, which also holds the size caps and the write rate)" findstr /c:"'max_total_bytes' => 0," "!QUOTA_FILE!" >nul 2>&1 && findstr /c:"'max_uploads' => 0," "!QUOTA_FILE!" >nul 2>&1 && set "QUOTA_LABEL=no storage ceiling, no upload rate (quota.php present; it also holds the size caps and the write rate)" ) set "SELFREG_LABEL=off (default - auth.php not created yet)" if exist "!AUTH_FILE!" ( set "SELFREG_LABEL=off (value unreadable - check auth.php)" findstr /c:"'allow_self_registration' => false" "!AUTH_FILE!" >nul 2>&1 && set "SELFREG_LABEL=off" findstr /c:"'allow_self_registration' => true" "!AUTH_FILE!" >nul 2>&1 && set "SELFREG_LABEL=on (anyone may create an account at /admin/register)" ) REM ABSENT MEANS ALLOWED here. The console writes nothing and grants nothing: REM every command it lists is already reachable at /management/ by any caller REM whose role allows it. So a fresh install nobody configured keeps its REM developer tooling. REM REM Note the "value unreadable" branch says OFF, not the default. Absent and REM broken are opposite answers here, and a broken console.php leaves the console REM off - the only reason to create the file is to turn it off, so a typo must REM not silently re-open it. The engine reads it the same way. set "CONSOLE_FILE=!CONFIG_DIR!\console.php" set "CONSOLE_LABEL=on (default - console.php not created yet)" if exist "!CONSOLE_FILE!" ( set "CONSOLE_LABEL=off (value unreadable - check console.php)" findstr /c:" 'allow_console' => false," "!CONSOLE_FILE!" >nul 2>&1 && set "CONSOLE_LABEL=off (/admin/command is not offered)" findstr /c:" 'allow_console' => true," "!CONSOLE_FILE!" >nul 2>&1 && set "CONSOLE_LABEL=on" ) REM ABSENT MEANS SHOWN for the Embed Security page too, and a broken file hides REM it, for the console's reason: the file exists only to say no. set "EMBED_FILE=!CONFIG_DIR!\embed-security.php" set "EMBED_LABEL=shown (default - embed-security.php not created yet)" if exist "!EMBED_FILE!" ( set "EMBED_LABEL=hidden (value unreadable - check embed-security.php)" findstr /c:" 'show_embed_security' => false," "!EMBED_FILE!" >nul 2>&1 && set "EMBED_LABEL=hidden (/admin/embed-security is not in the menu)" findstr /c:" 'show_embed_security' => true," "!EMBED_FILE!" >nul 2>&1 && set "EMBED_LABEL=shown" ) REM The default language for new projects. ABSENT MEANS 'en', and so does a REM file the engine cannot use - broken, or naming a code the language list REM does not hold. The label says which of the two, because a file somebody REM wrote that the engine ignores is worth a look (the engine logs why). REM The list is the installation's own languages.json when it has made one, REM the shipped list otherwise. (The engine also falls back to the shipped list REM when the own copy is not JSON; findstr cannot tell, so that is the one case REM where the two disagree.) set "DEFLANG_FILE=!CONFIG_DIR!\default-language.php" set "LANGLIST_FILE=!CONFIG_DIR!\languages.json.example" if exist "!CONFIG_DIR!\languages.json" set "LANGLIST_FILE=!CONFIG_DIR!\languages.json" set "DEFLANG_EFFECTIVE=en" set "DEFLANG_LABEL=en (default - default-language.php not created yet)" if exist "!DEFLANG_FILE!" call :deflang_read REM The idle sign-out: auth.php's session.idle_ttl, in seconds. ABSENT MEANS 1 REM DAY, and the engine never goes below 5 minutes. set "IDLE_SECS=" set "IDLE_EFFECTIVE=86400" set "IDLE_LABEL=1 day (default - auth.php not created yet)" if exist "!AUTH_FILE!" call :idle_read goto :eof REM The code default-language.php names, when it is one the list holds. The REM pattern writes the quotes around the key as '.', so no single quote reaches REM the for /f command line, and it is anchored at the start of the line so the REM file's own comments - which talk about the setting - never match. :deflang_read set "DEFLANG_LABEL=en (value unusable - check default-language.php)" set "DL_CODE=" for /f "tokens=4 delims='" %%a in ('findstr /r /c:"^ *.default_language. *=> *.[a-z]*.," "!DEFLANG_FILE!" 2^>nul') do if not defined DL_CODE set "DL_CODE=%%a" if not defined DL_CODE goto :eof call :deflang_listed if errorlevel 1 goto :eof set "DEFLANG_LABEL=!DL_CODE!" set "DEFLANG_EFFECTIVE=!DL_CODE!" goto :eof REM errorlevel 0 when DL_CODE is a code in the language list: two or three REM lowercase letters (the engine's own rule, spelled letter by letter because REM findstr's [a-z] also matches most capitals), then a key of the list. The REM value is passed in a variable, never as a call argument: call parses its REM arguments a second time, and an & typed at the prompt would run. :deflang_listed set "DL_TMP=%TEMP%\qs_setup_deflang.txt" > "!DL_TMP!" echo(!DL_CODE! findstr /r /x /c:"[abcdefghijklmnopqrstuvwxyz][abcdefghijklmnopqrstuvwxyz]" /c:"[abcdefghijklmnopqrstuvwxyz][abcdefghijklmnopqrstuvwxyz][abcdefghijklmnopqrstuvwxyz]" "!DL_TMP!" >nul 2>&1 set "DL_SHAPE=!errorlevel!" del "!DL_TMP!" 2>nul if not "!DL_SHAPE!"=="0" exit /b 1 if not exist "!LANGLIST_FILE!" exit /b 1 findstr /r /c:"\"!DL_CODE!\" *:" "!LANGLIST_FILE!" >nul 2>&1 if errorlevel 1 exit /b 1 exit /b 0 REM The value from its own line, which starts with the key - written '.idle_ttl.' REM so no single quote reaches the for /f command line, as deflang_read does: a REM value the file's comments quote is not the setting. The label tells "no REM auth.php yet" from "an auth.php that sets none", and says when a value is REM under the minimum the engine raises it to. :idle_read set "IDLE_LABEL=1 day (default - auth.php does not set it)" for /f "tokens=2 delims=>," %%a in ('findstr /r /c:"^ *.idle_ttl. *=> *[0-9][0-9]*," "!AUTH_FILE!" 2^>nul') do if not defined IDLE_SECS set "IDLE_SECS=%%a" if not defined IDLE_SECS goto :eof set "IDLE_SECS=!IDLE_SECS: =!" set "IW_SECS=!IDLE_SECS!" call :idle_words if !IDLE_SECS! LSS 300 ( set "IDLE_EFFECTIVE=300" set "IDLE_LABEL=5 minutes (auth.php says !IDLE_WORDS!; the engine never goes below 5 minutes)" goto :eof ) set "IDLE_EFFECTIVE=!IDLE_SECS!" set "IDLE_LABEL=!IDLE_WORDS!" goto :eof REM IDLE_WORDS: IW_SECS seconds in the largest unit that holds them whole - 86400 REM is "1 day", 5400 is "90 minutes": the setting as it is, never rounded. :idle_words set "IW_U=second" set "IW_N=!IW_SECS!" set /a IW_R=IW_SECS %% 60 if not "!IW_R!"=="0" goto :idle_words_say set /a IW_N=IW_SECS / 60 set "IW_U=minute" set /a IW_R=IW_SECS %% 3600 if not "!IW_R!"=="0" goto :idle_words_say set /a IW_N=IW_SECS / 3600 set "IW_U=hour" set /a IW_R=IW_SECS %% 86400 if not "!IW_R!"=="0" goto :idle_words_say set /a IW_N=IW_SECS / 86400 set "IW_U=day" :idle_words_say if "!IW_N!"=="1" (set "IDLE_WORDS=1 !IW_U!") else (set "IDLE_WORDS=!IW_N! !IW_U!s") goto :eof REM A line-oriented "press Enter", not `pause`. `pause` consumes a single REM CHARACTER, so on a redirected stdin it splits a line in half and the menu REM then reads the remainder as a bogus choice. set /p consumes a whole line, REM which behaves identically for a person and correctly for a script. :pause_menu echo. set "QS_ANYKEY=" set /p "QS_ANYKEY= Press Enter to return to the menu... " goto :eof :save_conf ( echo PUBLIC_FOLDER_NAME=!PUBLIC_FOLDER_NAME! echo SECURE_FOLDER_NAME=!SECURE_FOLDER_NAME! echo PUBLIC_SPACE=!PUBLIC_SPACE! ) > "%CONF_FILE%" goto :eof REM ========================================================== REM Landing page - first launch only, and only into an empty root REM ========================================================== REM QuickSite serves nothing at the web root by design, so a brand-new install REM has nothing there and answers 403. That is correct and it looks broken, REM which is the worst combination - hence a placeholder that says where the REM panel is. REM REM TWO CONDITIONS, both required. It runs only on the FIRST setup launch, so REM deleting the page is permanent rather than something the next run undoes; REM and only when the root holds no index file of its own, so cloning QuickSite REM onto a server that already has a site never overwrites that site's front REM page. :maybe_place_landing_page if not "!FIRST_LAUNCH!"=="yes" goto :eof call :resolve_init if not exist "!SERVED_DIR!" goto :eof set "QS_LANDING_SRC=!SECURE_DIR!\deploy\index.html.example" if not exist "!QS_LANDING_SRC!" goto :eof REM Any index.* at all - .php, .html, .htm, anything the server might pick as a REM DirectoryIndex. If something is already there, it is not ours to touch. if exist "!SERVED_DIR!\index.*" goto :eof copy /y "!QS_LANDING_SRC!" "!SERVED_DIR!\index.html" >nul 2>&1 if errorlevel 1 goto :eof echo. echo + Added a placeholder page at the web root echo The root serves nothing by default, so it would answer 403. echo Delete or replace !PUBLIC_FOLDER_NAME!\index.html when you put echo your own site there - nothing depends on it. goto :eof REM ========================================================== REM Embed policy - the install-wide iframe sandbox rules REM ========================================================== REM One file for the whole installation (embed-policy.json) decides which hosts REM an iframe in ANY project may embed with extra sandbox permissions. It is set REM at deployment and the panel only READS it - no user, owner or admin can REM widen it, which is the whole reason it is here rather than per project. REM REM Created from the shipped .example the first time setup runs, and NEVER REM overwritten afterwards, so a deployer's own edits are safe (same rule as REM auth.php / roles.php). An absent file means the strictest sandbox - every REM embed blocked - so the .example ships the YouTube default (a common host). :maybe_create_embed_policy set "QS_EMBED_DIR=!SECURE_DIR!\management\config" if not exist "!QS_EMBED_DIR!" goto :eof set "QS_EMBED_LIVE=!QS_EMBED_DIR!\embed-policy.json" set "QS_EMBED_EX=!QS_EMBED_DIR!\embed-policy.json.example" if exist "!QS_EMBED_LIVE!" goto :eof if not exist "!QS_EMBED_EX!" goto :eof copy /y "!QS_EMBED_EX!" "!QS_EMBED_LIVE!" >nul 2>&1 if errorlevel 1 goto :eof echo. echo + Created the install-wide embed policy echo Which hosts an iframe may embed lives in echo !SECURE_FOLDER_NAME!\management\config\embed-policy.json - edit that file echo to allow more hosts. The admin panel only shows it, read-only. goto :eof REM Where init.php lives right now (it moves with the URL space). :resolve_init if "!PUBLIC_SPACE!"=="" ( set "INIT_FILE=!PUBLIC_DIR!\init.php" set "SERVED_DIR=!PUBLIC_DIR!" ) else ( set "INIT_FILE=!PUBLIC_DIR!\!PUBLIC_SPACE!\init.php" set "SERVED_DIR=!PUBLIC_DIR!\!PUBLIC_SPACE!" ) goto :eof REM ========================================================== REM Item 1 - public folder name REM ========================================================== :item_public echo. echo Public folder name echo. echo Your vhost DocumentRoot must point at this folder. If the vhost echo expects a specific name (e.g. "www", "www.example.com", echo "public_html"), rename it here. echo. echo Current name: !PUBLIC_FOLDER_NAME! echo. set "NEW_PUBLIC_NAME=!ARG_PUBLIC!" if "!NEW_PUBLIC_NAME!"=="" set /p "NEW_PUBLIC_NAME= New name (Enter to keep '!PUBLIC_FOLDER_NAME!'): " if "!NEW_PUBLIC_NAME!"=="" ( echo OK Keeping "!PUBLIC_FOLDER_NAME!" goto :eof ) if "!NEW_PUBLIC_NAME!"=="!PUBLIC_FOLDER_NAME!" ( echo OK Keeping "!PUBLIC_FOLDER_NAME!" goto :eof ) echo "!NEW_PUBLIC_NAME!" | findstr /r "[/\\]" >nul 2>&1 if !errorlevel! equ 0 ( echo X Error: folder name cannot contain slashes goto :eof ) if "!NEW_PUBLIC_NAME!"=="!SECURE_FOLDER_NAME!" ( echo X Error: cannot use the same name as the secure folder goto :eof ) if not exist "!PUBLIC_DIR!" ( echo X Error: public folder "!PUBLIC_FOLDER_NAME!" not found goto :eof ) if exist "!SCRIPT_DIR!!NEW_PUBLIC_NAME!" ( echo X Error: "!NEW_PUBLIC_NAME!" already exists goto :eof ) rename "!PUBLIC_DIR!" "!NEW_PUBLIC_NAME!" if errorlevel 1 ( echo X Failed to rename. Check permissions or close open files. goto :eof ) set "PUBLIC_DIR=!SCRIPT_DIR!!NEW_PUBLIC_NAME!" set "PUBLIC_FOLDER_NAME=!NEW_PUBLIC_NAME!" call :resolve_init if exist "!INIT_FILE!" call :update_init_constant "!INIT_FILE!" "PUBLIC_FOLDER_NAME" "!NEW_PUBLIC_NAME!" echo + Renamed to !NEW_PUBLIC_NAME! goto :eof REM ========================================================== REM Item 2 - secure folder name REM ========================================================== :item_secure echo. echo Secure folder name echo. echo The secure folder holds the QuickSite engine and every project's echo data. It is never served. Rename it for obscurity, or nest it. echo Examples: "backend", "app", "backends/project1" echo. echo Current name: !SECURE_FOLDER_NAME! echo. set "NEW_SECURE_NAME=" set /p "NEW_SECURE_NAME= New name (Enter to keep '!SECURE_FOLDER_NAME!'): " if "!NEW_SECURE_NAME!"=="" ( echo OK Keeping "!SECURE_FOLDER_NAME!" goto :eof ) if "!NEW_SECURE_NAME!"=="!SECURE_FOLDER_NAME!" ( echo OK Keeping "!SECURE_FOLDER_NAME!" goto :eof ) if "!NEW_SECURE_NAME!"=="!PUBLIC_FOLDER_NAME!" ( echo X Error: cannot use the same name as the public folder goto :eof ) if not exist "!SECURE_DIR!" ( echo X Error: secure folder "!SECURE_FOLDER_NAME!" not found goto :eof ) REM Move via PowerShell (supports nested paths like backends/project1) echo Renaming: !SECURE_FOLDER_NAME! -^> !NEW_SECURE_NAME! set "PS_SEC_TEMP=%TEMP%\qs_setup_secure.ps1" echo $src = '!SECURE_DIR!' -replace '/','\' > "%PS_SEC_TEMP%" echo $name = '!NEW_SECURE_NAME!' -replace '\\','/' >> "%PS_SEC_TEMP%" echo $root = '%SCRIPT_DIR%'.TrimEnd('\') >> "%PS_SEC_TEMP%" echo $dest = Join-Path $root ($name -replace '/','\') >> "%PS_SEC_TEMP%" echo $segments = ($name -split '/').Count >> "%PS_SEC_TEMP%" echo if ($segments -gt 5) { Write-Host ' X Error: path too deep (max 5 levels)'; exit 1 } >> "%PS_SEC_TEMP%" echo if ((Test-Path $dest) -and -not $src.StartsWith($dest + '\')) { >> "%PS_SEC_TEMP%" echo Write-Host ' X Error: destination already exists'; exit 1 >> "%PS_SEC_TEMP%" echo } >> "%PS_SEC_TEMP%" echo $parent = Split-Path $dest >> "%PS_SEC_TEMP%" echo if ($src.StartsWith($dest + '\')) { >> "%PS_SEC_TEMP%" echo # Un-nesting (e.g. secure/test -^> secure): target is ancestor of source >> "%PS_SEC_TEMP%" echo $tmp = Join-Path $root '.secure_move_tmp' >> "%PS_SEC_TEMP%" echo Move-Item $src $tmp >> "%PS_SEC_TEMP%" echo $cleanDir = Split-Path $src >> "%PS_SEC_TEMP%" echo while ($cleanDir -ne $root -and (Test-Path $cleanDir)) { >> "%PS_SEC_TEMP%" echo if ((Get-ChildItem $cleanDir -Force).Count -eq 0) { >> "%PS_SEC_TEMP%" echo Remove-Item $cleanDir >> "%PS_SEC_TEMP%" echo $cleanDir = Split-Path $cleanDir >> "%PS_SEC_TEMP%" echo } else { break } >> "%PS_SEC_TEMP%" echo } >> "%PS_SEC_TEMP%" echo Move-Item $tmp $dest >> "%PS_SEC_TEMP%" echo } elseif ($dest.StartsWith($src + '\')) { >> "%PS_SEC_TEMP%" echo # Self-nesting (e.g. secure -^> secure/test) >> "%PS_SEC_TEMP%" echo $tmp = Join-Path $root '.secure_move_tmp' >> "%PS_SEC_TEMP%" echo Move-Item $src $tmp >> "%PS_SEC_TEMP%" echo if ($parent) { New-Item -ItemType Directory -Path $parent -Force ^| Out-Null } >> "%PS_SEC_TEMP%" echo Move-Item $tmp $dest >> "%PS_SEC_TEMP%" echo } elseif ($parent -and -not (Test-Path $parent)) { >> "%PS_SEC_TEMP%" echo New-Item -ItemType Directory -Path $parent -Force ^| Out-Null >> "%PS_SEC_TEMP%" echo Move-Item $src $dest >> "%PS_SEC_TEMP%" echo } else { >> "%PS_SEC_TEMP%" echo Move-Item $src $dest >> "%PS_SEC_TEMP%" echo } >> "%PS_SEC_TEMP%" echo # Cleanup empty parent directories from the old nested path >> "%PS_SEC_TEMP%" echo $oldParent = Split-Path $src >> "%PS_SEC_TEMP%" echo while ($oldParent -ne $root -and (Test-Path $oldParent)) { >> "%PS_SEC_TEMP%" echo if ((Get-ChildItem $oldParent -Force).Count -eq 0) { >> "%PS_SEC_TEMP%" echo Remove-Item $oldParent >> "%PS_SEC_TEMP%" echo $oldParent = Split-Path $oldParent >> "%PS_SEC_TEMP%" echo } else { break } >> "%PS_SEC_TEMP%" echo } >> "%PS_SEC_TEMP%" echo Write-Host " + Renamed to $name" >> "%PS_SEC_TEMP%" REM The vhost holds an ABSOLUTE include pointing INTO the secure folder, so a REM rename moves that file out from under it and nginx refuses the next reload. REM The failure surfaces later, when nobody connects it back to this menu. echo Write-Host "" >> "%PS_SEC_TEMP%" echo Write-Host " ! nginx: your vhost still points at the OLD folder." >> "%PS_SEC_TEMP%" echo Write-Host " Update the include line in your server block to:" >> "%PS_SEC_TEMP%" echo Write-Host " include $dest\nginx\dynamic_routes.conf;" >> "%PS_SEC_TEMP%" echo Write-Host " then reload nginx. An Apache install needs nothing here." >> "%PS_SEC_TEMP%" echo exit 0 >> "%PS_SEC_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_SEC_TEMP%" nul goto :eof ) del "%PS_SEC_TEMP%" 2>nul set "SECURE_FOLDER_NAME=!NEW_SECURE_NAME!" set "SECURE_DIR=!SCRIPT_DIR!!NEW_SECURE_NAME!" call :resolve_init if exist "!INIT_FILE!" call :update_init_constant "!INIT_FILE!" "SECURE_FOLDER_NAME" "!NEW_SECURE_NAME!" goto :eof REM ========================================================== REM Item 3 - URL space / prefix REM ========================================================== :item_space echo. echo URL space / prefix echo. echo A URL space serves the site from a subdirectory of the domain. echo Example: "web" makes the site http://your-domain/web/ echo. if "!PUBLIC_SPACE!"=="" goto :space_none_set echo Current space: !PUBLIC_SPACE! echo. echo Enter a new space, type "none" to remove it, or press Enter echo to keep the current one. echo. set "SPACE_INPUT=" set /p "SPACE_INPUT= Space: " if "!SPACE_INPUT!"=="" ( echo OK Keeping "!PUBLIC_SPACE!" goto :eof ) if "!SPACE_INPUT!"=="!PUBLIC_SPACE!" ( echo OK Keeping "!PUBLIC_SPACE!" goto :eof ) if /i "!SPACE_INPUT!"=="none" ( set "DESIRED_SPACE=" ) else ( set "DESIRED_SPACE=!SPACE_INPUT!" ) goto :space_apply :space_none_set echo No space currently set - the site serves from the domain root. echo. set "SPACE_INPUT=" set /p "SPACE_INPUT= Space (Enter to keep serving from the root): " if "!SPACE_INPUT!"=="" ( echo OK No space - serving from the root goto :eof ) set "DESIRED_SPACE=!SPACE_INPUT!" :space_apply REM Everything the space change touches, in one PowerShell pass: validation, REM the file moves, the init.php constant, and the .htaccess routing. set "PS_SPACE_TEMP=%TEMP%\qs_setup_space.ps1" echo $publicDir = '!PUBLIC_DIR!' > "%PS_SPACE_TEMP%" echo $oldSpace = '!PUBLIC_SPACE!' >> "%PS_SPACE_TEMP%" echo $newSpace = '!DESIRED_SPACE!' >> "%PS_SPACE_TEMP%" echo $newSpace = $newSpace.Trim('/\') >> "%PS_SPACE_TEMP%" echo if ($newSpace -and $newSpace -notmatch '^[a-zA-Z0-9._/\-]+$') { >> "%PS_SPACE_TEMP%" echo Write-Host ' X Error: invalid characters in space name' >> "%PS_SPACE_TEMP%" echo Write-Host ' Allowed: a-z A-Z 0-9 . - _ /' >> "%PS_SPACE_TEMP%" echo exit 1 >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo if ($newSpace -and ($newSpace -split '/').Count -gt 5) { >> "%PS_SPACE_TEMP%" echo Write-Host ' X Error: path too deep (max 5 levels)'; exit 1 >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo # Every segment is a folder name, and none is p: /p/^/ takes the >> "%PS_SPACE_TEMP%" echo # project from the segment after the FIRST p, so a space holding one binds the wrong one. >> "%PS_SPACE_TEMP%" echo $segs = $newSpace.Split('/') >> "%PS_SPACE_TEMP%" echo if ($newSpace -and ($segs -contains '' -or $segs -contains '.' -or $segs -contains '..')) { >> "%PS_SPACE_TEMP%" echo Write-Host ' X Error: every segment of a space is a folder name (no empty segment, no . or ..)'; exit 1 >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo if ($newSpace -and $segs -ccontains 'p') { >> "%PS_SPACE_TEMP%" echo Write-Host ' X Error: a space cannot hold a segment named p - /p/ is where each project is served'; exit 1 >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo # Remove the old space (move files back to the public root) >> "%PS_SPACE_TEMP%" echo if ($oldSpace) { >> "%PS_SPACE_TEMP%" echo $oldDir = Join-Path $publicDir ($oldSpace -replace '/','\') >> "%PS_SPACE_TEMP%" echo if (Test-Path $oldDir) { >> "%PS_SPACE_TEMP%" echo Get-ChildItem $oldDir -Force ^| ForEach-Object { Move-Item $_.FullName $publicDir -Force } >> "%PS_SPACE_TEMP%" echo $top = ($oldSpace -split '/')[0] >> "%PS_SPACE_TEMP%" echo Remove-Item (Join-Path $publicDir $top) -Recurse -Force -ErrorAction SilentlyContinue >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo # Set the new space (move files into the space dir) >> "%PS_SPACE_TEMP%" echo if ($newSpace) { >> "%PS_SPACE_TEMP%" echo $newDir = Join-Path $publicDir ($newSpace -replace '/','\') >> "%PS_SPACE_TEMP%" echo if (Test-Path $newDir) { Write-Host " X Error: '$newSpace' already exists"; exit 1 } >> "%PS_SPACE_TEMP%" echo New-Item -ItemType Directory -Path $newDir -Force ^| Out-Null >> "%PS_SPACE_TEMP%" echo $top = ($newSpace -split '/')[0] >> "%PS_SPACE_TEMP%" echo Get-ChildItem $publicDir -Force ^| Where-Object { $_.Name -ne $top } ^| ForEach-Object { >> "%PS_SPACE_TEMP%" echo Move-Item $_.FullName $newDir -Force >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo $initDir = if ($newSpace) { Join-Path $publicDir ($newSpace -replace '/','\') } else { $publicDir } >> "%PS_SPACE_TEMP%" echo $initFile = Join-Path $initDir 'init.php' >> "%PS_SPACE_TEMP%" echo if (Test-Path $initFile) { >> "%PS_SPACE_TEMP%" echo $c = Get-Content $initFile -Raw -Encoding UTF8 >> "%PS_SPACE_TEMP%" echo $c = $c -replace "define\('PUBLIC_FOLDER_SPACE',\s*'.*?'\)", "define('PUBLIC_FOLDER_SPACE', '$newSpace')" >> "%PS_SPACE_TEMP%" echo [IO.File]::WriteAllText($initFile, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo # Apache: retarget every FallbackResource to the new prefix. >> "%PS_SPACE_TEMP%" echo # FallbackResource takes a URL path relative to the DocumentRoot, so each >> "%PS_SPACE_TEMP%" echo # has to carry the space or Apache looks for the entry point where it no >> "%PS_SPACE_TEMP%" echo # longer is. /p/ is in this list because /p/^/ is how EVERY >> "%PS_SPACE_TEMP%" echo # project is served - miss it and the whole public surface 404s on a space >> "%PS_SPACE_TEMP%" echo # install while /admin/ still works, which reads like a routing bug. >> "%PS_SPACE_TEMP%" echo $prefix = if ($newSpace) { "/$newSpace" } else { '' } >> "%PS_SPACE_TEMP%" echo foreach ($pair in @( >> "%PS_SPACE_TEMP%" echo @{ File=(Join-Path $initDir '.htaccess'); Fallback="$prefix/index.php" }, >> "%PS_SPACE_TEMP%" echo @{ File=(Join-Path $initDir 'p\.htaccess'); Fallback="$prefix/p/index.php" }, >> "%PS_SPACE_TEMP%" echo @{ File=(Join-Path $initDir 'management\.htaccess'); Fallback="$prefix/management/index.php" }, >> "%PS_SPACE_TEMP%" echo @{ File=(Join-Path $initDir 'admin\.htaccess'); Fallback="$prefix/admin/index.php" } >> "%PS_SPACE_TEMP%" echo )) { >> "%PS_SPACE_TEMP%" echo if (Test-Path $pair.File) { >> "%PS_SPACE_TEMP%" echo $c = Get-Content $pair.File -Raw -Encoding UTF8 >> "%PS_SPACE_TEMP%" echo $c = $c -replace 'FallbackResource .*', ('FallbackResource ' + $pair.Fallback) >> "%PS_SPACE_TEMP%" echo [IO.File]::WriteAllText($pair.File, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo # nginx: drop the stale routing config, do NOT rewrite it here. The >> "%PS_SPACE_TEMP%" echo # generator lives in ONE place - secure/src/functions/NginxConfig.php - >> "%PS_SPACE_TEMP%" echo # and init.php recreates the file on the next page load. This script used >> "%PS_SPACE_TEMP%" echo # to keep its own copy of that generator, which silently went stale. >> "%PS_SPACE_TEMP%" echo $nginxConf = Join-Path ('!SECURE_DIR!' -replace '/','\') 'nginx\dynamic_routes.conf' >> "%PS_SPACE_TEMP%" echo if (Test-Path $nginxConf) { >> "%PS_SPACE_TEMP%" echo Remove-Item $nginxConf -Force >> "%PS_SPACE_TEMP%" echo Write-Host ' + Removed the old nginx routing config' >> "%PS_SPACE_TEMP%" echo Write-Host ' ! nginx: load any page once - that regenerates it for the new' >> "%PS_SPACE_TEMP%" echo Write-Host ' prefix. Reload nginx after that, not before.' >> "%PS_SPACE_TEMP%" REM The regenerated include cannot reach the ONE block that lives in the vhost: REM location @quicksite_project carries the URL prefix inside SCRIPT_FILENAME and REM is pasted by hand, because it needs a fastcgi_pass only the operator knows. REM Left stale, every /p/ request posts at an entry point that is not there. echo Write-Host '' >> "%PS_SPACE_TEMP%" echo Write-Host ' ! nginx: also update the block you pasted by hand.' >> "%PS_SPACE_TEMP%" echo Write-Host ' location @quicksite_project must read:' >> "%PS_SPACE_TEMP%" echo if ($newSpace) { Write-Host " fastcgi_param SCRIPT_FILENAME `$document_root/$newSpace/p/index.php;" } else { Write-Host " fastcgi_param SCRIPT_FILENAME `$document_root/p/index.php;" } >> "%PS_SPACE_TEMP%" echo Write-Host ' Miss it and /p/ answers a bare File-not-found while /admin/ works.' >> "%PS_SPACE_TEMP%" echo } >> "%PS_SPACE_TEMP%" echo if ($newSpace) { Write-Host " + Space set: $newSpace" } else { Write-Host ' + Space removed - serving from the root' } >> "%PS_SPACE_TEMP%" echo exit 0 >> "%PS_SPACE_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_SPACE_TEMP%" nul goto :eof ) del "%PS_SPACE_TEMP%" 2>nul set "PUBLIC_SPACE=!DESIRED_SPACE!" goto :eof REM ========================================================== REM Item 4 - environment (production / development) REM ========================================================== :item_environment call :read_state echo. echo Environment echo. echo QuickSite fetches URLs from the server for you - data resolvers, echo API endpoint tests, OAuth back-channels, importing an asset by URL. echo. echo production those fetches may NOT reach loopback, private or echo cloud-metadata addresses. This is what a public or echo shared deployment must run as. echo development lifts the internal-address block, so a local author echo can call http://localhost:3000 or a LAN API. The echo http/https scheme rule and DNS pinning stay on. echo. echo Currently: !ENV_LABEL! echo. echo Only ever choose development on a machine you fully trust and echo that untrusted callers cannot reach. echo. REM Both answers are SPELLED OUT. A bare [y/N] makes the reader infer what N REM means, and here it is not "no" - it is the other named mode. Getting that REM backwards on a public server is the one mistake this item can cause. set "ANSWER=" set /p "ANSWER= Is this a local/dev install? y = development, Enter = production: " set "ENV_VALUE=production" if /i "!ANSWER!"=="y" set "ENV_VALUE=development" if /i "!ANSWER!"=="yes" set "ENV_VALUE=development" if not exist "!CONFIG_DIR!" ( echo X Error: config folder not found: !CONFIG_DIR! goto :eof ) REM Start from the .example so the live file keeps the full explanation of REM what the setting does - the operator edits this file by hand later, and a REM bare two-line array tells them nothing. if not exist "!ENV_FILE!" ( if exist "!CONFIG_DIR!\environment.php.example" ( copy /y "!CONFIG_DIR!\environment.php.example" "!ENV_FILE!" >nul ) else ( ( echo ^ 'production', echo ]; ) > "!ENV_FILE!" ) ) set "PS_CFG_TEMP=%TEMP%\qs_setup_env.ps1" echo $f = '!ENV_FILE!' > "%PS_CFG_TEMP%" echo $v = '!ENV_VALUE!' >> "%PS_CFG_TEMP%" echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_CFG_TEMP%" REM The '>' inside the double-quoted regex needs NO caret — the quotes stop cmd REM reading it as a redirect, and a caret it does not need lands in the .ps1 as REM a literal '^', which turns the pattern into one that matches nothing. Only REM the trailing '>>' here is a real redirect. echo $c = $c -replace "'environment'\s*=>\s*'.*?'", ("'environment' => '" + $v + "'") >> "%PS_CFG_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_CFG_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_CFG_TEMP%" nul 2>&1 del "%PS_CFG_TEMP%" 2>nul REM Verify rather than assume - report honestly if the file has been reshaped. findstr /c:"'environment' => '!ENV_VALUE!'" "!ENV_FILE!" >nul 2>&1 if errorlevel 1 ( echo X Could not set the value automatically. echo Edit this file by hand and set 'environment' =^> '!ENV_VALUE!': echo !ENV_FILE! goto :eof ) echo + Environment: !ENV_VALUE! if /i "!ENV_VALUE!"=="development" echo ^^! Do not run a publicly reachable install this way. echo. echo This is a server-side file, on purpose: there is no API that changes echo it, so a leaked credential cannot switch the install into development echo and re-open outbound access. goto :eof REM ========================================================== REM Item 5 - self-registration REM ========================================================== :item_selfreg call :read_state echo. echo Self-registration echo. echo May visitors create their own accounts at /admin/register? echo. echo off accounts are created by you, from the panel. This is the echo default, and what a private install wants. echo on anyone who can reach /admin/register can make an account, echo subject to the per-IP and per-hour limits in auth.php. echo. echo Currently: !SELFREG_LABEL! echo. echo Either way, the FIRST account is created from the first-run page echo using the setup token - self-registration never bootstraps an echo install. echo. set "ANSWER=" set /p "ANSWER= Allow visitors to create their own accounts? [y/N]: " set "REG_VALUE=false" if /i "!ANSWER!"=="y" set "REG_VALUE=true" if /i "!ANSWER!"=="yes" set "REG_VALUE=true" if not exist "!CONFIG_DIR!" ( echo X Error: config folder not found: !CONFIG_DIR! goto :eof ) REM auth.php does not exist yet on a fresh clone - the engine creates it from REM the .example on the first page load. Creating it here is the same copy, REM done earlier; the engine's copy is create-if-absent and leaves ours alone. if not exist "!AUTH_FILE!" ( if not exist "!CONFIG_DIR!\auth.php.example" ( echo X Error: neither auth.php nor auth.php.example is present echo !CONFIG_DIR! goto :eof ) copy /y "!CONFIG_DIR!\auth.php.example" "!AUTH_FILE!" >nul ) set "PS_CFG_TEMP=%TEMP%\qs_setup_reg.ps1" echo $f = '!AUTH_FILE!' > "%PS_CFG_TEMP%" echo $v = '!REG_VALUE!' >> "%PS_CFG_TEMP%" echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_CFG_TEMP%" echo $c = $c -replace "'allow_self_registration'\s*=>\s*\w+", ("'allow_self_registration' => " + $v) >> "%PS_CFG_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_CFG_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_CFG_TEMP%" nul 2>&1 del "%PS_CFG_TEMP%" 2>nul findstr /c:"'allow_self_registration' => !REG_VALUE!" "!AUTH_FILE!" >nul 2>&1 if errorlevel 1 ( echo X Could not set the value automatically. echo Edit this file by hand and set 'allow_self_registration' =^> !REG_VALUE!: echo !AUTH_FILE! goto :eof ) if /i "!REG_VALUE!"=="true" ( echo + Self-registration: on echo ^^! /admin/register is now open to anyone who can reach it. ) else ( echo + Self-registration: off ) goto :eof REM ========================================================== REM Item 8 - the command console REM ========================================================== REM ABSENT MEANS ALLOWED, so this item declines to write a file when the answer REM is ON: the file exists only to say no. REM REM AND IT IS NOT A SECURITY CONTROL. Every command the console lists is REM already reachable at /management/ by any caller whose role allows it - the REM console runs them through the same permission check. Turning it off removes REM reach and discoverability, not access. The prompt says so, because an REM operator who reads this as "my users can no longer run commands" has been REM misled. :item_console call :read_state echo. echo Command console echo. echo Should the admin panel offer the raw command runner at echo /admin/command - a form for every command, and a page listing them? echo. echo on the default. Anyone signed in sees the Commands entry in the echo panel and can run any command their role allows. echo off the page and its nav entry are gone. This is what a shared or echo hosted install wants: your users author sites through the echo visual editor, sitemap and media pages, and have no business echo holding a generic runner for the whole command surface. echo. echo Currently: !CONSOLE_LABEL! echo. echo ^^! This is NOT a security control. Every command stays reachable echo at /management/ to any caller whose role allows it, and the echo console checks the same permissions. Turning it off removes echo reach and discoverability, not access - do not skip a role echo review because of it. The command history tab stays either way. echo. set "ANSWER=" set /p "ANSWER= Offer the command console on this installation? [Y/n]: " REM Enter accepts the SHIPPED DEFAULT, which here is on. set "CON_VALUE=true" if /i "!ANSWER!"=="n" set "CON_VALUE=false" if /i "!ANSWER!"=="no" set "CON_VALUE=false" if not exist "!CONFIG_DIR!" ( echo X Error: config folder not found: !CONFIG_DIR! goto :eof ) REM Accepting when no file exists LEAVES IT ABSENT - absent already means the REM console is offered, and writing a file to say so would turn an install that REM never opted out into one that opted in, which reads as a decision somebody REM made. if /i "!CON_VALUE!"=="true" ( if not exist "!CONSOLE_FILE!" ( echo. echo + Console stays on - console.php left absent, which is what a echo fresh install has. goto :eof ) ) REM console.php.example is the shipped documentation and is never edited: the REM live file is a COPY with the value patched, so every explanatory comment REM survives into the file the deployer will actually read later. if not exist "!CONSOLE_FILE!" ( if not exist "!CONFIG_DIR!\console.php.example" ( echo X Error: neither console.php nor console.php.example is present echo !CONFIG_DIR! goto :eof ) copy /y "!CONFIG_DIR!\console.php.example" "!CONSOLE_FILE!" >nul ) set "PS_CON_TEMP=%TEMP%\qs_setup_console.ps1" echo $f = '!CONSOLE_FILE!' > "%PS_CON_TEMP%" echo $v = '!CON_VALUE!' >> "%PS_CON_TEMP%" REM -Encoding UTF8 is load-bearing: without it Get-Content reads a UTF-8 file REM through the system ANSI codepage and WriteAllText re-encodes the result, REM which mangles every non-ASCII character in the file's own comments. echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_CON_TEMP%" echo $c = $c -replace "'allow_console'\s*=>\s*\w+", ("'allow_console' => " + $v) >> "%PS_CON_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_CON_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_CON_TEMP%" nul 2>&1 del "%PS_CON_TEMP%" 2>nul findstr /c:" 'allow_console' => !CON_VALUE!," "!CONSOLE_FILE!" >nul 2>&1 if errorlevel 1 ( echo X Could not set the value automatically. echo Edit this file by hand and set 'allow_console' =^> !CON_VALUE!: echo !CONSOLE_FILE! goto :eof ) if /i "!CON_VALUE!"=="false" ( echo + Command console: off echo /admin/command no longer renders the runner and the nav entry echo is gone. The commands themselves are unchanged. ) else ( echo + Command console: on ) goto :eof REM ========================================================== REM Item 9 - the default language for new projects REM ========================================================== REM The language a NEW project starts in when nobody chooses one: createProject's REM default, what the panel's New Project dialog preselects, and what an REM imported archive that lists no language gets. A project that exists never REM reads it. REM REM A free-text value, so Enter KEEPS what is there (items 1-3's rule), rather REM than resetting to the shipped default the way the yes/no items do. REM REM ABSENT MEANS 'en', and choosing 'en' when no file exists LEAVES IT ABSENT - REM item 8's rule: a file that says what the default already is would read as REM a decision somebody made. A code that is not in the language list is REM refused here and nothing is written: the engine would ignore it (and log REM why) anyway. :item_deflang call :read_state echo. echo Default language for new projects echo. echo The language a new project starts in when nobody chooses one. The echo admin panel's New Project dialog preselects it, and an imported echo project whose archive lists no language gets it. Projects that echo already exist keep their languages. echo. echo Currently: !DEFLANG_LABEL! echo. echo Type a code from this installation's language list, lowercase as the echo list writes it: en, fr, es, de, pt, zh, ja, fil... The list is echo !LANGLIST_FILE! echo. set "DL_ANSWER=" set /p "DL_ANSWER= Language code (Enter keeps !DEFLANG_EFFECTIVE!): " if defined DL_ANSWER set "DL_ANSWER=!DL_ANSWER: =!" if not defined DL_ANSWER ( echo Unchanged. goto :eof ) if not exist "!CONFIG_DIR!" ( echo X Error: config folder not found: !CONFIG_DIR! goto :eof ) set "DL_CODE=!DL_ANSWER!" call :deflang_listed if errorlevel 1 ( echo X '!DL_ANSWER!' is not a code in this installation's language list. echo Nothing was written. goto :eof ) if "!DL_ANSWER!"=="en" if not exist "!DEFLANG_FILE!" ( echo. echo + Default language stays en - default-language.php left absent, echo which is what a fresh install has. goto :eof ) REM default-language.php.example is the shipped documentation and is never REM edited: the live file is a COPY with the value patched, so every REM explanatory comment survives into the file the operator will read later. if not exist "!DEFLANG_FILE!" ( if not exist "!CONFIG_DIR!\default-language.php.example" ( echo X Error: neither default-language.php nor default-language.php.example is present echo !CONFIG_DIR! goto :eof ) copy /y "!CONFIG_DIR!\default-language.php.example" "!DEFLANG_FILE!" >nul ) REM Anchored to the start of a line ((?m)^), so a commented-out line in the REM operator's own file is left alone. -Encoding UTF8 is load-bearing - see REM item 8. set "PS_DL_TEMP=%TEMP%\qs_setup_deflang.ps1" echo $f = '!DEFLANG_FILE!' > "%PS_DL_TEMP%" echo $v = '!DL_ANSWER!' >> "%PS_DL_TEMP%" echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_DL_TEMP%" echo $c = $c -replace "(?m)^( *)'default_language'\s*=>\s*'[^']*',", ('$1' + "'default_language' => '" + $v + "',") >> "%PS_DL_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_DL_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_DL_TEMP%" nul 2>&1 del "%PS_DL_TEMP%" 2>nul call :read_state if not "!DEFLANG_EFFECTIVE!"=="!DL_ANSWER!" ( echo X Could not set the value automatically. echo Edit this file by hand and set 'default_language' =^> '!DL_ANSWER!': echo !DEFLANG_FILE! goto :eof ) echo + Default language for new projects: !DL_ANSWER! echo Only projects created from now on start in it. goto :eof REM ========================================================== REM Item 10 - idle sign-out REM ========================================================== REM How long a session may go unused before the panel signs it out: auth.php's REM session.idle_ttl, in seconds. The login page states it under "Keep me signed REM in". A free-text value, so Enter KEEPS what is there (item 9's rule). The REM answer is a number and m, h or d, at least 5 minutes - the engine's own REM minimum - and written as seconds. REM REM ABSENT MEANS 1 DAY, and choosing 1 day when auth.php does not exist yet REM LEAVES IT ABSENT (item 9's rule). Otherwise auth.php is created from its REM .example as item 5 creates it, and only the number on the idle_ttl line REM changes. :item_idle call :read_state echo. echo Idle sign-out echo. echo How long the admin panel may go unused before it signs you out. Ticking echo "Keep me signed in" keeps a session across browser restarts, never past echo this limit; the login page states it under that box. echo. echo Currently: !IDLE_LABEL! echo. set "IW_SECS=!IDLE_EFFECTIVE!" call :idle_words set "ID_ANSWER=" set /p "ID_ANSWER= Sign out after how long without using the panel? A number and m, h or d (Enter keeps !IDLE_WORDS!): " if defined ID_ANSWER set "ID_ANSWER=!ID_ANSWER: =!" if not defined ID_ANSWER ( echo Unchanged. goto :eof ) REM One to four digits, then m, h or d, in either case. The answer is written to REM a file and matched there, never passed on a command line: an & typed at the REM prompt would run (item 9's reader says the same). set "ID_TMP=%TEMP%\qs_setup_idle.txt" > "!ID_TMP!" echo(!ID_ANSWER! findstr /r /x /i /c:"[0123456789][0123456789]*[mhd]" "!ID_TMP!" >nul 2>&1 set "ID_SHAPE=!errorlevel!" del "!ID_TMP!" 2>nul set "ID_NUM=!ID_ANSWER:~0,-1!" if "!ID_SHAPE!"=="0" if not "!ID_NUM:~4!"=="" set "ID_SHAPE=1" if not "!ID_SHAPE!"=="0" ( echo X '!ID_ANSWER!' is not a number followed by m, h or d. echo Nothing was written. goto :eof ) REM A leading zero would make set /a read the number as octal. :idle_strip if "!ID_NUM:~0,1!"=="0" if not "!ID_NUM!"=="0" ( set "ID_NUM=!ID_NUM:~1!" goto :idle_strip ) set "ID_UNIT=!ID_ANSWER:~-1!" set "ID_MULT=60" if /i "!ID_UNIT!"=="h" set "ID_MULT=3600" if /i "!ID_UNIT!"=="d" set "ID_MULT=86400" set /a ID_SECS=ID_NUM * ID_MULT if !ID_SECS! LSS 300 ( echo X The shortest idle sign-out is 5 minutes. echo Nothing was written. goto :eof ) if not exist "!CONFIG_DIR!" ( echo X Error: config folder not found: !CONFIG_DIR! goto :eof ) if "!ID_SECS!"=="86400" if not exist "!AUTH_FILE!" ( echo. echo + Idle sign-out stays 1 day - auth.php left absent, which is what a echo fresh install has. goto :eof ) REM auth.php does not exist yet on a fresh clone - item 5 says why creating it REM here is safe. if not exist "!AUTH_FILE!" ( if not exist "!CONFIG_DIR!\auth.php.example" ( echo X Error: neither auth.php nor auth.php.example is present echo !CONFIG_DIR! goto :eof ) copy /y "!CONFIG_DIR!\auth.php.example" "!AUTH_FILE!" >nul ) REM Anchored to the start of a line ((?m)^), and only the number changes: the REM key's spacing and the comment after it stay. -Encoding UTF8 is load-bearing REM - see item 8. set "PS_ID_TEMP=%TEMP%\qs_setup_idle.ps1" echo $f = '!AUTH_FILE!' > "%PS_ID_TEMP%" echo $v = '!ID_SECS!' >> "%PS_ID_TEMP%" echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_ID_TEMP%" echo $c = $c -replace "(?m)^(\s*'idle_ttl'\s*=>\s*)\d+", ('${1}' + $v) >> "%PS_ID_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_ID_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_ID_TEMP%" nul 2>&1 del "%PS_ID_TEMP%" 2>nul call :read_state if not "!IDLE_SECS!"=="!ID_SECS!" ( echo X Could not set the value automatically. echo Edit this file by hand and set 'idle_ttl' =^> !ID_SECS!: echo !AUTH_FILE! goto :eof ) set "IW_SECS=!ID_SECS!" call :idle_words echo + Idle sign-out: !IDLE_WORDS! echo Every session follows it from its next request. goto :eof REM ========================================================== REM Item 11 - the Embed Security page REM ========================================================== REM The page shows the installation's embed policy (embed-policy.json), REM read-only: no author can change it from the panel, so a deployer may decide REM nobody needs to see it. ABSENT MEANS SHOWN, so this item declines to write a REM file when the answer is "show": the file exists only to say no. REM REM AND IT IS NOT A SECURITY CONTROL. Hiding the page removes a page, not a REM rule: the policy stays in force, and every command stays reachable at REM /management/ to any caller whose role allows it. :item_embed call :read_state echo. echo Embed Security page echo. echo Should the admin panel show the Embed Security page at echo /admin/embed-security - a read-only view of the embed policy, the echo sandbox the installation puts around iframes embedded in a page? echo. echo show the default. The page is in the panel's Settings menu, for echo the advanced mode. echo hide the page leaves the menu and says it was turned off. Nobody echo can change the policy from the panel anyway: you set it in echo embed-policy.json. echo. echo Currently: !EMBED_LABEL! echo. echo ^^! This is NOT a security control. The embed policy stays in echo force either way, and every command stays reachable at echo /management/ to any caller whose role allows it. echo. set "ANSWER=" set /p "ANSWER= Show the Embed Security page on this installation? [Y/n]: " REM Enter accepts the SHIPPED DEFAULT, which here is show. set "EMB_VALUE=true" if /i "!ANSWER!"=="n" set "EMB_VALUE=false" if /i "!ANSWER!"=="no" set "EMB_VALUE=false" if not exist "!CONFIG_DIR!" ( echo X Error: config folder not found: !CONFIG_DIR! goto :eof ) REM Accepting when no file exists LEAVES IT ABSENT, as item 8 does: absent REM already means shown. if /i "!EMB_VALUE!"=="true" ( if not exist "!EMBED_FILE!" ( echo. echo + Embed Security stays shown - embed-security.php left absent, echo which is what a fresh install has. goto :eof ) ) REM The .example is the shipped documentation and is never edited: the live REM file is a COPY with the value patched. if not exist "!EMBED_FILE!" ( if not exist "!CONFIG_DIR!\embed-security.php.example" ( echo X Error: neither embed-security.php nor embed-security.php.example is present echo !CONFIG_DIR! goto :eof ) copy /y "!CONFIG_DIR!\embed-security.php.example" "!EMBED_FILE!" >nul ) set "PS_EMB_TEMP=%TEMP%\qs_setup_embed.ps1" echo $f = '!EMBED_FILE!' > "%PS_EMB_TEMP%" echo $v = '!EMB_VALUE!' >> "%PS_EMB_TEMP%" REM -Encoding UTF8 is load-bearing, as in item 8. echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_EMB_TEMP%" echo $c = $c -replace "'show_embed_security'\s*=>\s*\w+", ("'show_embed_security' => " + $v) >> "%PS_EMB_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_EMB_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_EMB_TEMP%" nul 2>&1 del "%PS_EMB_TEMP%" 2>nul findstr /c:" 'show_embed_security' => !EMB_VALUE!," "!EMBED_FILE!" >nul 2>&1 if errorlevel 1 ( echo X Could not set the value automatically. echo Edit this file by hand and set 'show_embed_security' =^> !EMB_VALUE!: echo !EMBED_FILE! goto :eof ) if /i "!EMB_VALUE!"=="false" ( echo + Embed Security page: hidden echo It is no longer in the menu, and says it was turned off. The echo embed policy is unchanged. ) else ( echo + Embed Security page: shown ) goto :eof REM ========================================================== REM Item 6 - show my setup token REM ========================================================== :item_token call :read_state echo. echo Setup token echo. echo QuickSite ships no default password. The first account is created echo in the browser, and what proves you are the person who installed echo this - rather than a passer-by who found the address - is being echo able to read a file on the server. That file is the token. echo. if exist "!TOKEN_FILE!" goto :token_read REM THE ORDERING THAT SURPRISES PEOPLE. The token is minted by the engine when REM the first-run page renders, not by this script. On a fresh clone it REM genuinely does not exist yet, and printing an empty value here would look REM like a broken install. echo The token has not been created yet. echo. echo It is written the first time the admin panel is loaded. Do this, echo in this order: echo. echo 1. Point your vhost DocumentRoot at !PUBLIC_FOLDER_NAME!\ and echo restart your web server. if "!PUBLIC_SPACE!"=="" ( echo 2. Open http://your-domain/admin/ in a browser. ) else ( echo 2. Open http://your-domain/!PUBLIC_SPACE!/admin/ in a browser. ) echo Leave the page open - it is the form you will fill in. echo 3. Come back here and choose this item again. echo. echo Expected location: echo !TOKEN_FILE! goto :eof :token_read set "TOKEN=" for /f "usebackq delims=" %%a in ("!TOKEN_FILE!") do if not defined TOKEN set "TOKEN=%%a" echo !TOKEN!| findstr /r /c:"^[0-9a-f][0-9a-f]*$" >nul 2>&1 if errorlevel 1 goto :token_consumed echo Your setup token: echo. echo !TOKEN! echo. echo Paste it into the first-run form at /admin/. It is used once and echo destroyed the moment your account is created. goto :eof :token_consumed REM qs_setup_token_consume() overwrites the value with a marker before REM unlinking, so anything here that is not token-shaped means the bootstrap REM is over. echo This token has already been used. echo. echo An account exists on this installation, so the first-run page is echo gone for good and the token grants nothing. Sign in at /admin/ echo instead. echo. echo Lost the password? There is no reset from here. Delete echo !CONFIG_DIR!\users.php echo to start the bootstrap over - every account goes with it. goto :eof REM ========================================================== REM Helper: update a define() constant in init.php REM Usage: call :update_init_constant "file.php" "CONSTANT_NAME" "new_value" REM REM Every powershell call in this file runs with "%PS_TEMP%" echo $n = '%UIC_NAME%' >> "%PS_TEMP%" echo $v = '%UIC_VALUE%' >> "%PS_TEMP%" echo $c = Get-Content $f -Raw -Encoding UTF8 >> "%PS_TEMP%" echo $c = $c -replace "define\('$n',\s*'.*?'\)", "define('$n', '$v')" >> "%PS_TEMP%" echo [IO.File]::WriteAllText($f, $c, (New-Object System.Text.UTF8Encoding $false)) >> "%PS_TEMP%" powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_TEMP%" nul if errorlevel 1 ( echo ? Could not update %UIC_NAME% in init.php - edit manually ) else ( echo + Updated %UIC_NAME% in init.php ) del "%PS_TEMP%" 2>nul goto :eof