setup.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
# ==========================================================
# QuickSite — Post-Clone Setup
# ==========================================================
#
# Run this after cloning, before your first page load.
#
# Usage:
# chmod +x setup.sh
# ./setup.sh # menu
# ./setup.sh <public_name> # pre-answer the public-folder rename,
# # then show the menu
# PHP=/usr/bin/php8.3 ./setup.sh # check that PHP's version rather than
# # the php on the PATH
#
# A MENU, NOT A WALK. Every item is independent and re-runnable: come back
# later to change one thing without answering the other five again. The
# settings you choose are remembered in .quicksite.conf and read back the next
# time you run this.
#
# 1. Public folder name — match your vhost DocumentRoot (www, public_html…)
# 2. Secure folder name — rename/nest the engine folder for obscurity
# 3. URL space — serve from a subdirectory (http://domain/web/)
# 4. Environment — production (default) or development
# 5. Self-registration — may visitors create their own accounts?
# 6. Setup token — read the first-run credential off disk
# 7. Storage quotas — per-account size and upload-rate ceilings
# 8. Command console — offer the raw command runner at /admin/command?
# 9. Default language — the language a new project starts in
# 10. Idle sign-out — how long the panel may go unused before it signs
# you out
# 11. Embed security page — show the embed policy's page in the panel?
#
# Items 1-3 rewrite init.php constants and the .htaccess routing. Items 4-5 and
# 7-11 write config files under the secure folder. Item 6 only reads.
#
# Before anything else, it checks the PHP version: older than 8.3, it stops.
#
# WHAT THIS SCRIPT CANNOT DO: create your account. No account exists yet, so
# there is nobody to be one. That happens in the browser at /admin/, and the
# setup token (item 6) is what authorises it — see the notes there.
# ==========================================================
set -euo pipefail
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'
# ==========================================================
# The PHP floor — checked before anything is written
# ==========================================================
# QuickSite needs PHP 8.3 or later. This script runs no PHP of its own, so it asks
# the php on the PATH — or the one the PHP variable names — for its version, and
# stops at once when it is older, before the ownership fix or any file below.
# That is the command-line PHP: on some servers the web server's own (mod_php,
# php-fpm) is a separate install, which is why the README asks for a patched
# release of the branch either way. Only the floor is checked here: patch numbers
# change with every advisory, so they live in the README.
check_php_floor() {
local php="${PHP:-}" out=""
[ -n "$php" ] || php="$(command -v php 2>/dev/null || true)"
if [ -n "$php" ]; then
# -n: no php.ini, so an extension that fails to load prints nothing here
out="$("$php" -n -r 'echo PHP_VERSION_ID, " ", PHP_VERSION;' 2>/dev/null || true)"
fi
if [[ ! "$out" =~ ^([0-9]+)\ ([0-9][^[:space:]]*)$ ]]; then
echo ""
if [ -z "$php" ]; then
echo -e " ${YELLOW}⚠ The PHP version could not be checked: no php command was found${NC}"
echo -e " ${YELLOW} (name one with PHP=/path/to/php). QuickSite needs PHP 8.3 or later.${NC}"
else
echo -e " ${YELLOW}⚠ The PHP version could not be checked: $php answered no version.${NC}"
echo -e " ${YELLOW} QuickSite needs PHP 8.3 or later.${NC}"
fi
return 0
fi
if [ "${BASH_REMATCH[1]}" -lt 80300 ]; then
echo ""
echo -e " ${RED}✗ QuickSite needs PHP 8.3 or later; the PHP found here ($php, ${BASH_REMATCH[2]}) is older.${NC}"
echo " Install a supported PHP, a patched release of its branch (see the README), and run setup again."
exit 1
fi
}
check_php_floor
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PUBLIC_DIR="$SCRIPT_DIR/public"
SECURE_DIR="$SCRIPT_DIR/secure"
PUBLIC_FOLDER_NAME="public"
SECURE_FOLDER_NAME="secure"
PUBLIC_SPACE=""
# Read existing config if available (re-run detection)
CONF_FILE="$SCRIPT_DIR/.quicksite.conf"
# Has setup ever run here? Captured BEFORE save_conf writes the file, because
# the landing-page drop below is a first-launch-only action.
FIRST_LAUNCH=yes
[ -f "$CONF_FILE" ] && FIRST_LAUNCH=no
if [ -f "$CONF_FILE" ]; then
source "$CONF_FILE"
# Backward compat: old conf format used PUBLIC_FOLDER_SPACE
PUBLIC_SPACE="${PUBLIC_SPACE:-${PUBLIC_FOLDER_SPACE:-}}"
PUBLIC_DIR="$SCRIPT_DIR/$PUBLIC_FOLDER_NAME"
SECURE_DIR="$SCRIPT_DIR/$SECURE_FOLDER_NAME"
fi
# Helper: save current state to conf (crash recovery + re-run detection)
save_conf() {
cat > "$CONF_FILE" << CONFEOF
PUBLIC_FOLDER_NAME=$PUBLIC_FOLDER_NAME
SECURE_FOLDER_NAME=$SECURE_FOLDER_NAME
PUBLIC_SPACE=$PUBLIC_SPACE
CONFEOF
}
# Where init.php lives right now (it moves with the URL space).
init_file() {
if [ -n "$PUBLIC_SPACE" ]; then
echo "$PUBLIC_DIR/$PUBLIC_SPACE/init.php"
else
echo "$PUBLIC_DIR/init.php"
fi
}
# The directory holding the served .htaccess files (also moves with the space).
served_dir() {
if [ -n "$PUBLIC_SPACE" ]; then
echo "$PUBLIC_DIR/$PUBLIC_SPACE"
else
echo "$PUBLIC_DIR"
fi
}
CONFIG_DIR() { echo "$SECURE_DIR/management/config"; }
# First capture group of a BRE, or '' when the file or the pattern is absent.
#
# POSIX sed, deliberately: `grep -P` is a GNU extension that ALSO refuses to run
# under a non-UTF-8 multibyte locale ("-P supports only unibyte and UTF-8
# locales"), which is a property of the server this lands on, not of the
# pattern. A setup script that reads its own config back wrongly on somebody
# else's box is worse than one that never reads it at all — it reports settings
# that are not in effect.
first_capture() {
local file="$1" pattern="$2" out
[ -f "$file" ] || { echo ""; return 0; }
out=$(sed -n "s/.*${pattern}.*/\\1/p" "$file" 2>/dev/null) || out=""
echo "${out%%$'\n'*}"
}
pause_for_menu() {
echo ""
read -r -p " Press Enter to return to the menu… " _ || true
}
# ==========================================================
# Crash recovery — locate folders that were renamed without us
# ==========================================================
if [ ! -d "$PUBLIC_DIR" ]; then
for d in "$SCRIPT_DIR"/*/; do
if [ -f "$d/init.php" ] || ls "$d"/*/init.php >/dev/null 2>&1; then
PUBLIC_FOLDER_NAME="$(basename "$d")"
PUBLIC_DIR="$SCRIPT_DIR/$PUBLIC_FOLDER_NAME"
break
fi
done
fi
if [ ! -d "$SECURE_DIR" ]; then
INIT_PROBE="$(init_file)"
if [ -f "$INIT_PROBE" ]; then
DETECTED=$(first_capture "$INIT_PROBE" "define('SECURE_FOLDER_NAME',[[:space:]]*'\\([^']*\\)')")
if [ -n "$DETECTED" ] && [ -d "$SCRIPT_DIR/$DETECTED" ]; then
SECURE_DIR="$SCRIPT_DIR/$DETECTED"
SECURE_FOLDER_NAME="$DETECTED"
fi
fi
fi
# ==========================================================
# File ownership — PHP needs write access to the secure folder
# ==========================================================
# Runs once, before the menu: a clone made as root leaves every later item
# writing files the web server cannot then read back.
OWNERSHIP_WARNING=""
if [ "$(whoami)" = "root" ]; then
OWNERSHIP_WARNING="yes"
WEB_USER=""
# CloudPanel: the site user matches the htdocs parent folder name
HTDOCS_PARENT="$(basename "$(dirname "$SCRIPT_DIR")" 2>/dev/null || true)"
if id "$HTDOCS_PARENT" &>/dev/null 2>&1; then
WEB_USER="$HTDOCS_PARENT"
elif id "www-data" &>/dev/null 2>&1; then
WEB_USER="www-data"
elif id "nginx" &>/dev/null 2>&1; then
WEB_USER="nginx"
elif id "apache" &>/dev/null 2>&1; then
WEB_USER="apache"
fi
if [ -n "$WEB_USER" ]; then
echo ""
echo -e " ${BOLD}Fixing file ownership…${NC}"
chown -R "$WEB_USER:$WEB_USER" "$SCRIPT_DIR"
echo -e " ${GREEN}✓${NC} Ownership set to ${BOLD}$WEB_USER${NC}"
OWNERSHIP_WARNING=""
fi
fi
# ==========================================================
# Landing page — first launch only, and only into an empty root
# ==========================================================
# QuickSite serves nothing at the web root by design, so a brand-new install has
# nothing there and answers 403. That is correct and it looks broken, which is
# the worst combination — hence a placeholder that says where the panel is.
#
# TWO CONDITIONS, both required. It runs only on the FIRST setup launch, so
# deleting the page is permanent rather than something the next run undoes; and
# only when the root holds no index file of its own, so cloning QuickSite onto a
# server that already has a site never overwrites that site's front page.
maybe_place_landing_page() {
[ "$FIRST_LAUNCH" = yes ] || return 0
local dir; dir="$(served_dir)"
local template="$SECURE_DIR/deploy/index.html.example"
[ -d "$dir" ] || return 0
[ -f "$template" ] || return 0
# Any index.* at all — .php, .html, .htm, anything the server might pick as
# a DirectoryIndex. If something is already there, it is not ours to touch.
if compgen -G "$dir/index.*" > /dev/null 2>&1; then
return 0
fi
cp "$template" "$dir/index.html" || return 0
echo ""
echo -e " ${GREEN}✓${NC} Added a placeholder page at the web root"
echo -e " ${DIM}The root serves nothing by default, so it would answer 403.${NC}"
echo -e " ${DIM}Delete or replace $PUBLIC_FOLDER_NAME/index.html when you put${NC}"
echo -e " ${DIM}your own site there — nothing depends on it.${NC}"
}
# ==========================================================
# Embed policy — the install-wide iframe sandbox rules
# ==========================================================
# One file for the whole installation (embed-policy.json) decides which hosts an
# <iframe> in ANY project may embed with extra sandbox permissions. It is set at
# deployment and the panel only READS it — no user, owner or admin can widen it,
# which is the whole reason it is here rather than per project.
#
# Created from the shipped .example the first time setup runs, and NEVER
# overwritten afterwards, so a deployer's own edits are safe (same rule as
# auth.php / roles.php, which the engine copies from their .example on first
# load). An absent file means the strictest sandbox — every embed blocked — so
# the .example ships the YouTube default (the host projects most commonly embed).
ensure_embed_policy() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/embed-policy.json"
local example="$cfg_dir/embed-policy.json.example"
[ -d "$cfg_dir" ] || return 0
[ -f "$live" ] && return 0 # never touch an existing policy
[ -f "$example" ] || return 0
if cp "$example" "$live"; then
echo ""
echo -e " ${GREEN}✓${NC} Created the install-wide embed policy"
echo -e " ${DIM}Which hosts an <iframe> may embed lives in${NC}"
echo -e " ${DIM}$SECURE_FOLDER_NAME/management/config/embed-policy.json — edit that file to${NC}"
echo -e " ${DIM}allow more hosts. The admin panel only shows it, read-only.${NC}"
fi
}
# ==========================================================
# Current-state readers (shown on the menu, so nothing is guessed)
# ==========================================================
# 'production' | 'development' | '' when the file has not been written.
env_current() {
first_capture "$(CONFIG_DIR)/environment.php" \
"'environment'[[:space:]]*=>[[:space:]]*'\\([^']*\\)'"
}
env_label() {
local v; v="$(env_current)"
case "$v" in
development) echo -e "${YELLOW}development${NC} ${DIM}(may call localhost / LAN addresses)${NC}" ;;
production) echo -e "production" ;;
"") echo -e "production ${DIM}(default — no environment.php yet)${NC}" ;;
*) echo -e "production ${DIM}(unrecognised value '$v' — treated as production)${NC}" ;;
esac
}
# 'true' | 'false' | '' when auth.php has not been created yet.
selfreg_current() {
first_capture "$(CONFIG_DIR)/auth.php" \
"'allow_self_registration'[[:space:]]*=>[[:space:]]*\\([a-zA-Z]*\\)"
}
selfreg_label() {
local v; v="$(selfreg_current)"
case "$v" in
true) echo -e "${YELLOW}on${NC} ${DIM}(anyone may create an account at /admin/register)${NC}" ;;
false) echo -e "off" ;;
*) echo -e "off ${DIM}(default — auth.php not created yet)${NC}" ;;
esac
}
# 'true' | 'false' | '' when console.php has not been created yet.
#
# ⚠ ABSENT MEANS ALLOWED. The console writes nothing and grants nothing: every
# command it lists is already reachable at /management/ by any caller whose role
# allows it, so a fresh install that nobody configured should keep its developer
# tooling. Fail-closed is for a capability that GRANTS something.
#
# The trailing comma in the pattern is load-bearing: first_capture wraps what
# it is given in .*<pattern>.* and returns the FIRST match in the file, so a
# pattern that also fits a sentence in the file's own comments would report
# the documentation instead of the setting. A '^' anchor cannot help — inside
# that wrapper it is a literal caret.
console_current() {
first_capture "$(CONFIG_DIR)/console.php" \
"'allow_console'[[:space:]]*=>[[:space:]]*\([a-zA-Z]*\),"
}
# ⚠ This MUST distinguish "no file" from "file present, value unreadable": they
# are opposite answers. A broken console.php
# leaves the console OFF (the only reason to create the file is to turn it off,
# so a typo must not silently re-open it), and a label that reported the
# fresh-install default for it would contradict the engine.
console_label() {
if [ ! -f "$(CONFIG_DIR)/console.php" ]; then
echo -e "on ${DIM}(default — console.php not created yet)${NC}"
return
fi
local v; v="$(console_current)"
case "$v" in
true) echo -e "on" ;;
false) echo -e "${YELLOW}off${NC} ${DIM}(/admin/command is not offered)${NC}" ;;
*) echo -e "${YELLOW}off${NC} ${DIM}(value unreadable — check console.php)${NC}" ;;
esac
}
# 'true' | 'false' | '' when embed-security.php has not been created yet.
#
# ⚠ ABSENT MEANS SHOWN, as for the console (item 8): the page is a read-only view
# of the embed policy and grants nothing. The trailing comma is load-bearing, for
# the reason console_current gives.
embed_current() {
first_capture "$(CONFIG_DIR)/embed-security.php" \
"'show_embed_security'[[:space:]]*=>[[:space:]]*\([a-zA-Z]*\),"
}
# A broken embed-security.php hides the page (the only reason to create the file
# is to hide it), so "no file" and "value unreadable" are opposite answers here too.
embed_label() {
if [ ! -f "$(CONFIG_DIR)/embed-security.php" ]; then
echo -e "shown ${DIM}(default — embed-security.php not created yet)${NC}"
return
fi
local v; v="$(embed_current)"
case "$v" in
true) echo -e "shown" ;;
false) echo -e "${YELLOW}hidden${NC} ${DIM}(/admin/embed-security is not in the menu)${NC}" ;;
*) echo -e "${YELLOW}hidden${NC} ${DIM}(value unreadable — check embed-security.php)${NC}" ;;
esac
}
# The language list the engine reads: the installation's own languages.json when
# it has made one, the shipped list otherwise. (The engine also falls back to the
# shipped list when the own copy is not JSON; this reader does not parse JSON, so
# it cannot tell — a broken copy is the one case where the two disagree.)
language_list_file() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
if [ -f "$cfg_dir/languages.json" ]; then
echo "$cfg_dir/languages.json"
else
echo "$cfg_dir/languages.json.example"
fi
}
# Is $1 a code in that list? The shape is checked first — two or three lowercase
# letters, the engine's own rule — so the code is safe inside the grep pattern.
# A key is matched as "code": anywhere on a line, which a minified copy also
# satisfies; a quoted word inside the file's comments cannot match, because a
# quote inside a JSON string is written \".
deflang_listed() {
local code="$1" list
case "$code" in
[a-z][a-z]|[a-z][a-z][a-z]) ;;
*) return 1 ;;
esac
list="$(language_list_file)"
[ -f "$list" ] && grep -q "\"$code\"[[:space:]]*:" "$list"
}
# The code default-language.php names, or '' when the file has not been created
# yet. The trailing comma in the pattern is load-bearing for the same reason it
# is in console_current above.
deflang_current() {
first_capture "$(CONFIG_DIR)/default-language.php" \
"'default_language'[[:space:]]*=>[[:space:]]*'\([a-z]*\)',"
}
# The default the engine actually uses: the file's code when it is in the list,
# and 'en' otherwise — absent, broken, or naming a code the list does not hold.
deflang_effective() {
local v; v="$(deflang_current)"
if [ -n "$v" ] && deflang_listed "$v"; then echo "$v"; else echo "en"; fi
}
# ABSENT MEANS 'en', and so does a file the engine cannot use. The label says
# which of the two it is, because a file somebody wrote that the engine ignores
# is worth a look — the engine logs why it ignored it.
deflang_label() {
if [ ! -f "$(CONFIG_DIR)/default-language.php" ]; then
echo -e "en ${DIM}(default — default-language.php not created yet)${NC}"
return
fi
local v; v="$(deflang_current)"
if [ -n "$v" ] && deflang_listed "$v"; then
echo -e "$v"
else
echo -e "en ${YELLOW}(value unusable — check default-language.php)${NC}"
fi
}
# The idle sign-out auth.php sets, in seconds, or '' when auth.php has not been
# created yet or sets none. Read from its own line, which starts with the key, as
# quota_setting reads its settings: a value the file's comments quote is not it.
idle_current() {
local file line
file="$(CONFIG_DIR)/auth.php"
[ -f "$file" ] || { echo ""; return 0; }
line=$(grep -E "^[[:space:]]*'idle_ttl'[[:space:]]*=>" "$file" 2>/dev/null | head -n 1 || true)
if [[ "$line" =~ =\>[[:space:]]*([0-9]+)[[:space:]]*, ]]; then
echo "${BASH_REMATCH[1]}"
else
echo ""
fi
}
# What the engine applies: auth.php's value, 1 day without one, never under its
# 5-minute minimum (qs_session_config()).
idle_effective() {
local v; v="$(idle_current)"
[ -n "$v" ] || v=86400
[ "$v" -ge 300 ] || v=300
echo "$v"
}
# Seconds in the largest unit that holds them whole — 86400 is "1 day", 5400 is
# "90 minutes": the setting as it is, never rounded.
idle_words() {
local s="$1" n unit
if [ $((s % 86400)) -eq 0 ]; then n=$((s / 86400)); unit=day
elif [ $((s % 3600)) -eq 0 ]; then n=$((s / 3600)); unit=hour
elif [ $((s % 60)) -eq 0 ]; then n=$((s / 60)); unit=minute
else n=$s; unit=second
fi
if [ "$n" -eq 1 ]; then echo "1 $unit"; else echo "$n ${unit}s"; fi
}
# ABSENT MEANS 1 DAY. The label tells "no auth.php yet" from "an auth.php that sets
# none", and says when a value is under the minimum the engine raises it to.
idle_label() {
if [ ! -f "$(CONFIG_DIR)/auth.php" ]; then
echo -e "1 day ${DIM}(default — auth.php not created yet)${NC}"
return
fi
local v; v="$(idle_current)"
if [ -z "$v" ]; then
echo -e "1 day ${DIM}(default — auth.php does not set it)${NC}"
elif [ "$v" -lt 300 ]; then
echo -e "5 minutes ${YELLOW}(auth.php says $(idle_words "$v"); the engine never goes below 5 minutes)${NC}"
else
echo -e "$(idle_words "$v")"
fi
}
# Quota is the one config whose ABSENCE is a real setting: no quota.php means the
# defaults — no storage ceiling and no upload rate, while the size caps and the write
# rate apply at the values the engine ships — and a fresh install has none. So the
# label tells "no file" from "a file that sets no ceiling": they behave the same
# today, but the second is a deliberate choice somebody made.
#
# A setting is read from its own line, which starts with the key: the .example
# carries a commented example above each setting (// 'max_uploads' => 60, a
# multiplication for the bytes), and a reader that matched anywhere on a line took
# that example for the setting. A value that is not a plain number is "?".
quota_setting() {
local file line
file="$(CONFIG_DIR)/quota.php"
[ -f "$file" ] || { echo ""; return 0; }
line=$(grep -E "^[[:space:]]*'$1'[[:space:]]*=>" "$file" 2>/dev/null | head -n 1)
[ -n "$line" ] || { echo ""; return 0; }
if [[ "$line" =~ =\>[[:space:]]*([0-9]+)[[:space:]]*,?[[:space:]]*$ ]]; then
echo "${BASH_REMATCH[1]}"
else
echo "?"
fi
}
quota_bytes_current() { quota_setting max_total_bytes; }
quota_uploads_current() { quota_setting max_uploads; }
quota_label() {
if [ ! -f "$(CONFIG_DIR)/quota.php" ]; then
echo -e "size caps + write rate (defaults) ${DIM}(quota.php not created yet; no storage ceiling, no upload rate)${NC}"
return
fi
local b u parts=""
b="$(quota_bytes_current)"; u="$(quota_uploads_current)"
if [ "$b" = "?" ]; then
parts="a storage ceiling"
elif [ -n "$b" ] && [ "$b" != "0" ]; then
parts="$(( b / 1024 / 1024 )) MB per user"
fi
if [ "$u" = "?" ]; then
[ -n "$parts" ] && parts="$parts, "
parts="${parts}an upload rate"
elif [ -n "$u" ] && [ "$u" != "0" ]; then
[ -n "$parts" ] && parts="$parts, "
parts="${parts}${u} uploads per period"
fi
if [ -z "$parts" ]; then
echo -e "no storage ceiling, no upload rate ${DIM}(quota.php present; it also holds the size caps and the write rate)${NC}"
else
echo -e "${YELLOW}${parts}${NC} ${DIM}(quota.php also holds the size caps and the write rate)${NC}"
fi
}
space_label() {
if [ -n "$PUBLIC_SPACE" ]; then
echo "$PUBLIC_SPACE → http://your-domain/$PUBLIC_SPACE/"
else
echo -e "(none) ${DIM}— served from the domain root${NC}"
fi
}
# ==========================================================
# Item 1 — public folder name
# ==========================================================
item_public() {
local NEW_NAME="${1:-}"
echo ""
echo -e "${BOLD}Public folder name${NC}"
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 -e " Current name: ${BOLD}$PUBLIC_FOLDER_NAME${NC}"
echo ""
if [ -z "$NEW_NAME" ]; then
read -r -p " New name (Enter to keep '$PUBLIC_FOLDER_NAME'): " NEW_NAME || true
fi
if [ -z "$NEW_NAME" ] || [ "$NEW_NAME" = "$PUBLIC_FOLDER_NAME" ]; then
echo -e " ${GREEN}✓${NC} Keeping '$PUBLIC_FOLDER_NAME'"
return 0
fi
if echo "$NEW_NAME" | grep -q '[/\\]'; then
echo -e " ${RED}✗ Error: folder name cannot contain slashes${NC}"
return 0
fi
if [ "$NEW_NAME" = "$SECURE_FOLDER_NAME" ]; then
echo -e " ${RED}✗ Error: cannot use the same name as the secure folder${NC}"
return 0
fi
if [ ! -d "$PUBLIC_DIR" ]; then
echo -e " ${RED}✗ Error: public folder '$PUBLIC_FOLDER_NAME' not found${NC}"
return 0
fi
if [ -e "$SCRIPT_DIR/$NEW_NAME" ]; then
echo -e " ${RED}✗ Error: '$NEW_NAME' already exists${NC}"
return 0
fi
mv "$PUBLIC_DIR" "$SCRIPT_DIR/$NEW_NAME"
PUBLIC_DIR="$SCRIPT_DIR/$NEW_NAME"
PUBLIC_FOLDER_NAME="$NEW_NAME"
local INIT_FILE; INIT_FILE="$(init_file)"
if [ -f "$INIT_FILE" ]; then
sed -i "s#define('PUBLIC_FOLDER_NAME',[[:space:]]*'[^']*')#define('PUBLIC_FOLDER_NAME', '$NEW_NAME')#" "$INIT_FILE"
fi
save_conf
echo -e " ${GREEN}✓${NC} Renamed → ${BOLD}$NEW_NAME${NC}"
}
# ==========================================================
# Item 2 — secure folder name
# ==========================================================
item_secure() {
echo ""
echo -e "${BOLD}Secure folder name${NC}"
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 -e " Current name: ${BOLD}$SECURE_FOLDER_NAME${NC}"
echo ""
local NEW_NAME
read -r -p " New name (Enter to keep '$SECURE_FOLDER_NAME'): " NEW_NAME || true
# Normalize backslashes to forward slashes, trim leading/trailing slashes
NEW_NAME=$(echo "$NEW_NAME" | sed 's:\\:/:g; s:^/*::; s:/*$::')
if [ -z "$NEW_NAME" ] || [ "$NEW_NAME" = "$SECURE_FOLDER_NAME" ]; then
echo -e " ${GREEN}✓${NC} Keeping '$SECURE_FOLDER_NAME'"
return 0
fi
if [ "$NEW_NAME" = "$PUBLIC_FOLDER_NAME" ]; then
echo -e " ${RED}✗ Error: cannot use the same name as the public folder${NC}"
return 0
fi
if [ ! -d "$SECURE_DIR" ]; then
echo -e " ${RED}✗ Error: secure folder '$SECURE_FOLDER_NAME' not found${NC}"
return 0
fi
local DEPTH; DEPTH=$(echo "$NEW_NAME" | awk -F/ '{print NF}')
if [ "$DEPTH" -gt 5 ]; then
echo -e " ${RED}✗ Error: path too deep (max 5 levels)${NC}"
return 0
fi
local TARGET="$SCRIPT_DIR/$NEW_NAME"
# Detect un-nesting (e.g. secure/test → secure): target is ancestor of source
local IS_UNNEST=false
if echo "$SECURE_DIR" | grep -q "^${TARGET}/"; then
IS_UNNEST=true
fi
if [ -e "$TARGET" ] && [ "$IS_UNNEST" != true ]; then
echo -e " ${RED}✗ Error: '$NEW_NAME' already exists${NC}"
return 0
fi
local OLD_SECURE_DIR="$SECURE_DIR"
local PARENT_DIR; PARENT_DIR="$(dirname "$TARGET")"
local TMP_DIR CLEANUP_DIR
if [ "$IS_UNNEST" = true ]; then
# Un-nesting (e.g. secure/test → secure): target is parent of source
TMP_DIR="$SCRIPT_DIR/.secure_move_tmp"
mv "$SECURE_DIR" "$TMP_DIR"
CLEANUP_DIR="$(dirname "$SECURE_DIR")"
while [ "$CLEANUP_DIR" != "$SCRIPT_DIR" ] && [ -d "$CLEANUP_DIR" ]; do
if [ -z "$(ls -A "$CLEANUP_DIR" 2>/dev/null)" ]; then
rmdir "$CLEANUP_DIR"
CLEANUP_DIR="$(dirname "$CLEANUP_DIR")"
else
break
fi
done
if [ -e "$TARGET" ]; then
echo -e " ${YELLOW}⚠ Warning: '$NEW_NAME' still contains other files.${NC}"
echo -e " ${YELLOW} They will be merged into the secure folder.${NC}"
fi
mv "$TMP_DIR" "$TARGET"
elif echo "$TARGET" | grep -q "^${SECURE_DIR}/"; then
# Self-nesting (e.g. secure → secure/test): can't mv into itself
TMP_DIR="$SCRIPT_DIR/.secure_move_tmp"
mv "$SECURE_DIR" "$TMP_DIR"
mkdir -p "$PARENT_DIR"
mv "$TMP_DIR" "$TARGET"
elif [ "$PARENT_DIR" != "$SCRIPT_DIR" ]; then
mkdir -p "$PARENT_DIR"
mv "$SECURE_DIR" "$TARGET"
else
mv "$SECURE_DIR" "$TARGET"
fi
SECURE_DIR="$TARGET"
SECURE_FOLDER_NAME="$NEW_NAME"
local INIT_FILE; INIT_FILE="$(init_file)"
if [ -f "$INIT_FILE" ]; then
sed -i "s#define('SECURE_FOLDER_NAME',[[:space:]]*'[^']*')#define('SECURE_FOLDER_NAME', '$NEW_NAME')#" "$INIT_FILE"
fi
# Cleanup empty parent directories from the old nested path
local OLD_PARENT; OLD_PARENT="$(dirname "$OLD_SECURE_DIR")"
while [ "$OLD_PARENT" != "$SCRIPT_DIR" ] && [ -d "$OLD_PARENT" ]; do
if [ -z "$(ls -A "$OLD_PARENT" 2>/dev/null)" ]; then
rmdir "$OLD_PARENT"
OLD_PARENT="$(dirname "$OLD_PARENT")"
else
break
fi
done
save_conf
echo -e " ${GREEN}✓${NC} Renamed → ${BOLD}$NEW_NAME${NC}"
# The vhost holds an ABSOLUTE include pointing INTO the secure folder.
# Renaming the folder moves that file, and nginx then refuses to reload on
# a path that no longer exists — a failure that surfaces at the next reload
# rather than here, which is precisely when nobody connects it to this menu.
echo ""
echo -e " ${YELLOW}⚠ nginx: your vhost still points at the OLD folder.${NC}"
echo " Update the include line in your server {} block to:"
echo ""
echo -e " ${BOLD}include $SECURE_DIR/nginx/dynamic_routes.conf;${NC}"
echo ""
echo " then reload nginx. An Apache install needs nothing here."
}
# ==========================================================
# Item 3 — URL space / prefix
# ==========================================================
item_space() {
echo ""
echo -e "${BOLD}URL space / prefix${NC}"
echo ""
echo " A URL space serves the site from a subdirectory of the domain."
echo " Example: 'web' → http://your-domain/web/"
echo ""
local DESIRED_SPACE="$PUBLIC_SPACE"
local INPUT
if [ -n "$PUBLIC_SPACE" ]; then
echo -e " Current space: ${BOLD}$PUBLIC_SPACE${NC}"
echo ""
echo " Enter a new space, type 'none' to remove it, or press Enter"
echo " to keep the current one."
echo ""
read -r -p " Space: " INPUT || true
if [ -z "$INPUT" ] || [ "$INPUT" = "$PUBLIC_SPACE" ]; then
echo -e " ${GREEN}✓${NC} Keeping '$PUBLIC_SPACE'"
return 0
elif [ "$INPUT" = "none" ]; then
DESIRED_SPACE=""
else
DESIRED_SPACE=$(echo "$INPUT" | sed 's:^[/\\]*::; s:[/\\]*$::')
fi
else
echo " No space currently set — the site serves from the domain root."
echo ""
read -r -p " Space (Enter to keep serving from the root): " INPUT || true
if [ -z "$INPUT" ]; then
echo -e " ${GREEN}✓${NC} No space — serving from the root"
return 0
fi
DESIRED_SPACE=$(echo "$INPUT" | sed 's:^[/\\]*::; s:[/\\]*$::')
fi
if [ "$DESIRED_SPACE" = "$PUBLIC_SPACE" ]; then
echo -e " ${GREEN}✓${NC} Unchanged"
return 0
fi
if [ -n "$DESIRED_SPACE" ]; then
if echo "$DESIRED_SPACE" | grep -q '[^a-zA-Z0-9._/-]'; then
echo -e " ${RED}✗ Error: invalid characters in space name${NC}"
echo " Allowed: a-z A-Z 0-9 . - _ /"
return 0
fi
local DEPTH; DEPTH=$(echo "$DESIRED_SPACE" | awk -F/ '{print NF}')
if [ "$DEPTH" -gt 5 ]; then
echo -e " ${RED}✗ Error: space path too deep (max 5 levels)${NC}"
return 0
fi
# Every segment is a folder name: an empty one, '.' or '..' names no folder of its own.
if echo "$DESIRED_SPACE" | grep -q '//' || echo "$DESIRED_SPACE" | grep -Eq '(^|/)\.{1,2}(/|$)'; then
echo -e " ${RED}✗ Error: every segment of a space is a folder name (no empty segment, no . or ..)${NC}"
return 0
fi
# No segment may be 'p': /p/<projectId>/ takes the project from the segment after the
# FIRST 'p' in the address, so a space holding one would bind the wrong project.
if echo "$DESIRED_SPACE" | grep -Eq '(^|/)p(/|$)'; then
echo -e " ${RED}✗ Error: a space cannot hold a segment named p — /p/ is where each project is served${NC}"
return 0
fi
if [ -d "$PUBLIC_DIR/$DESIRED_SPACE" ]; then
echo -e " ${RED}✗ Error: directory '$DESIRED_SPACE' already exists${NC}"
return 0
fi
fi
local SPACE_DIR TOP_SEGMENT item BASENAME
# Remove the current space (move files back to the public root)
if [ -n "$PUBLIC_SPACE" ]; then
SPACE_DIR="$PUBLIC_DIR/$PUBLIC_SPACE"
if [ -d "$SPACE_DIR" ]; then
for item in "$SPACE_DIR"/* "$SPACE_DIR"/.*; do
BASENAME="$(basename "$item")"
case "$BASENAME" in .|..) continue ;; esac
mv "$item" "$PUBLIC_DIR/" 2>/dev/null || true
done
TOP_SEGMENT=$(echo "$PUBLIC_SPACE" | cut -d/ -f1)
rm -rf "$PUBLIC_DIR/$TOP_SEGMENT" 2>/dev/null || true
fi
fi
# Set the new space (move files from the root into the space dir)
if [ -n "$DESIRED_SPACE" ]; then
TOP_SEGMENT=$(echo "$DESIRED_SPACE" | cut -d/ -f1)
SPACE_DIR="$PUBLIC_DIR/$DESIRED_SPACE"
mkdir -p "$SPACE_DIR"
for item in "$PUBLIC_DIR"/* "$PUBLIC_DIR"/.*; do
BASENAME="$(basename "$item")"
case "$BASENAME" in .|..) continue ;; esac
[ "$BASENAME" = "$TOP_SEGMENT" ] && continue
mv "$item" "$SPACE_DIR/" 2>/dev/null || true
done
fi
PUBLIC_SPACE="$DESIRED_SPACE"
local INIT_FILE; INIT_FILE="$(init_file)"
if [ -f "$INIT_FILE" ]; then
sed -i "s#define('PUBLIC_FOLDER_SPACE',[[:space:]]*'[^']*')#define('PUBLIC_FOLDER_SPACE', '$PUBLIC_SPACE')#" "$INIT_FILE"
fi
# ---- Apache: retarget every FallbackResource to the new prefix ----------
# FallbackResource takes a URL path relative to the DocumentRoot, so each of
# these has to carry the space or Apache looks for the entry point where it
# no longer is. /p/ is in this list because /p/<projectId>/ is how EVERY
# project is served (public/p/.htaccess) — miss it and the whole public
# surface 404s on a space install while /admin/ still works, which reads
# like a routing bug and is not one.
local HT_DIR PREFIX
HT_DIR="$(served_dir)"
if [ -n "$PUBLIC_SPACE" ]; then PREFIX="/$PUBLIC_SPACE"; else PREFIX=""; fi
[ -f "$HT_DIR/.htaccess" ] && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/index.php|" "$HT_DIR/.htaccess"
[ -f "$HT_DIR/p/.htaccess" ] && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/p/index.php|" "$HT_DIR/p/.htaccess"
[ -f "$HT_DIR/management/.htaccess" ] && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/management/index.php|" "$HT_DIR/management/.htaccess"
[ -f "$HT_DIR/admin/.htaccess" ] && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/admin/index.php|" "$HT_DIR/admin/.htaccess"
# ---- nginx: drop the stale routing config, do NOT rewrite it here -------
# The generator lives in ONE place — secure/src/functions/NginxConfig.php —
# and init.php recreates the file on the next page load. This script used to
# keep its own copy of that generator, which silently went stale: it still
# emitted a root catch-all into an entry point that no longer exists and no
# /p/ block at all, so re-running setup broke the routing of an install that
# was working. Deleting is the one operation that cannot go out of date.
local NGINX_CONF="$SECURE_DIR/nginx/dynamic_routes.conf"
if [ -f "$NGINX_CONF" ]; then
rm -f "$NGINX_CONF"
echo -e " ${GREEN}✓${NC} Removed the old nginx routing config"
echo -e " ${YELLOW}⚠ nginx: load any page once${NC} — that regenerates"
echo " $NGINX_CONF"
echo " for the new prefix. Reload nginx after that, not before: the"
echo " include points at a file that does not exist right now."
echo ""
# The regenerated include cannot fix the ONE block that lives in the
# vhost instead: `location @quicksite_project` carries the URL prefix
# inside its SCRIPT_FILENAME, and it is pasted by hand because it needs
# a fastcgi_pass only the operator knows. Leave it stale and every
# /p/<id>/ request posts at an entry point that is not there, which
# php-fpm answers with a bare "File not found." while the admin panel —
# served entirely from the regenerated include — keeps working.
echo -e " ${YELLOW}⚠ nginx: also update the block you pasted by hand.${NC}"
echo " In your server {} block, location @quicksite_project must read:"
echo ""
if [ -n "$PUBLIC_SPACE" ]; then
echo -e " ${BOLD}fastcgi_param SCRIPT_FILENAME \$document_root/$PUBLIC_SPACE/p/index.php;${NC}"
else
echo -e " ${BOLD}fastcgi_param SCRIPT_FILENAME \$document_root/p/index.php;${NC}"
fi
echo ""
echo " Miss this and /p/<id>/ answers \"File not found.\" while the"
echo " admin panel still works — the two are served by different blocks."
fi
save_conf
if [ -n "$PUBLIC_SPACE" ]; then
echo -e " ${GREEN}✓${NC} Space set → http://your-domain/${BOLD}$PUBLIC_SPACE${NC}/"
else
echo -e " ${GREEN}✓${NC} Space removed — serving from the root"
fi
}
# ==========================================================
# Item 4 — environment (production / development)
# ==========================================================
item_environment() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/environment.php"
local example="$cfg_dir/environment.php.example"
echo ""
echo -e "${BOLD}Environment${NC}"
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 -e " ${BOLD}production${NC} 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 -e " ${BOLD}development${NC} 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 -e " Currently: $(env_label)"
echo ""
echo -e " ${DIM}Only ever choose development on a machine you fully trust and${NC}"
echo -e " ${DIM}that untrusted callers cannot reach.${NC}"
echo ""
# Both answers are SPELLED OUT. A bare [y/N] makes the reader infer what N
# means, and here it is not "no" — it is the other named mode. Getting that
# backwards on a public server is the one mistake this item can cause.
local ANSWER
read -r -p " Is this a local/dev install? y = development, Enter = production: " ANSWER || true
local VALUE="production"
case "${ANSWER:-}" in
[yY]|[yY][eE][sS]) VALUE="development" ;;
esac
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
# Start from the .example so the live file keeps the full explanation of
# what the setting does — the operator edits this file by hand later, and a
# bare two-line array tells them nothing.
if [ ! -f "$live" ]; then
if [ -f "$example" ]; then
cp "$example" "$live"
else
printf "<?php\n\nreturn [\n 'environment' => 'production',\n];\n" > "$live"
fi
fi
# '#' as the delimiter, not '|': BRE alternation is written \| and sed reads
# a backslash-escaped delimiter as a LITERAL, so a '|'-delimited s/// that
# contains \| silently matches nothing. That is how the sibling
# self-registration edit failed its first test run.
sed -i "s#'environment'[[:space:]]*=>[[:space:]]*'[^']*'#'environment' => '$VALUE'#" "$live"
if [ "$(env_current)" = "$VALUE" ]; then
echo -e " ${GREEN}✓${NC} Environment: ${BOLD}$VALUE${NC}"
if [ "$VALUE" = "development" ]; then
echo -e " ${YELLOW}⚠ Do not run a publicly reachable install this way.${NC}"
fi
else
echo -e " ${RED}✗ Could not set the value automatically.${NC}"
echo " Edit this file by hand and set 'environment' => '$VALUE':"
echo " $live"
fi
echo ""
echo -e " ${DIM}This is a server-side file, on purpose: there is no API that${NC}"
echo -e " ${DIM}changes it, so a leaked credential cannot switch the install${NC}"
echo -e " ${DIM}into development and re-open outbound access.${NC}"
}
# ==========================================================
# Item 5 — self-registration
# ==========================================================
item_selfreg() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/auth.php"
local example="$cfg_dir/auth.php.example"
echo ""
echo -e "${BOLD}Self-registration${NC}"
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 -e " Currently: $(selfreg_label)"
echo ""
echo -e " ${DIM}Either way, the FIRST account is created from the first-run page${NC}"
echo -e " ${DIM}using the setup token — self-registration never bootstraps an${NC}"
echo -e " ${DIM}install.${NC}"
echo ""
local ANSWER
read -r -p " Allow visitors to create their own accounts? [y/N]: " ANSWER || true
local VALUE="false"
case "${ANSWER:-}" in
[yY]|[yY][eE][sS]) VALUE="true" ;;
esac
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
# auth.php does not exist yet on a fresh clone — the engine creates it from
# the .example on the first page load. Creating it here is the same copy,
# done earlier; the engine's copy is create-if-absent and leaves ours alone.
if [ ! -f "$live" ]; then
if [ -f "$example" ]; then
cp "$example" "$live"
else
echo -e " ${RED}✗ Error: neither auth.php nor auth.php.example is present${NC}"
echo " $cfg_dir"
return 0
fi
fi
sed -i "s#'allow_self_registration'[[:space:]]*=>[[:space:]]*[a-zA-Z]*#'allow_self_registration' => $VALUE#" "$live"
if [ "$(selfreg_current)" = "$VALUE" ]; then
if [ "$VALUE" = "true" ]; then
echo -e " ${GREEN}✓${NC} Self-registration: ${BOLD}on${NC}"
echo -e " ${YELLOW}⚠ /admin/register is now open to anyone who can reach it.${NC}"
else
echo -e " ${GREEN}✓${NC} Self-registration: ${BOLD}off${NC}"
fi
else
echo -e " ${RED}✗ Could not set the value automatically.${NC}"
echo " Edit this file by hand and set 'allow_self_registration' => $VALUE:"
echo " $live"
fi
}
# ==========================================================
# Item 6 — show my setup token
# ==========================================================
item_token() {
local token_file; token_file="$(CONFIG_DIR)/setup-token.txt"
echo ""
echo -e "${BOLD}Setup token${NC}"
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 [ ! -f "$token_file" ]; then
# THE ORDERING THAT SURPRISES PEOPLE. The token is minted by the engine
# when the first-run page renders, not by this script. On a fresh clone
# it genuinely does not exist yet, and printing an empty value here
# would look like a broken install.
echo -e " ${YELLOW}The token has not been created yet.${NC}"
echo ""
echo " It is written the first time the admin panel is loaded. Do this,"
echo " in this order:"
echo ""
echo -e " 1. Point your vhost DocumentRoot at ${BOLD}$PUBLIC_FOLDER_NAME/${NC} and"
echo " restart your web server."
if [ -n "$PUBLIC_SPACE" ]; then
echo -e " 2. Open ${BOLD}http://your-domain/$PUBLIC_SPACE/admin/${NC} in a browser."
else
echo -e " 2. Open ${BOLD}http://your-domain/admin/${NC} in a browser."
fi
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"
return 0
fi
# ⚠ EXISTS BUT UNREADABLE IS NOT "ALREADY USED". The engine mints this file
# as the WEB SERVER's user and chmods it 0600 (setupToken.php), so on a real
# Linux host it belongs to php-fpm rather than to whoever runs this script.
# `-f` above still succeeds — that needs only search permission on the
# directory — and the `cat` below then fails silently behind its `|| true`,
# leaving an empty value that fails the shape test and lands in the
# "already been used" branch. A live token was reported as spent, with
# nothing pointing at permissions. Windows never showed it: chmod is a no-op
# there, which is why this only appears on a deployed install.
if [ ! -r "$token_file" ]; then
echo -e " ${YELLOW}The token exists, but this account cannot read it.${NC}"
echo ""
echo " It is created by the web server and is owner-only, so it belongs"
echo " to the PHP-FPM user and not to you:"
echo ""
echo " $(ls -l "$token_file" 2>/dev/null || echo "$token_file")"
echo " you are $(id -un 2>/dev/null || echo 'this shell user')"
echo ""
echo " Read it as that user, or with elevated rights:"
echo ""
echo -e " ${BOLD}sudo cat $token_file${NC}"
echo ""
echo -e " ${DIM}This is not a broken install — the token is live and the${NC}"
echo -e " ${DIM}first-run form will accept it.${NC}"
return 0
fi
local RAW TOKEN
RAW="$(cat "$token_file" 2>/dev/null || true)"
TOKEN="$(echo "$RAW" | tr -d '[:space:]')"
if echo "$TOKEN" | grep -qE '^[0-9a-f]{64}$'; then
echo -e " ${GREEN}Your setup token:${NC}"
echo ""
echo -e " ${BOLD}$TOKEN${NC}"
echo ""
echo " Paste it into the first-run form at /admin/. It is used once and"
echo " destroyed the moment your account is created."
else
# Consumed: qs_setup_token_consume() overwrites the value with a marker
# before unlinking, so anything here that is not token-shaped means the
# bootstrap is over.
echo -e " ${YELLOW}This token has already been used.${NC}"
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 -e " ${DIM}Lost the password? There is no reset from here. Delete${NC}"
echo -e " ${DIM}$(CONFIG_DIR)/users.php${NC}"
echo -e " ${DIM}to start the bootstrap over — every account goes with it.${NC}"
fi
}
# ==========================================================
# Item 7 — storage quotas
# ==========================================================
# ⚠ ABSENT MEANS THE DEFAULTS, AND THAT IS A REAL SETTING, NOT A MISSING ONE: no
# storage ceiling and no upload rate, with the size caps and the write rate at the
# values the engine ships. A fresh install has no quota.php and must not refuse
# uploads out of the box, so declining every prompt here LEAVES THE FILE ABSENT
# rather than writing zeros. The two are equivalent to the engine today, but writing
# a file for "no ceiling" would turn an install that opted out into one that opted in
# to nothing, and the next person to read the folder could not tell them apart.
item_quota() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/quota.php"
local example="$cfg_dir/quota.php.example"
echo ""
echo -e "${BOLD}Storage quotas${NC}"
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 -e " Currently: $(quota_label)"
echo ""
echo -e " ${DIM}Leave both blank for no storage ceiling and no upload rate; the size${NC}"
echo -e " ${DIM}caps and the write rate still apply. Full documentation of every${NC}"
echo -e " ${DIM}option lives in quota.php.example, which is never modified.${NC}"
echo ""
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
local MB UPLOADS HOURS
read -r -p " Max total size per user, in MB [blank = unlimited]: " MB || true
read -r -p " Max uploads per period [blank = unlimited]: " UPLOADS || true
# Non-numeric input is refused rather than silently coerced: "2GB" would
# otherwise become 0, i.e. unlimited, which is the opposite of the intent.
case "${MB:-}" in
"") ;;
*[!0-9]*) echo -e " ${RED}✗ Not a whole number of MB: $MB${NC}"; return 0 ;;
esac
case "${UPLOADS:-}" in
"") ;;
*[!0-9]*) echo -e " ${RED}✗ Not a whole number: $UPLOADS${NC}"; return 0 ;;
esac
HOURS=""
if [ -n "$UPLOADS" ] && [ "$UPLOADS" != "0" ]; then
read -r -p " ...per how many hours [1]: " HOURS || true
[ -z "$HOURS" ] && HOURS=1
case "$HOURS" in
*[!0-9]*|0) echo -e " ${RED}✗ Not a whole number of hours: $HOURS${NC}"; return 0 ;;
esac
fi
local BYTES=0 MAXUP=0 PERIOD=3600
[ -n "$MB" ] && BYTES=$(( MB * 1024 * 1024 ))
[ -n "$UPLOADS" ] && MAXUP="$UPLOADS"
[ -n "$HOURS" ] && PERIOD=$(( HOURS * 3600 ))
# Both axes unlimited — do NOT write a file to say so.
if [ "$BYTES" = "0" ] && [ "$MAXUP" = "0" ]; then
if [ ! -f "$live" ]; then
echo ""
echo -e " ${GREEN}✓${NC} 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."
return 0
fi
echo ""
echo -e " ${DIM}quota.php already exists. To go back to the defaults, delete it:${NC}"
echo -e " ${DIM}$live${NC}"
echo -e " ${DIM}Setup does not delete a config file you wrote.${NC}"
return 0
fi
# quota.php.example is the shipped documentation and is never edited: the
# live file is a COPY with the numbers patched, so every explanatory comment
# survives into the file the deployer will actually read later.
if [ ! -f "$live" ]; then
if [ ! -f "$example" ]; then
echo -e " ${RED}✗ Error: quota.php.example is missing${NC}"
return 0
fi
cp "$example" "$live"
fi
# Anchored to start-of-line so the COMMENTED example value above each
# setting (those lines begin with //) is left alone.
sed -i "s|^\( *\)'max_total_bytes'[[:space:]]*=>.*|\1'max_total_bytes' => ${BYTES},|" "$live"
sed -i "s|^\( *\)'max_uploads'[[:space:]]*=>.*|\1'max_uploads' => ${MAXUP},|" "$live"
sed -i "s|^\( *\)'period_seconds'[[:space:]]*=>.*|\1'period_seconds' => ${PERIOD},|" "$live"
echo ""
echo -e " ${GREEN}✓${NC} Quotas written to quota.php"
echo -e " $(quota_label)"
}
# ==========================================================
# Item 8 — the command console
# ==========================================================
# ⚠ ABSENT MEANS ALLOWED, so this item declines to write a file when the answer
# is ON: the file exists only to say no.
#
# ⚠ AND IT IS NOT A SECURITY CONTROL. Every command the console lists is
# already reachable at /management/ by any caller whose role allows it — the
# console runs them through the same permission check. Turning it off removes
# reach and discoverability, not access. The prompt says so, because an operator
# who reads this as "my users can no longer run commands" has been misled.
item_console() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/console.php"
local example="$cfg_dir/console.php.example"
echo ""
echo -e "${BOLD}Command console${NC}"
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 -e " Currently: $(console_label)"
echo ""
echo -e " ${DIM}⚠ This is NOT a security control. Every command stays reachable${NC}"
echo -e " ${DIM}at /management/ to any caller whose role allows it, and the${NC}"
echo -e " ${DIM}console checks the same permissions. Turning it off removes${NC}"
echo -e " ${DIM}reach and discoverability, not access — do not skip a role${NC}"
echo -e " ${DIM}review because of it. The command history tab stays either way.${NC}"
echo ""
local ANSWER
read -r -p " Offer the command console on this installation? [Y/n]: " ANSWER || true
# Enter accepts the SHIPPED DEFAULT, which here is on.
local VALUE="true"
case "${ANSWER:-}" in
[nN]|[nN][oO]) VALUE="false" ;;
esac
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
# Accepting when no file exists LEAVES IT ABSENT — absent already means the
# console is offered, and writing a file to say so would turn an install
# that never opted out into one that opted in, which reads as a decision
# somebody made.
if [ "$VALUE" = "true" ] && [ ! -f "$live" ]; then
echo ""
echo -e " ${GREEN}✓${NC} Console stays on — console.php left absent, which is what a"
echo " fresh install has."
return 0
fi
# console.php.example is the shipped documentation and is never edited: the
# live file is a COPY with the value patched, so every explanatory comment
# survives into the file the deployer will actually read later.
if [ ! -f "$live" ]; then
if [ -f "$example" ]; then
cp "$example" "$live"
else
echo -e " ${RED}✗ Error: neither console.php nor console.php.example is present${NC}"
echo " $cfg_dir"
return 0
fi
fi
# Anchored to start-of-line so the value quoted inside the file's own
# comments is left alone.
sed -i "s|^\( *\)'allow_console'[[:space:]]*=>[[:space:]]*[a-zA-Z]*|\1'allow_console' => $VALUE|" "$live"
if [ "$(console_current)" = "$VALUE" ]; then
if [ "$VALUE" = "false" ]; then
echo -e " ${GREEN}✓${NC} Command console: ${BOLD}off${NC}"
echo -e " ${DIM} /admin/command no longer renders the runner and the nav entry${NC}"
echo -e " ${DIM} is gone. The commands themselves are unchanged.${NC}"
else
echo -e " ${GREEN}✓${NC} Command console: ${BOLD}on${NC}"
fi
else
echo -e " ${RED}✗ Could not set the value automatically.${NC}"
echo " Edit this file by hand and set 'allow_console' => $VALUE:"
echo " $live"
fi
}
# ==========================================================
# Item 9 — the default language for new projects
# ==========================================================
# The language a NEW project starts in when nobody chooses one: createProject's
# default, what the panel's New Project dialog preselects, and what an imported
# archive that lists no language gets. A project that exists never reads it.
#
# A free-text value, so Enter KEEPS what is there (items 1-3's rule), rather than
# resetting to the shipped default the way the yes/no items do.
#
# ABSENT MEANS 'en', and choosing 'en' when no file exists LEAVES IT ABSENT —
# item 8's rule: a file that says what the default already is would read as a
# decision somebody made. A code that is not in the language list is refused
# here and nothing is written: the engine would ignore it (and log why) anyway.
item_deflang() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/default-language.php"
local example="$cfg_dir/default-language.php.example"
echo ""
echo -e "${BOLD}Default language for new projects${NC}"
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 -e " 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 " $(language_list_file)"
echo ""
local ANSWER
read -r -p " Language code (Enter keeps $(deflang_effective)): " ANSWER || true
local VALUE="${ANSWER:-}"
VALUE="${VALUE//[[:space:]]/}"
if [ -z "$VALUE" ]; then
echo -e " ${DIM}Unchanged.${NC}"
return 0
fi
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
if ! deflang_listed "$VALUE"; then
echo -e " ${RED}✗ '$VALUE' is not a code in this installation's language list.${NC}"
echo " Nothing was written."
return 0
fi
if [ "$VALUE" = "en" ] && [ ! -f "$live" ]; then
echo ""
echo -e " ${GREEN}✓${NC} Default language stays en — default-language.php left absent,"
echo " which is what a fresh install has."
return 0
fi
# default-language.php.example is the shipped documentation and is never
# edited: the live file is a COPY with the value patched, so every
# explanatory comment survives into the file the operator will read later.
if [ ! -f "$live" ]; then
if [ -f "$example" ]; then
cp "$example" "$live"
else
echo -e " ${RED}✗ Error: neither default-language.php nor default-language.php.example is present${NC}"
echo " $cfg_dir"
return 0
fi
fi
# Anchored to start-of-line so the value quoted inside the file's own
# comments is left alone. '#' as the delimiter — item 4 says why.
sed -i "s#^\( *\)'default_language'[[:space:]]*=>[[:space:]]*'[^']*',#\1'default_language' => '$VALUE',#" "$live"
if [ "$(deflang_current)" = "$VALUE" ]; then
echo -e " ${GREEN}✓${NC} Default language for new projects: ${BOLD}$VALUE${NC}"
echo -e " ${DIM} Only projects created from now on start in it.${NC}"
else
echo -e " ${RED}✗ Could not set the value automatically.${NC}"
echo " Edit this file by hand and set 'default_language' => '$VALUE':"
echo " $live"
fi
}
# ==========================================================
# Item 10 — idle sign-out
# ==========================================================
# How long a session may go unused before the panel signs it out: auth.php's
# session.idle_ttl, in seconds. The login page states it under "Keep me signed
# in". A free-text value, so Enter KEEPS what is there (item 9's rule). The
# answer is a number and m, h or d, at least 5 minutes — the engine's own
# minimum — and written as seconds.
#
# ABSENT MEANS 1 DAY, and choosing 1 day when auth.php does not exist yet LEAVES
# IT ABSENT (item 9's rule). Otherwise auth.php is created from its .example as
# item 5 creates it, and only the number on the idle_ttl line changes.
item_idle() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/auth.php"
local example="$cfg_dir/auth.php.example"
echo ""
echo -e "${BOLD}Idle sign-out${NC}"
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 -e " Currently: $(idle_label)"
echo ""
local ANSWER
read -r -p " Sign out after how long without using the panel? A number and m, h or d (Enter keeps $(idle_words "$(idle_effective)")): " ANSWER || true
local VALUE="${ANSWER:-}"
VALUE="${VALUE//[[:space:]]/}"
VALUE="$(printf '%s' "$VALUE" | tr '[:upper:]' '[:lower:]')"
if [ -z "$VALUE" ]; then
echo -e " ${DIM}Unchanged.${NC}"
return 0
fi
if [[ ! "$VALUE" =~ ^([0-9]{1,4})([mhd])$ ]]; then
echo -e " ${RED}✗ '$ANSWER' is not a number followed by m, h or d.${NC}"
echo " Nothing was written."
return 0
fi
# 10#: a leading zero is a decimal digit here, not octal
local NUM=$((10#${BASH_REMATCH[1]})) MULT=60
case "${BASH_REMATCH[2]}" in
h) MULT=3600 ;;
d) MULT=86400 ;;
esac
local SECS=$((NUM * MULT))
if [ "$SECS" -lt 300 ]; then
echo -e " ${RED}✗ The shortest idle sign-out is 5 minutes.${NC}"
echo " Nothing was written."
return 0
fi
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
if [ "$SECS" -eq 86400 ] && [ ! -f "$live" ]; then
echo ""
echo -e " ${GREEN}✓${NC} Idle sign-out stays 1 day — auth.php left absent, which is what a"
echo " fresh install has."
return 0
fi
# auth.php does not exist yet on a fresh clone — item 5 says why creating it
# here is safe.
if [ ! -f "$live" ]; then
if [ -f "$example" ]; then
cp "$example" "$live"
else
echo -e " ${RED}✗ Error: neither auth.php nor auth.php.example is present${NC}"
echo " $cfg_dir"
return 0
fi
fi
# Anchored to start-of-line, and only the number changes: the key's spacing and
# the comment after it stay. '#' as the delimiter — item 4 says why.
sed -i "s#^\([[:space:]]*'idle_ttl'[[:space:]]*=>[[:space:]]*\)[0-9][0-9]*#\1$SECS#" "$live"
if [ "$(idle_current)" = "$SECS" ]; then
echo -e " ${GREEN}✓${NC} Idle sign-out: ${BOLD}$(idle_words "$SECS")${NC}"
echo -e " ${DIM} Every session follows it from its next request.${NC}"
else
echo -e " ${RED}✗ Could not set the value automatically.${NC}"
echo " Edit this file by hand and set 'idle_ttl' => $SECS:"
echo " $live"
fi
}
# ==========================================================
# Item 11 — the Embed Security page
# ==========================================================
# The page shows the installation's embed policy (embed-policy.json), read-only:
# no author can change it from the panel, so a deployer may decide nobody needs
# to see it. ABSENT MEANS SHOWN, so this item declines to write a file when the
# answer is "show": the file exists only to say no.
#
# ⚠ AND IT IS NOT A SECURITY CONTROL. Hiding the page removes a page, not a rule:
# the policy stays in force, and every command stays reachable at /management/
# to any caller whose role allows it.
item_embed() {
local cfg_dir; cfg_dir="$(CONFIG_DIR)"
local live="$cfg_dir/embed-security.php"
local example="$cfg_dir/embed-security.php.example"
echo ""
echo -e "${BOLD}Embed Security page${NC}"
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 -e " Currently: $(embed_label)"
echo ""
echo -e " ${DIM}⚠ This is NOT a security control. The embed policy stays in${NC}"
echo -e " ${DIM}force either way, and every command stays reachable at${NC}"
echo -e " ${DIM}/management/ to any caller whose role allows it.${NC}"
echo ""
local ANSWER
read -r -p " Show the Embed Security page on this installation? [Y/n]: " ANSWER || true
# Enter accepts the SHIPPED DEFAULT, which here is show.
local VALUE="true"
case "${ANSWER:-}" in
[nN]|[nN][oO]) VALUE="false" ;;
esac
if [ ! -d "$cfg_dir" ]; then
echo -e " ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
return 0
fi
# Accepting when no file exists LEAVES IT ABSENT, as item 8 does: absent
# already means shown.
if [ "$VALUE" = "true" ] && [ ! -f "$live" ]; then
echo ""
echo -e " ${GREEN}✓${NC} Embed Security stays shown — embed-security.php left absent,"
echo " which is what a fresh install has."
return 0
fi
# The .example is the shipped documentation and is never edited: the live
# file is a COPY with the value patched.
if [ ! -f "$live" ]; then
if [ -f "$example" ]; then
cp "$example" "$live"
else
echo -e " ${RED}✗ Error: neither embed-security.php nor embed-security.php.example is present${NC}"
echo " $cfg_dir"
return 0
fi
fi
# Anchored to start-of-line so the value quoted inside the file's own
# comments is left alone.
sed -i "s|^\( *\)'show_embed_security'[[:space:]]*=>[[:space:]]*[a-zA-Z]*|\1'show_embed_security' => $VALUE|" "$live"
if [ "$(embed_current)" = "$VALUE" ]; then
if [ "$VALUE" = "false" ]; then
echo -e " ${GREEN}✓${NC} Embed Security page: ${BOLD}hidden${NC}"
echo -e " ${DIM} It is no longer in the menu, and says it was turned off. The${NC}"
echo -e " ${DIM} embed policy is unchanged.${NC}"
else
echo -e " ${GREEN}✓${NC} Embed Security page: ${BOLD}shown${NC}"
fi
else
echo -e " ${RED}✗ Could not set the value automatically.${NC}"
echo " Edit this file by hand and set 'show_embed_security' => $VALUE:"
echo " $live"
fi
}
# ==========================================================
# The menu
# ==========================================================
show_header() {
echo ""
echo -e "${BOLD}========================================${NC}"
echo -e "${BOLD} QuickSite Setup${NC}"
echo -e "${BOLD}========================================${NC}"
}
show_menu() {
echo ""
echo -e " Public folder: ${BOLD}$PUBLIC_FOLDER_NAME${NC}"
echo -e " Secure folder: ${BOLD}$SECURE_FOLDER_NAME${NC}"
echo -e " URL space: $(space_label)"
echo -e " Environment: $(env_label)"
echo -e " Self-registration: $(selfreg_label)"
echo -e " Storage quotas: $(quota_label)"
echo -e " Command console: $(console_label)"
echo -e " Default language: $(deflang_label)"
echo -e " Idle sign-out: $(idle_label)"
echo -e " 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 ""
}
show_header
maybe_place_landing_page
ensure_embed_policy
save_conf
# A public folder name given on the command line pre-answers item 1.
if [ -n "${1:-}" ]; then
item_public "$1"
fi
while true; do
show_menu
CHOICE=""
read -r -p " Choice: " CHOICE || { echo ""; break; }
case "${CHOICE:-}" in
1) item_public; pause_for_menu ;;
2) item_secure; pause_for_menu ;;
3) item_space; pause_for_menu ;;
4) item_environment; pause_for_menu ;;
5) item_selfreg; pause_for_menu ;;
6) item_token; pause_for_menu ;;
7) item_quota; pause_for_menu ;;
8) item_console; pause_for_menu ;;
9) item_deflang; pause_for_menu ;;
10) item_idle; pause_for_menu ;;
11) item_embed; pause_for_menu ;;
q|Q|"") break ;;
*) echo -e " ${RED}Unknown choice: $CHOICE${NC}" ;;
esac
done
save_conf
echo ""
echo -e "${BOLD}========================================${NC}"
echo -e "${GREEN}${BOLD} Setup complete${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
echo " Public folder: $PUBLIC_FOLDER_NAME"
echo " Secure folder: $SECURE_FOLDER_NAME"
if [ -n "$PUBLIC_SPACE" ]; then
echo " URL space: $PUBLIC_SPACE"
fi
echo -e " Environment: $(env_label)"
echo -e " Self-registration: $(selfreg_label)"
echo -e " Storage quotas: $(quota_label)"
echo -e " Command console: $(console_label)"
echo -e " Default language: $(deflang_label)"
echo -e " Idle sign-out: $(idle_label)"
echo -e " Embed security: $(embed_label)"
echo ""
if [ -n "$OWNERSHIP_WARNING" ]; then
echo -e " ${RED}${BOLD}⚠ IMPORTANT: file ownership${NC}"
echo -e " ${RED}You are running as root. PHP needs write access to $SECURE_FOLDER_NAME/.${NC}"
echo -e " ${RED}Run this (replace USER with your web server / site user):${NC}"
echo ""
echo -e " ${BOLD}chown -R USER:USER $(basename "$SCRIPT_DIR")/${NC}"
echo ""
echo -e " ${RED}Common users: www-data (Ubuntu), nginx, apache,${NC}"
echo -e " ${RED}or your CloudPanel site user (e.g. quicksite-test)${NC}"
echo ""
fi
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.sh 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 ""