Folio is a single-script PHP reading library for a web folder. It lists files uploaded over FTP, serves crawlable detail pages for each file, previews and prints PDFs and images, and lets an authenticated admin assign titles, descriptions, categories, and tags. No database is required.
| Author | MENJ · https://menj.blog |
| Repository | https://github.com/menj/folio |
| Licence | GPL-3.0-or-later |
- Directory listing with subfolder navigation and breadcrumbs
- Inline preview and printing for PDF, image, and Markdown files
- Audio and video play in the page through a transport styled to the active theme, with the web server delivering the bytes directly so seeking works. With JavaScript off, the browser's own player is used. An optional setting plays a folder's audio as a playlist, with a queue and auto-advance
- Fast thumbnails when the Imagick or GD extension is available: listings and hover cards send small cached copies instead of full-size scans, and TIFF, HEIC, and AVIF files get a viewable preview. Originals are never modified, and with no image extension Folio serves them as it always has
- A page-flip reader for PDFs, rendering real pages with Mozilla's pdf.js from
your own domain; keyboard, click-zone, and page-number navigation, and it
respects
prefers-reduced-motion - Markdown (.md) files rendered to formatted HTML by Parsedown, in safe mode
- Hover preview cards on the listing: image thumbnails, first-page PDF previews, and titled tiles for other formats; on desktop and hover-capable pointers only
- Responsive layout: the listing collapses from a table into stacked cards on narrow screens, so rows never scroll sideways
- Editable title and short description per file; the raw filename and extension stay out of sight
- One category and up to ten tags per file
- Browsable category archive pages with their own indexable URLs, gathering documents from every folder; tags filter the current view by chip
- Client-side search across titles, descriptions, categories, tags, and filenames; appears once a folder holds three or more files and composes with the chip filters
EXCLUDE_PATTERNSinconfig.phpto hide specific files or folders from every public surface, including direct URL access- Optional Standalone pages — About, FAQ, and three custom slots — edited in
the admin, stored privately, with
AboutPage,FAQPage(with parsed Question and Answer entities), andWebPagestructured data - Direct hotlinks to every file, copied to the clipboard in one click
- SEO layer: per-file detail pages with canonical URLs, Open Graph and Twitter Card tags, an XML sitemap with image extensions, a generated llms.txt map for AI crawlers, and optional clean URLs
- Crawler controls in the admin: indexability switch, sitemap and llms.txt toggles, robots.txt generator, sitemap preview, Bing ping, IndexNow key generation and one-click URL submission, and a clean-URL preflight that verifies mod_rewrite before letting the setting be enabled
- Focused schema.org JSON-LD: WebSite, optional publisher, breadcrumbs, CollectionPage and ItemList on archives, with detailed typed nodes on file pages
- Four colour schemes (Folio, Ledger, Garden, Night), remembered per visitor
- Admin managed in the browser: Settings, Accounts with multiple users and in-app password change, a documentation viewer, and a diagnostics page
- Hardened by default: CSRF tokens, namespaced login throttling, strict Content-Security-Policy, lazy hardened sessions, session revocation, symlink containment, atomic metadata storage, and active-file download controls
- Per-file PDF access control (
pdf_access: public / viewer / hidden), enforced by a signed-URL endpoint once a routing preflight on the Crawlers screen confirms it's actually in effect (Apache only) - Optional
transcript,document_type, andlanguagemetadata fields, with the transcript rendered server-side so restricted documents stay fully readable and indexable even when the original PDF is not - Dublin Core Terms alongside the existing Schema.org structured data
- Automatic blurred first-page previews for hidden PDFs, with a manual placeholder-image fallback where that isn't available
The interface is set as an open codex: the listing and the preview sit as two leaves divided by a gutter. Titles are set in a Garamond stack, while the apparatus — sizes, dates, labels, controls — is set in a sans face, after the oxblood-and-graphite house monograph style. Hairline rules carry the structure. Four colour schemes ship: Folio (oxblood on bone), Ledger (indigo on cool grey), Garden (green), and Night (a dark scheme for evening reading).
| Format | Extensions | Preview | Detail page | In sitemap | |
|---|---|---|---|---|---|
.pdf |
Embedded viewer, plus a page-flip reader | Yes | Yes | Yes | |
| Camera and scanner | .tif .tiff .heic .heif .avif |
Converted preview (needs Imagick) | Yes | Yes | Yes |
| Images | .png .jpg .jpeg .gif .webp .bmp |
Inline image | Yes | Yes | Yes, with image extensions |
| SVG | .svg |
Inline image | Yes | Yes | Yes, with image extensions |
| Markdown | .md |
Rendered to HTML | Yes | Yes, rendered into the page | Yes |
| Audio | .mp3 .m4a .aac .wav .flac .ogg .oga .opus .weba |
Themed player | No | Yes, plays in the page | Yes |
| Video | .mp4 .m4v .webm .ogv .mov |
Themed player | No | Yes, plays in the page | Yes |
| Plain text | .txt |
No | No | Yes, with a download link | Yes |
| Anything else | any | No | No | Yes, with a download link | No |
Files of any other type are listed, titled, tagged, and downloadable; they are simply served as attachments rather than displayed. SVG files are served under a sandboxing Content-Security-Policy, so any script embedded in an SVG cannot run. Markdown is rendered in Parsedown safe mode, so raw HTML inside a Markdown file is escaped rather than executed.
To add a format, add its extension and MIME type to the $mime_map array near
the top of index.php. Formats a browser cannot display will fall back to a
download link.
- PHP 8.4 or newer with JSON, password, random and
mbstringsupport - Apache or LiteSpeed using the supplied
.htaccesswithmod_mimeandmod_headers;mod_rewriteis needed only for optional clean URLs - Read permission for PHP on
uploads/and write permission ondata/
index.php Application (all server logic)
install.php One-page guided installer; delete after use
readme.md, changelog.md, security.md At the root, where conventions expect them
docs/ Installation, upgrade, and readme.txt guides
config-sample.php Settings template; copy to config.php
data/ Private accounts, settings, metadata, and install token
.gitignore Keeps configuration, runtime data, and uploads untracked
assets/css/style.css Stylesheet and colour schemes
assets/css/flipbook.css Styles for the PDF flip-view reader only
assets/js/app.js Listing behaviour: preview, print, editing, filtering
assets/js/view.js Detail page behaviour: printing
assets/js/media.js Themed audio and video transport; listing and detail
assets/js/admin.js Admin-only: delete/remove confirmations, rewrite preflight
assets/js/flipbook.js PDF flip-view reader; loaded only on that screen
assets/img/ favicon.svg, favicon.ico, apple-touch-icon.png
lib/parsedown/ Parsedown 1.8.0 (MIT), renders Markdown files
lib/pdfjs/ PDF.js (Apache 2.0), powers both PDF readers
lib/pdfjs/ Mozilla pdf.js 5.4.149 (Apache-2.0), with its licences
changelog.md Version history
.htaccess Apache rules, active as shipped
tests/ Isolated integration smoke test
robots.txt Neutral crawler template; upload to the DOMAIN ROOT
license.txt GNU General Public License v3
uploads/ Your published files; keep this directory present
uploads/.htaccess Hardening for the publicly served uploads folder
See docs/install.md for the complete procedure. In brief:
- Unpack the zip and upload the contents of
folio/. - Make
uploads/anddata/writable by the web server. - The shipped
.htaccessworks as-is on Apache or LiteSpeed. - Open
install.php. It createsdata/install-token.php. - Read that one-time token over FTP, enter it in the installer, and provide the
exact canonical
SITE_URLfor the Folio folder. - Complete the account and site fields, then delete
install.php. - Log in, run the admin-only diagnostics, and publish a customised
robots.txt.
Clean URLs can be enabled later; query-string URLs remain fully supported.
All uploads, renames, and deletions happen over FTP. The application never writes to your files; it only reads the folder — with one narrow exception: a hidden probe file, uploads/.folio-pdf-probe.pdf, that the PDF access control preflight (below) creates to test itself. It's never shown anywhere and safe to delete; Folio recreates it if needed.
Each PDF can be set to Public (default, unchanged from previous versions), Viewer, or Hidden in its inline editor:
- Viewer embeds the PDF through a short-lived signed link and removes the direct-link and flip-view download buttons. The file isn't reachable at its plain
uploads/URL. - Hidden serves no PDF bytes by any path — preview, flip view, print, or direct link. The detail page shows a notice and, where available, a redacted preview instead of the file.
Neither is enforced until you:
- Set
FOLIO_URL_SIGNING_KEYinconfig.php(generate it the same way asFOLIO_AUTH_PEPPER— never reuse that value here). - Confirm the preflight on the Crawlers screen, which proves that requests to a real PDF under
uploads/actually reach Folio instead of being served directly by the webserver.
Until both are done, every PDF behaves as Public regardless of what's set on it — Diagnostics and each file's editor say so plainly. This is deliberate: a restriction that silently doesn't work would be worse than no restriction at all. This feature requires Apache or LiteSpeed (see Requirements above); the routing preflight fails safe on any server that can't confirm the rewrite, so an unsupported server never falsely presents a restriction.
Restricting a PDF never affects its record page's own indexability: the sitemap, the page's robots meta tag, and llms.txt reference the record page exactly as they would for any other file. Add a transcript in the editor and it renders directly in the page's HTML, so the content stays fully readable — by people and by search/AI crawlers — even when the original file is not.
For Hidden PDFs specifically, Folio can generate a blurred first-page preview automatically if the server can render PDF pages (check Diagnostics). Where that isn't available, set placeholder_image in the editor to the relative path of any image already in uploads/ to use as a manual stand-in instead.
- Click Admin in the top bar and log in.
- Click Edit on any row. Fill in the title, description, category, and comma-separated tags, then save.
- Metadata is stored atomically in
data/metadata.json, keyed by relative path, with a last-known-good backup atdata/metadata.json.bak. Existinguploads/.sfm-meta.jsondata is read for migration. Renaming a file over FTP still changes its key, so re-enter the metadata after a rename.
The category field suggests categories already in use, which keeps the taxonomy
consistent. Categories are a real taxonomy: each one has its own archive page at
/category/<slug>/, gathering every document in that category from across all
folders, with its own title, meta description, canonical URL, breadcrumb, and
CollectionPage structured data. Category archives appear in the XML sitemap and
are linked from the chip bar on every listing, from each row, and from each file
page, which gives search engines a clean internal link graph.
Tags work differently on purpose: they appear only on the rows that carry them, not in the bar at the top, and clicking one filters the current view in the browser without loading a page. Reserve categories for the few durable divisions of the library, and tags for everything finer-grained.
A folder literally named category would collide with the archive route. Rename
it if you have one.
The Link button copies the safe delivery URL for the file. PDFs, raster
images, text, and Markdown use their real location in uploads/, for example
https://example.com/documents/uploads/paper.pdf, and are served directly by
the web server. SVG uses Folio's controlled delivery endpoint, while unsupported or active
formats use the same endpoint as forced downloads; none can become uncontrolled
same-origin executable content.
Legacy ?action=raw links redirect to the appropriate current URL.
The supplied Apache/LiteSpeed rules reject symlinks and hidden files,
block executable formats, sandbox SVG, force unknown or active formats to
download, and mark raw documents noindex so their Folio detail pages remain
the search target. Keep those rules in force. The PHP endpoint remains a safe
fallback for links generated by Folio when direct inline delivery is unsuitable.
A page slug includes the normalised filename and extension:
Acts 17 Reconsidered.pdf becomes acts-17-reconsidered-pdf. If two filenames
normalise to the same value, Folio appends a short stable hash, so every file
remains reachable. Old extensionless or extension-bearing URLs redirect to the
new canonical URL when they identify exactly one file.
Slugs derive from filenames rather than editable titles. Renaming a file over FTP therefore changes its URL.
Upload robots.txt to your domain root, for example https://example.com/robots.txt,
after editing the Sitemap: line to match your domain and folder. A robots.txt
placed inside a subfolder is ignored. If a robots.txt already exists at the root,
merge the Sitemap: line into it rather than replacing the file. The supplied
version opens the whole site to search engines and to AI crawlers such as GPTBot,
ClaudeBot, PerplexityBot, and CCBot.
Submit sitemap.xml in Google Search Console. Detail pages carry the ranking signals, and their quality depends on the titles and descriptions you write. Untitled files fall back to thin generated metadata.
Run ./tests/smoke.sh from the Folio root. The isolated test installation checks
routing, canonical URL trust, symlink containment, active-file delivery,
authentication, metadata integrity, session revocation, and sitemap caching.
Listing and category pages emit a focused graph containing WebSite, an
optional publisher, BreadcrumbList, CollectionPage, and a lightweight
ItemList. File pages add ItemPage and the file itself as ImageObject,
DigitalDocument, Article, or TextDigitalDocument.
Filesystem modification time is used only as dateModified; Folio does not
pretend it is the publication or upload date. Publisher nodes are omitted when
no publisher name is configured. Set identity values in config.php or the
Settings screen and validate the result with a schema validator.
File-page JSON-LD also mirrors Dublin Core Terms (dcterms:title,
dcterms:type, dcterms:subject, dcterms:date, dcterms:format,
dcterms:language, dcterms:modified) alongside the Schema.org fields,
additive to the same graph rather than a separate block.
Folio is a public library. Being clear about this up front saves disappointment later, because several reasonable-sounding expectations are outside what it does:
- Every document is public unless excluded. Anything in
uploads/is served to anyone who asks, and appears in the listing, the sitemap, and structured data.EXCLUDE_PATTERNSinconfig.phphides files and folders from every public surface, but that is a publishing decision, not an access control: an excluded file is simply treated as absent. - There is no per-document permission model. Folio cannot show one document to one visitor and hide it from another. If you need that, you need an access-controlled repository, not Folio.
- Every account has full administrative authority. Accounts exist so that several people can administer the library with their own passwords and so that one person's access can be revoked. There are no roles, no read-only administrators, and no per-folder delegation.
- Anyone with FTP access controls the library. FTP is the intended way to add and remove documents, so the FTP account is the real trust boundary.
- Metadata is intentionally shallow. A title, a description, one category, and up to ten tags. There are no custom fields, relationships, or workflow states, and no versioning of documents or their metadata.
- It is not a digital-asset manager. Folio never modifies the original file. It generates small, disposable derivative copies for faster viewing (thumbnails, format conversions, blurred previews) — all deleted and regenerated freely — but it does not watermark, edit, or version the original in any way, and the original is always what direct links and downloads give you.
| Screen | Where | Purpose |
|---|---|---|
| Library | index.php |
Edit titles, descriptions, categories, tags |
| Settings | ?action=settings |
Site name, description, publisher, language, Admin-link visibility |
| Analytics | ?action=analytics |
Matomo and GA4 configuration |
| Crawlers | ?action=crawlers |
Sitemap, llms.txt, indexability, robots.txt, sitemap preview, Bing ping, IndexNow, clean-URL preflight |
| Accounts | ?action=users |
Change your password, add, reset, delete accounts |
| Docs | ?action=docs |
Read the Readme, Upgrading guide, and Changelog |
| Pages | ?action=pages |
Optional standalone pages (About, FAQ, three custom slots) |
| Log in | ?action=login |
Direct sign-in page, works with the Admin link hidden |
| Diagnostics | ?action=diagnostics |
Environment, addressing, and configuration health |
Log in and click Settings in the top bar, or open
index.php?action=settings. From there you can rename the site and edit the
description, publisher identity, language, and the Admin-link visibility. The
change applies immediately across page titles, the header, the lang
attribute, and the structured data.
Saved settings are written to data/settings.php and take precedence over
config.php, which remains the fallback and still holds the settings that can
take the site down if misconfigured: SITE_URL, PRETTY_URLS, TRUST_PROXY_HEADERS, and UPLOADS_DIRNAME stay
file-only on purpose.
Log in and click Crawlers, or open index.php?action=crawlers. From there:
- Indexability. One switch marks every public page
noindex, nofollow, for a library that is not yet ready to be found. On by default. - XML sitemap. Toggle whether the sitemap is served; disabled, it returns 404 and the head link disappears.
- llms.txt. Folio generates a curated Markdown map of the library for AI
crawlers at
/llms.txt(or?action=llms), built live from your titles, descriptions, and categories, with an optional introduction paragraph you write on this screen. Toggle it off to return 404. It also returns 404 while the whole site is non-indexable. - robots.txt generator. The screen shows a robots.txt reflecting the settings above, ready to copy into the file at your domain root. Folio never writes outside its own folder, so that final step stays manual by design.
- Clean URLs with a real preflight check: click Test rewrite, and Folio
probes a fake pretty URL through your
.htaccess. Only if that probe succeeds is the Enable button revealed. It cannot silently take the site down. - Sitemap preview shows the URL count and the first few entries the sitemap will contain.
- Notify search engines: Folio offers no sitemap "ping" button, because
the anonymous ping endpoints no longer exist. Microsoft retired Bing's in
May 2022 (it answers
410 Gone) and Google retired its own in 2023. A button would report success while doing nothing. Use therobots.txtsitemap reference, Bing Webmaster Tools or Google Search Console for manual submission, or IndexNow below. - Change dates that mean something: each sitemap entry reports the later of the file's modification time and the last time its metadata changed, so retitling a document or rewriting its description tells search engines that page changed. Editing one document does not disturb any other entry. Folder and category entries take the newest date among their contents.
- Sitemap partitioning: a sitemap may contain at most 50,000 URLs. Larger
libraries are served as a sitemap index at
sitemap.xmlpointing tositemap-1.xml,sitemap-2.xml, and so on. Smaller libraries are served as a single file exactly as before, so most sites see no change. - IndexNow: generate a key, host it at the site root as
{key}.txtautomatically, and submit every URL in the library in one click. Compatible engines are Bing, Yandex, Naver, and others. The protocol accepts at most 10,000 URLs per request, so larger libraries are submitted in batches automatically; each batch is reported on separately, and a partial failure is never described as a complete success. Folio submits the whole library rather than only what changed.
If your host has the Imagick or GD PHP extension, Folio generates small cached copies of images and serves those in listings, hover cards, and detail pages. On a library of scanned documents this is the single largest speed difference available: hovering a row previously downloaded the entire original.
Imagick additionally reads TIFF, HEIC, HEIF, and AVIF — formats no browser displays — so scans and phone photos get a viewable preview instead of appearing as an unknown download. The original is untouched; the direct link and download always give you the real file.
Check which engine you have at index.php?action=diagnostics. If neither
extension is present, nothing breaks: Folio serves originals exactly as it did
before, and the Diagnostics row tells you what to ask your host to enable.
Derivatives are stored in data/thumbs/. The folder is disposable — delete it
whenever you like and it rebuilds on demand. Replacing a file over FTP
invalidates its derivatives automatically, so you never need to clear a cache
by hand.
Two behaviours are deliberate. Generated images have their metadata stripped,
so EXIF GPS coordinates from a phone photo are not republished in a public
thumbnail. And server-rendered PDF thumbnails are off by default; the in-browser
reader already previews PDFs. Set PDF_SERVER_PREVIEW to true in
config.php if you want them.
By default every PDF behaves as Public, whatever you set on it. Two things must be in place before Folio will enforce a restriction, because enforcement depends on your web server actually routing PDF requests through Folio. If it enforced without checking, it would claim a document was protected while anyone could still download the file directly — worse than not having the feature at all.
1. Add a signing key. Open ?action=crawlers. When no key is set, Folio
shows a freshly generated one, ready to copy:
define('FOLIO_URL_SIGNING_KEY', '…64 hex characters…');Copy the whole line into config.php and reload. A new key is offered on
every visit until the setting is in place, so use the one in front of you.
Folio does not write it into config.php for you deliberately. That file
stays one the application cannot modify, which is what stops any future flaw
from rewriting Folio's own configuration.
Prefer to generate it yourself? Any of these work:
php -r 'echo bin2hex(random_bytes(32)), "\n";'
openssl rand -hex 32
head -c 32 /dev/urandom | xxd -p -c 64Use a different value from FOLIO_AUTH_PEPPER. They protect different
things, and reusing one weakens both. Changing the signing key later
invalidates any signed link already shared.
2. Confirm the preflight, on the same screen. Folio requests a PDF and checks that the request reached it rather than being served straight off disk by the web server. Only when that passes does the Confirm button appear.
This needs Apache or LiteSpeed with mod_rewrite and the rule that ships in
.htaccess:
RewriteRule ^uploads/(.+\.pdf)$ index.php?action=raw&serve=1&file=$1 [L,QSA,NC]If you renamed the uploads folder, change uploads in that rule to match, or
restrictions silently will not apply. The preflight reports this.
3. Set access per document in its Edit panel: Public, Viewer (links are signed and expire), or Hidden.
Diagnostics tells you where you are at every stage, and while a key is set but the preflight is unconfirmed it names the specific files that are marked restricted but still behaving as public.
If the server already has certain command-line tools — often because another
application on the same host needs them — Folio finds them and puts them to
use. Nothing is required: with none installed, everything works as before.
index.php?action=diagnostics lists what was found, where, and what each one
enables.
| Tool | What Folio does with it |
|---|---|
ocrmypdf + tesseract |
Make scanned documents searchable |
pdftotext |
Pull text out of PDFs, cached for reuse |
pdfinfo |
Page counts and PDF facts |
pdftocairo or pdftoppm |
Render PDF page previews |
pngquant |
Smaller PNG derivatives |
unpaper |
Straighten crooked scans before OCR |
A scanned document is a picture of text: nothing can search it, and no crawler can read it. Where OCRmyPDF and Tesseract are installed, Folio can produce a searchable copy.
Your file is never modified. Folio reads it and writes a separate copy
under data/ocr/; that copy is what text is read from. Delete data/ocr/ at
any time and nothing is lost but the work.
A PDF that already contains text is reported as already searchable rather than being reprocessed, and pages inside a mixed document that already carry text are left untouched.
OCR is slow — seconds to minutes for a long document — so it runs from the admin when you ask for it, one document at a time, never during a visitor's page load.
Languages come from OCR_LANGUAGES, which defaults to English, Malay
(msa), and Arabic (ara). Only datasets actually installed are used;
Diagnostics names any that are missing so you know which Tesseract language
pack to ask your host for.
Every one of these is optional. Nothing here is required for Folio to run, and nothing breaks when a tool is absent — a feature is simply unavailable.
Folio looks in the usual system directories and, because a shared host will
not let you write to them, in this account's own home as well: ~/.local/bin,
~/bin, and any virtual environment such as ~/ocrmypdf-venv/bin. A tool
your host installed for your account only is found without configuration.
Diagnostics lists everything found and where it looked.
| Missing | Effect |
|---|---|
ocrmypdf |
OCR still works, using Tesseract and Poppler instead |
tesseract |
No OCR. Everything else is unaffected |
pdftotext |
No text extraction from PDFs |
pdftocairo and pdftoppm |
No PDF previews; the original file is served |
qpdf |
Single-page documents still OCR; multi-page ones explain why not |
pngquant |
Rendered PDF pages are simply larger |
| everything | Folio behaves exactly as it did before this feature existed |
A document's public address is permanent and independent of its file.
Renaming award_1997.pdf to scrabble_award.pdf over FTP, or moving it to a
different folder, does not change its URL.
That separation is deliberate:
FTP owns the files. Folio owns the addresses.
Folio never uploads, renames, moves, replaces, or deletes anything, and never creates folders. It only reads your library and maintains its own catalogue.
Open a document's Edit panel in the listing. The URL slug field sets the permanent address. Enter lowercase letters, numbers, and hyphens.
Changing it leaves a permanent 301 redirect from the old address, so existing links and search-engine results keep working. Change it again and both previous addresses redirect directly to the newest one — Folio never builds a chain of redirects.
A slug is refused if it is empty, is a reserved Folio route such as admin or
sitemap, is already used by another document, or is a previous address of
another document. Pasting a full web address is refused rather than silently
converted, since that is almost always a mistake.
Folio finds the file again by its contents.
- Rename or move the file however you like over FTP.
- Open
index.php?action=diagnostics. The catalogue row lists any document whose file has gone missing. - Run reconciliation. Folio matches each record to a file with identical contents and updates only the stored path.
The document keeps its URL, title, description, transcript, category, tags, and access settings. Nothing on disk is touched.
Open Catalogue in the admin bar to do this. It lists what has come adrift and offers both repairs.
Content matching only works while the bytes are unchanged. If a file was renamed and edited, its fingerprint no longer matches and Folio will not guess — attaching a document's history to the wrong file is worse than asking you to choose. The same applies when several files share identical contents.
Those cases appear in Diagnostics as a document whose file is missing alongside a file that is not yet catalogued, and you can relink them by hand. Relinking changes only which file the record points at.
Folders still organise browsing, and renaming one over FTP still changes the folder's browsing URL. They do not determine document addresses: a document's slug is a flat name, independent of where the file lives.
Only mbstring is genuinely required, and only for rendering Markdown.
Everything else Folio can use has a fallback, so a missing extension costs a
capability rather than breaking the site. ?action=diagnostics reports each
one and says what is lost when it is absent.
| Extension | What Folio does with it | Without it |
|---|---|---|
| mbstring | Correct handling of multi-byte text | Markdown rendering is disabled |
| Imagick | Thumbnails; converts TIFF, HEIC and AVIF for viewing; blurred previews | Falls back to GD |
| GD | Thumbnails for PNG, JPEG, GIF and WebP | No thumbnails; originals are served directly |
| fileinfo | Detects file types from content, not just the extension | Falls back to the filename extension |
| iconv | Transliterates accents when building URL slugs | Accents are stripped instead of converted |
| OPcache | Avoids recompiling Folio on every request | Works, but noticeably slower |
Folio does not use cURL, intl, ZIP, XML, EXIF, or any database extension. IndexNow uses PHP's own stream functions, sitemaps are built as strings, and there is no database. If your host offers a minimal PHP build, none of those are worth asking for.
The two that most affect a scanned-document library are Imagick — without it, hovering a row downloads the full-size original instead of a thumbnail — and OPcache, because Folio is one large file.
A scanned document is a picture of text. Nobody can search it, no crawler can read it, and copying a line out of it is impossible. OCR reads the picture and attaches a text layer, so the document becomes searchable while looking exactly the same.
Folio does this with OCRmyPDF and Tesseract when your server has them. Neither is required: without them everything else works and the OCR button simply does not appear.
Sign in and open index.php?action=diagnostics. Two rows matter:
- external utilities — lists every tool found and its full path.
- OCR — says whether OCR is ready, and in which languages.
If OCR is ready, skip to step 3.
On a cPanel or AlmaLinux server, this is usually:
dnf install tesseract tesseract-langpack-eng tesseract-langpack-msa \
tesseract-langpack-ara poppler-utils qpdf
OCRmyPDF is a Python program and is normally installed into a virtual
environment belonging to your account, because a shared host will not let you
write to /usr/bin:
python3 -m venv ~/ocrmypdf-venv
~/ocrmypdf-venv/bin/pip install ocrmypdf
Folio finds that automatically. It searches your account's home directory
as well as the system paths, including ~/.local/bin, ~/bin,
~/ocrmypdf-venv/bin, and cPanel's ~/virtualenv/<app>/<version>/bin. If a
tool lives somewhere unusual, name it directly in config.php:
define('TOOL_PATHS', [
'ocrmypdf' => '/home/youruser/ocrmypdf-venv/bin/ocrmypdf',
]);Nothing beyond the packages above is required.
Language packs are separate from Tesseract itself and are often missing even
when Tesseract is installed. Malay is msa and Arabic is ara. Diagnostics
names any that are absent, so you know exactly what to ask for.
By default Folio asks for English, Malay, and Arabic. Only the ones actually
installed are used. To change the list, in config.php:
define('OCR_LANGUAGES', ['eng', 'msa', 'ara']);Order matters a little: put the most likely language first.
In the listing, each PDF row shows an OCR button when you are signed in and OCR is available.
Click it. The button counts up while it works — expect anything from a few seconds for a one-page certificate to several minutes for a long document. When it finishes it reports how much text was found and stays marked, so you can see at a glance which documents you have already done.
If a PDF already contains text, Folio says Already searchable immediately rather than spending minutes reaching the same conclusion.
Your original file is never modified. Folio reads it and writes a separate
searchable copy into data/ocr/. That copy is what text is read from.
You can delete data/ocr/ at any time. Nothing is lost except the work, and
you can run OCR again. Replacing a file over FTP invalidates its OCR copy
automatically, because the cache is keyed to the file's size and modification
time.
- A poor scan gives poor text. Faint, skewed, or low-resolution originals
are hard to read. Installing
unpaperlets Folio straighten and clean pages before reading them, which helps noticeably with older photocopies. - Handwriting is not recognised. Tesseract reads printed text. A handwritten certificate will produce little or nothing.
- The wrong language gives nonsense. A Malay document read as English produces plausible-looking rubbish. Check the OCR row in Diagnostics lists the language you need.
- Long documents may hit a time limit.
OCR_TIMEOUTdefaults to 600 seconds. If a document is cut short, raise it inconfig.php.
Where OCR cannot help, the transcript field in the metadata editor lets you type or paste the text yourself. That is often the better answer for a short handwritten document.
Make a folder called branding/ beside index.php and put your icon in it:
branding/favicon.svg preferred
branding/favicon.png or this
branding/favicon.ico for older browsers
branding/apple-touch-icon.png for iOS home screens
Folio picks these up automatically — there is nothing to configure. They are
served both through the <link> tags and at the root paths browsers request
on their own, such as /favicon.ico, which is what tabs and bookmarks use.
Use branding/ rather than replacing the file inside assets/. assets/ is
release-owned, so an upgrade would overwrite whatever you put there and your
icon would silently revert. branding/ is never touched by an upgrade.
To point somewhere else instead, set SITE_ICON in config.php to a path
inside the installation or a full URL. Diagnostics reports which icon is in
use.
If a new icon does not appear immediately, it is almost always the browser cache — icons are cached far more aggressively than pages. Check in a private window before concluding it has not worked.
Folio publishes two sitemaps:
| Sitemap | Lists |
|---|---|
/sitemap.xml |
Record pages, categories, and standalone pages |
/sitemap-pdf.xml |
The PDF files themselves |
The second matters for a document library. Search engines index PDFs as pages
in their own right, so a scanned certificate can be found directly rather than
only through the page describing it. Both are announced in robots.txt.
Documents are served index, follow, so crawlers may index them and follow
the links inside them.
Every PDF in the library is listed, whatever its access setting. The access setting governs delivery, not discovery.
Files matching EXCLUDE_PATTERNS are the one exception, and not really an
exception at all: those are not part of the library. They return 404 on every
route, so a sitemap entry would point at nothing.
Click Name, Size or Date to sort; click again to reverse. An arrow marks the active column, and the headers work by keyboard.
Sorting uses the real values, not what is shown, so sizes order by bytes and dates order chronologically whatever form they were entered in. A document without its own date sorts after those that have one.
Search is the magnifier in the header, or press / from anywhere on the
page. Escape closes it and keeps the results.
Sorting and filtering are separate. The category chips and the search box choose which documents appear; sorting only changes their order, so the two compose. Your chosen order is remembered while you browse.
Scanners often write PDFs that store their page images with little or no compression, so a single certificate can arrive as tens of megabytes.
Each PDF row has a Compress button. It prepares a smaller copy with qpdf, losslessly — the structure is rewritten and the streams recompressed, and the images are left alone, so the result is the same document to any reader. On an unoptimised scan this can be dramatic: 42 MB down to 57 KB in testing.
Your file is not replaced. The copy downloads to you; put it in place over FTP if you want it, exactly as you would any other change to the library. Because the path stays the same, the document keeps its URL and metadata.
A copy is only offered when it saves at least 3% and still opens correctly. An already-efficient document says so rather than being duplicated for nothing.
Folio can optionally publish a few informational pages alongside the library: an About slot, a FAQ slot, and three general custom slots. All are disabled on a fresh install, so the site stays a pure library until you fill one in.
Log in and click Pages, or open index.php?action=pages. Each slot has an
enabled toggle, a title, an optional shorter menu label, and a Markdown body.
Content is written in Markdown; raw HTML in the body is escaped for safety.
Enabled pages appear in the header nav for public visitors and in the XML
sitemap when the site is indexable. URLs are /about/, /faq/, and
/p/<slug>/ under clean URLs, or ?page=<slot> otherwise. Disabled or unknown
slugs return a real 404.
Each page emits the correct schema.org type: AboutPage for About, FAQPage
for FAQ (with Question and Answer entities parsed from ## headings), and
WebPage for the custom slots. Pages are stored privately in data/pages.json
through the same atomic transaction as settings and metadata.
Each page has a URL slug field. It sets the page's address directly:
/bibliography/, not /p/page1/. Leave it empty and the page uses its slot
name.
Pages and documents share one address space, so a slug is refused if another
page, a document, or a Folio route already uses it. About and FAQ default to
/about/ and /faq/.
Changing a slug leaves the old address redirecting permanently, and the
original /p/slot/ form keeps working too, so nothing you have already linked
to breaks.
SHOW_ADMIN_LINK controls whether the Admin link appears in
the header for logged-out visitors.
define('SHOW_ADMIN_LINK', false);It is a checkbox on the Settings screen, or a constant in config.php. With it
off, the header shows nothing and the library reads as a public archive.
You can still sign in at any time through the direct login page:
index.php?action=login
That page exists whether the link is shown or not, so hiding it cannot lock you out. Bookmark it before switching the link off.
Treat this as tidiness rather than security. It keeps the login out of sight of casual visitors and of scanners that follow visible links, but anyone who knows the URL can still reach the form. The real protections are the password, the lockout after eight failed attempts, and HTTPS.
Log in and click Accounts in the top bar, or open index.php?action=users.
From there you can change your own password, add accounts, reset another
account holder's password, and delete accounts.
Until the first change is made there, the single account in config.php is
used. The first change writes all accounts to data/users.php, after which
config.php credentials are no longer consulted. That file is a PHP file that
returns an array, so even if the web server were misconfigured and served it
directly, it would execute and output nothing. The shipped data/.htaccess
denies access to the folder outright.
Rules the screen enforces: passwords are at least 10 characters, usernames are 3 to 32 characters of letters, digits, dot, dash, or underscore, changing your own password requires your current one, you cannot delete the account you are signed in with, and the last remaining account cannot be deleted. Every account has the same full access; there are no roles. Password resets and account deletions revoke previously issued sessions through an account version check.
For this to work, the web server needs write access to data/. The Accounts
screen says so plainly if it does not.
Before any account exists, credentials come from config.php and you need a
bcrypt hash. Any of these produces one:
With shell or cPanel Terminal.
php -r "echo password_hash('new-password', PASSWORD_DEFAULT), PHP_EOL;"
With a temporary file. Create hash.php containing
<?php echo password_hash('new-password', PASSWORD_DEFAULT);, load it in a
browser, copy the output, then delete it.
A valid hash begins with $2y$ and is exactly 60 characters. Paste the whole
thing, quotes included, over the existing ADMIN_PASSWORD_HASH line.
Once accounts are managed through the Accounts screen, changing or resetting a
password revokes older sessions automatically. For a hand-edited fallback
account in config.php, changing the cookie name forces a fresh login.
Credentials are kept out of the code so the repository can be public:
config.phpholds the username and password hash and is listed in.gitignore. Onlyconfig-sample.php, which contains placeholders, is committed.uploads/is ignored except for its.htaccess, so documents are not published with the code..htaccessitself is ignored, since it is installation-specific.
There is no default password. With no config.php present, ADMIN_PASSWORD_HASH
falls back to CHANGE_ME, which disables the login entirely rather than
accepting anything. A fresh clone is therefore read-only until someone supplies
their own hash.
If a hash was ever committed, remember that removing it in a later commit does not remove it from the repository history. Rotate the password.
See docs/upgrading.md for the full procedure, also readable from the admin under
Docs. In brief:
To upgrade: back up uploads/, data/, config.php, and .htaccess.
Overwrite index.php, assets/, lib/, and the documentation files with the
new release. Leave your config.php, data/, uploads/, and .htaccess
alone. Run the diagnostics, hard-refresh, and verify.
To remove: download uploads/ first, then delete the installation folder.
Folio writes nothing outside it: no database, no configuration elsewhere on the
server. Remove the Sitemap: line from your root robots.txt afterwards.
Two optional constants add defence-in-depth. Generate random values locally on
the server, for example with php -r "echo bin2hex(random_bytes(32)), PHP_EOL;",
and place them in config.php.
-
FOLIO_AUTH_PEPPERis HMAC-mixed into every stored password hash. Ifdata/users.phpever leaks in isolation — through a backup exposure, for example — the hashes remain uncrackable without the pepper sitting inconfig.php. Never change this value once accounts exist: it invalidates every hash and every account must reset their password. Existing hashes without a pepper migrate transparently on next successful login. -
FOLIO_COOKIE_NAMEreplaces PHP's defaultPHPSESSIDcookie name with a site-specific one, so sessions cannot collide with other PHP applications on the same domain. Safe to change at any time; the worst it does is log everyone out.
Both are optional. Without them, Folio uses bcrypt as before and the default session cookie. Adding them later is safe: peppering migrates hashes lazily, so no reset is required as long as the pepper never changes afterwards.
docs/upgrading.md carries a roadmap: what is planned
next, what is being considered, and — deliberately — what has been declined
and why. It also states the principles that will not change, chief among them
that Folio never modifies your files and will never gain upload, rename, move,
or delete controls.
Read it before planning around a feature. Something listed as declined is not waiting for a good enough reason.
security.md records what Folio actually enforces — output
encoding, path containment, file delivery, authentication and its limits,
request-forgery coverage, response headers, and canonical addressing — along
with the deployment steps Folio cannot enforce itself, such as HTTPS, file
permissions, keeping data/ unreadable from the web, and removing
install.php after setup. It also states the known weaknesses honestly, and
explains what counts as a vulnerability and how to report one privately.
tests/readme.md documents the regression suite: how to
run it, what each group proves, the security payloads it replays, and — just
as importantly — what it does not cover, so a passing run is not
over-read.
Themes are a set of seven CSS custom properties on the html element. There is
no build step, no preprocessor, and no per-theme stylesheet: everything in
assets/css/style.css refers to these tokens, so defining them for a new
data-theme value restyles the entire application.
html[data-theme="folio"] {
--paper: #f3f2ee; /* page background, the surface behind everything */
--leaf: #fbfaf8; /* raised surfaces: the document itself, cards */
--ink: #26262a; /* body text */
--quiet: #75727a; /* apparatus: metadata, captions, labels */
--accent: #6e1d1d; /* links, active states, primary buttons */
--rule: #ddd9d2; /* hairlines, borders, dividers */
--gutter: rgba(38, 38, 42, 0.09); /* the seam between the two leaves */
}Alongside the colours, three shared tokens set the shape and depth of the
interface: --radius-sm, --radius and --radius-lg for chips, controls and
panels, and --shadow, --shadow-lift and --sheen for elevation. The radii
are theme-independent; the shadows are not, because depth that reads correctly
on bone reads as a smudge on near-black, so each theme defines its own.
Four themes ship: folio (oxblood on bone), ledger (indigo on cool grey),
garden (green on warm white), and night (dark). The selector renders as the
row of colour dots in the header; the choice is stored client-side and applied
to the data-theme attribute.
Append a block to assets/css/style.css defining all seven tokens, then add a
swatch to the theme selector. Nothing else needs to change. Two constraints
worth respecting:
--accentis used for both text on--leafand as a button background with--leafas its text colour, so it needs contrast in both directions.--quietmust stay legible against--paper; it carries all metadata.
The listing is a two-leaf codex: documents on the left, preview on the right,
divided by --gutter. Below 900 pixels the preview pane collapses and the
hover card is disabled outright, since hover has no meaning on touch.
Typography is a Garamond stack for text and a sans stack for apparatus — metadata, labels, buttons, chips. That split is the main thing keeping the interface from reading as a generic admin panel, so new UI should follow it: anything the reader reads is serif, anything the interface says about itself is sans.
assets/css/flipbook.css loads only on the flip reader. Keep it that way: the
reader is the one screen with a relaxed Content-Security-Policy, and confining
both its CSS and its policy to that route keeps the rest of the site strict.
Markdown rendering uses Parsedown 1.8.0 by Emanuil Rusev, distributed under
the MIT licence. The library lives in lib/ with its licence text. It runs in
safe mode, so raw HTML inside a Markdown file is escaped rather than executed.
The PDF flip-view reader uses Mozilla's pdf.js 5.4.149, distributed under the
Apache 2.0 licence, vendored in lib/pdfjs/ with its licence text and the
licences of its bundled WebAssembly decoders. It is served from your own
domain: nothing is fetched from a CDN and no document is ever sent to a
third-party viewer. It loads only when someone opens the flip reader, so
visitors who never use it never download it.
Two deliberate limitations are worth knowing:
- The character maps and standard font files that pdf.js can optionally use are not bundled, to keep the package small. Almost every PDF embeds its own fonts and is unaffected. A PDF that relies on external CJK character maps may show substituted glyphs in the flip reader; the Download button always gives the untouched original.
- The reader shows one page at a time rather than a two-page spread.
Folio is free software under the GNU General Public License, version 3 or
later. You may use, study, modify, and redistribute it; derivative works
must carry the same licence. The full text is in license.txt.
Copyright (C) 2026 Mohd Elfie Nieshaem Juferi
SPDX-License-Identifier: GPL-3.0-or-later
Bundled components keep their own, GPL-3-compatible licences: Parsedown (MIT), Mozilla pdf.js (Apache-2.0), and the OpenJPEG and QCMS WebAssembly decoders.
Apache-2.0 is compatible with GPL version 3 but not with version 2, which is why Folio is version 3 or later rather than version 2.
1.25.0. Single-file application with separated CSS and JS assets.