curl and openssl extensions
(both are standard), and Apache .htaccess support.fsn1). Set visibility to private — visitors get
access only through short-lived presigned URLs.No bucket CORS rule is needed: uploads are proxied through the webhost
(same-origin) and gallery images load via <img> presigned GET URLs, which
browsers don't subject to CORS. Keep the bucket private.
Uploads stream through PHP one image per request, so the total gallery size is
irrelevant — but each single image must fit the host's limits. Ensure
upload_max_filesize and post_max_size are at least as large as your biggest
original (e.g. 200M for RAW files); on shared hosting set these in .user.ini
or php.ini:
upload_max_filesize = 200M
post_max_size = 200M
max_execution_time is lifted per upload request in code, but if your host caps
it at the web-server level (e.g. Apache/FPM request timeout), raise that too for
large files.
The browser uploads several images at once (uploads.concurrency, default 3),
which keeps the uplink busy while the webhost forwards earlier files to S3. Each
one occupies a PHP worker for its whole S3 round trip, so on shared hosting with
a tight per-site process limit, lower it:
'concurrency' => 2, // or 1 to restore strictly serial uploads
Gallery order does not depend on this: files are stored in the order they were selected whatever value you set, and whatever order the uploads finish in.
If uploads start failing with 503s under load, that limit is the first thing to check.
Enabling downloads on a gallery builds one ZIP of it into S3, so visitors get a single file without the webhost ever serving the bytes. Two things to know:
archive.step_seconds so no request approaches
max_execution_time — the 60 s cap common on shared hosting is fine and
needs no change.Rebuilds normally happen on their own: a page view dispatches worker.php,
which runs a slice and then dispatches its successor until the archive is
finished. If your host blocks outbound HTTP to itself, that chain cannot
start, and archives instead advance one slice per page view. A real cron job
removes the guesswork entirely — every 5 minutes is plenty:
*/5 * * * * curl -s "https://www.example.com/worker.php?key=YOUR_WORKER_KEY" >/dev/null
The key is generated on first use and stored in data/worker-key.json; read it
from there. Without a valid key the script returns a bare 404.
An interrupted build always resumes where it stopped, and one abandoned for
archive.abandon_hours (default 24) is aborted and restarted — which also
releases the incomplete multipart upload S3 would otherwise keep billing for.
cp config/config.sample.php config/config.php
cp config/credentials.sample.php config/credentials.php
Edit config/config.php:
| Key | Meaning |
|---|---|
site.name |
Fallback site title |
site.base_url |
Public base URL, used for gallery share links |
site.timezone |
Timezone for expiry checks, e.g. Europe/Berlin |
s3.endpoint |
https://<location>.your-objectstorage.com |
s3.region |
The location, e.g. fsn1 |
s3.bucket |
Bucket name |
s3.access_key / s3.secret_key |
S3 credentials |
s3.url_ttl |
Lifetime of presigned view URLs in seconds |
uploads.thumb_size |
Longest edge of grid thumbnails (browser-generated) |
uploads.resize_quality |
JPEG quality (0.0–1.0) for galleries that cap their upload resolution |
cron.enabled |
true only when a real cron line calls cron.php — see below |
cron.max_seconds |
Budget for one cron invocation (default 240) |
The default admin login is admin / changeme — change it in the admin
Settings page immediately after the first login.
Upload the contents of this folder into your account's document root
(usually public_html/, htdocs/ or www/) via FTP/SFTP. The site's home
page, index.php, sits directly in the document root — there is no separate
web-root subfolder to configure.
The application internals (app/, config/, data/, docs/,
manage-client/, migrations/, scripts/) live inside the document root but
are blocked from the web by the root .htaccess (plus a deny-all .htaccess
inside each of app/, config/, data/ and manage-client/ as a fallback).
The manage client is only ever reached through PHP includes — its update and
backup functions are exposed at /admin/maintenance.php, behind the login.
Verify after deploying — each of these must return 403 Forbidden, never their contents:
https://your-domain.com/config/config.phphttps://your-domain.com/config/credentials.phphttps://your-domain.com/data/site.jsonhttps://your-domain.com/manage-client/config.phpIf they don't, your host ignores .htaccess — move app/, config/ and
data/ above the document root and adjust the paths, or contact support.
The PHP process must be able to write to:
data/ (and data/galleries/) — flat-file contentmedia/ — hero + showreel imagesconfig/ — only for the online password changedata/manage/ — backups, update working files (created automatically)On typical shared hosting (suEXEC/FPM running as your user) this already
works; otherwise chmod 755 the directories (or 775/777 as a last resort).
/admin/, log in, change the password (Settings)./showreel.php, scroll behavior, and
that navigation hides when scrolling down.your-objectstorage.com, not from your domain,manage-client/ connects the installation to a
manage server that keeps the backups and hands out
releases. Once it is configured, a backup is a button and so is an update.
Everything it does is also available from the shell, and both paths call the
same code.
Skipping this is a supported choice: without manage-client/config.php the
site runs exactly as before, and the manual route in 5b stays open.
On the webhost:
cp manage-client/config.sample.php manage-client/config.php
Fill in MANAGE_INSTANCE and MANAGE_TOKEN. MANAGE_SERVER_URL is already
set. Nothing else needs changing: backup sources, protected paths, the
version file and the post-update hook are configured for this project.
Check it:
php manage-client/bin/manage-client.php status
Or open /admin/ → Maintenance, which shows the same thing.
manage-client.php backup).manage-client/config.php holds the token. It is gitignored, excluded from
release packages, and never inside a backup.
data/ (galleries, showreel, front page, settings) and media/ (hero and
showreel images) — the operational data, roughly a few hundred kilobytes plus
whatever the local images weigh.
Two deliberate omissions:
config/config.php and config/credentials.php. A backup is uploaded to
the manage server and can be downloaded there, so it must not carry the S3
keys or the admin password hash. Keep one copy of those two files somewhere
safe by hand — they are short and they change almost never.There is no restore command. A backup is a ZIP: unpack it over data/ and
media/, and the installation is back.
Shared hosting rarely has dependable cron, so the backoffice drives the two recurring jobs itself — the same mechanism gallery archives already use:
| Job | When | Trigger |
|---|---|---|
| Backup | the newest scheduled backup is older than a week | any admin page load past that point |
| Heartbeat | hourly | the same |
An admin page load past the interval hands the work to manage-worker.php in
the background and returns immediately; the operator never waits on a backup.
If the host blocks outbound HTTP to itself — which also breaks archive
building — the job runs inline instead, after the page has been sent. The
current state is on Admin → Maintenance → Schedule.
Two things are deliberately not on that schedule:
Tuning: MANAGE_BACKUP_AUTO_INTERVAL_SECONDS in manage-client/config.php
(a week; 0 turns the backup schedule off) and manage.heartbeat_interval in
config/config.php (an hour).
If the host does offer cron, one line replaces all of the above:
cron.php. It is the single entry point for every background job — each gallery
archive that is due, the scheduled backup, the heartbeat — and one invocation
drains all of them rather than moving one slice forward. Two variants, in
scripts/manage-client.cron:
*/5 * * * * curl -s -m 300 "https://www.example.com/cron.php?key=YOUR_WORKER_KEY" >/dev/null
*/5 * * * * /usr/bin/php /var/www/html/foto-portfolio/cron.php --quiet
The key is in data/worker-key.json; over the shell there is no key, since
reaching the CLI already means shell access.
It is off until you turn it on: set 'cron' => ['enabled' => true] in
config/config.php. A copy of this site restored onto a staging host brings the
crontab with it and must not start rebuilding archives and uploading backups on
its own. A disabled cron.php says so on STDERR and exits non-zero, so cron
mails it once instead of failing silently.
Nothing is switched off in exchange. The web-driven flow above stays live and the
two may run at the same moment, because they share the locks that already exist:
data/archive.lock (one process per archive, taken and released per slice, so
cron never starves the worker chain or an admin's "Rebuild now"),
data/manage.lock (one backup), and data/cron.lock (one cron invocation, so a
gallery slower to archive than the cron interval cannot pile invocations up).
In practice the web fallback simply finds nothing left to do.
cron.max_seconds (240) bounds one invocation. It is a point to stop at rather
than a hard ceiling: a slice that has already started copying a photo runs to its
end, so keep it under the timeout of whatever calls it. Work left over is
reported and picked up by the next tick. The last run is shown on
Admin → Maintenance → Schedule, which is how you confirm cron is really
firing.
Updates are still never automatic, by either driver.
/admin/ → Maintenance → Deploy update, with Create a backup first left
ticked. From the shell it is manage-client.php check and then
manage-client.php update.
What that does, in order: downloads the release, verifies size and SHA-256,
copies every file over the installation (saving each replaced file to
data/manage/updates/), runs any migrations from migrations/, then runs
app/after-update.php.
What it deliberately does not do:
config/, data/, media/ or .git/.Afterwards the dashboard tells you whether the per-gallery data migration in Admin → Data migration is outstanding (see 5b, step 3) — that one is separate, and stays your decision.
If a migration fails, the update reports it loudly and the files are still deployed: fix the cause, then press Run migrations on the Maintenance page.
The installed version lives in app/version.php as APP_VERSION, and nothing
writes it at runtime: it changes when a release package copies that file over.
It is shown at the foot of every backoffice page, on the dashboard, and on the
Maintenance page, and it is what the manage server compares against to decide
whether an update is available.
The format is semantic versioning with a v prefix — vMAJOR.MINOR.PATCH,
currently v1.2.0. Both the client and the manage server reject anything
else, so there are no -beta or -rc suffixes: patch for fixes, minor for
features, major for a release that needs manual steps.
For whoever maintains the software rather than the site:
./scripts/create-release-zip.sh v1.3.0
It writes the version into app/version.php, packs every git-tracked file
except configuration and content, and prints the SHA-256. Upload the resulting
build/releases/foto-portfolio-v1.3.0.zip under Releases on the manage
server. Full reference: the client documentation,
06_UPDATE_PACKAGING — and migrations/README.md for changes that need a
migration.
Without the manage client — or when you would rather use FTP — the application is a plain file tree, so an update is an upload plus one click.
data/ and media/. They are the whole database — a few
hundred kilobytes plus the local images. Nothing below deletes anything, but
there is no undo either.config/,
data/ or media/ — those hold your configuration and content, and are
never overwritten by an update. New config keys are always optional and read
with defaults, so an existing config/config.php keeps working unchanged.
Leave manage-client/config.php in place too./admin/ → Migration and press Run migration.The dashboard shows a banner while anything is outstanding. The step is safe to run more than once and removes nothing: it brings each gallery's data file up to the current format and reports what it did per gallery. Skipping it is not fatal — old files keep being read correctly — but the site is only fully converted once it has run.
Existing ZIP archives are not invalidated by an update on its own, so no gallery starts a multi-gigabyte rebuild just because you deployed. Only an actual change to a gallery's photos or their arrangement does that.
php -S localhost:8080 router.php
router.php reproduces the .htaccess protection for the PHP built-in server
(which does not read .htaccess); it is only used locally.
Everything except real S3 traffic works without credentials; gallery pages render presigned URLs that simply won't resolve until real keys are configured.