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
- Descriptions for folders, shown under the folder name and edited inline by an admin, kept in their own file apart from the document metadata
- 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 or video as a playlist, with a queue and auto-advance, on a standalone page that carries the same chrome and colour scheme as the rest of the site
- 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, a short silent looping moving clip for video (ffmpeg required; falling back to a static frame if the clip itself cannot be built, e.g. a large source file; offered for public video, and for any tier to the admin), optionally with a face or other detail blurred out via drawn redaction boxes, 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
- A stated relationship between each record and the person the archive is about, from a fixed vocabulary — authored, collected, translated, evidence, client work, or simply archived — so the library never claims authorship of work it merely holds. Inferred from the folder where you make no choice, and falling back to a claim that asserts nothing
- Reusable organisation and book entities, declared once and referenced by identifier everywhere, so a publisher named by six documents is one publisher rather than six unrelated strings
- Browsable category archive pages with their own indexable URLs, gathering documents from every folder; tags filter the current view by chip. A category or tag chip also shows or hides subfolders by whether they contain a match at any depth, so filtering does something useful even in a folder holding only other folders, not files directly
- 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, Contact, and custom slots — edited in
the admin, stored privately, with
AboutPage,FAQPage(with parsed Question and Answer entities),ContactPage, andWebPagestructured data - A contact form on the Contact page: messages are emailed to your publisher address, which visitors never see. Optional attachments are forwarded with the email and deleted immediately, never entering the library. Honeypot, timing, and rate-limit spam protection, and a test-email button to confirm delivery works
- Direct hotlinks to every file, copied to the clipboard in one click
- Share menu on every file's detail page: copy-link always available; X, Reddit, WhatsApp, and email share links appear only when the page itself is public and indexable, so a restricted or hidden file never gets a public share invitation, just the copy-link fallback for handing the URL to someone directly
- Verified social profiles (
SITE_SAMEAS) shown as recolourable icons in the site footer, the same list already exposed inidentity.jsonandvcard.vcf— nothing to configure twice - 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, an identity.json document, a downloadable vcard.vcf built from it, a YAML library index, and optional clean URLs
- Crawler controls in the admin: indexability switch, sitemap, llms.txt, JSON identity.json, vcard.vcf, and YAML index 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 and applied on every page, including the standalone playlist pages
- 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 and blurred frame previews for restricted/hidden video, both with a manual placeholder-image fallback where the server can't build one
- Restricted and hidden video shows a sealed-archive notice — a keyhole icon, a two-tier label, and either the blurred preview above or a plain archival texture — with an outlined status badge next to the title
- Verified social profiles (
SITE_SAMEAS) render as recolourable inline-SVG icons in the footer,identity.json, andvcard.vcffrom one shared map, so the three surfaces can't drift out of sync
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 and random support;
mbstringis optional and only affects Markdown rendering - 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, entities, 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, share menu
assets/js/media.js Themed audio and video transport, plus colour-scheme
switching on the standalone playlist pages; 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/js/library-view.js Renders sitemap.html in the browser from library.yaml
assets/img/ favicon.svg, favicon.ico, apple-touch-icon.png
assets/img/social/ Single-colour profile icons, recoloured by the active theme
assets/img/ui/ Single-colour status and function glyphs
lib/parsedown/ Parsedown 1.8.0 (MIT), renders Markdown files
lib/pdfjs/ Mozilla pdf.js 5.4.149 (Apache-2.0), powers both PDF readers
lib/js-yaml/ js-yaml 5.3.0 (MIT), renders sitemap.html in the browser
lib/vendor/ Google API client and dependencies (MIT/Apache 2.0), used by Google Indexing API
lib/video.php Folio's own: blurred previews for restricted video
lib/redirects.php Folio's own: the Redirect Manager and 404 Monitor
lib/contact.php Folio's own: the contact form and its mail delivery
changelog.md Version history
.htaccess Apache rules, active as shipped
tests/ Isolated integration smoke test
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 and run the admin-only diagnostics. robots.txt is generated automatically — see "Getting documents found" below if Folio lives in a subfolder rather than at your domain root.
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), Restricted, or Hidden in its inline editor:
- Restricted keeps the detail page listed and indexable, but the public sees a "restricted" notice in place of the embedded document; the file is withheld.
- Hidden additionally removes the page from the folder listing, while keeping it findable through search; the public reaches it only that way and meets the same notice.
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 is designed to keep its record page findable while gating the file itself — the "indexed page, gated file" split. A Restricted PDF keeps a public, crawlable record page: it stays in the public listing and the page sitemap, and its robots meta tag and llms.txt reference it exactly as for any other file, so search engines list it and a searcher can find it. Clicking through shows the detail page with a "restricted" notice where the document would be; the file is withheld. The file's own bytes are never advertised: a Restricted (or Hidden) PDF is left out of the PDF file sitemap, and its contentUrl and download actions are omitted from the structured data. A Hidden PDF goes further and is removed from the folder listing (while the page stays indexable, so it can still be found through search). In both cases, adding a transcript in the editor renders it 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. Video uses a lighter model of its own. Its Restricted and Hidden states delist a clip: the player and the file's URL are not shown to the public, who see a notice instead, yet the file itself is served directly by the webserver and stays reachable at its direct URL. The tier hides a clip from view rather than withholding its bytes, so a Restricted or Hidden video should be treated as reachable by anyone who has or can construct its URL. This is intentional, an accepted trade-off for direct-serve playback speed. A video that must be private should be kept out of the library, for example with EXCLUDE_PATTERNS, rather than relying on the tier.
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.
A Restricted or Hidden video gets the same treatment: instead of a blank notice, it shows a keyhole icon and a two-tier "This video is restricted / Private archive" label over a blurred frame, generated automatically if the server has both ffmpeg and Imagick (check Diagnostics). The frame is downscaled hard before it's blurred — the same irreversible-loss technique the PDF preview uses — so the result is safe to serve publicly even though the original clip is not. placeholder_image is the same field for both: set on a restricted video, it's used as the manual stand-in exactly like it is for a hidden PDF. Where neither an auto-generated nor a manual preview is available, the notice falls back to a plain archival texture instead of an image.
- 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 have their own sitemap at
sitemap-categories.xml, announced in robots.txt and llms.txt and kept
separate from the main sitemap, and they 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.
robots.txt is generated automatically from the Crawlers settings —
reachable at your domain root once Folio is installed there, or via one
rewrite rule at your domain root's own config if Folio lives in a
subfolder (see "Getting documents found" below). It opens the whole site
to search engines and to AI crawlers such as GPTBot, ClaudeBot,
PerplexityBot, and CCBot by default, following whatever the Crawlers
screen's indexability toggle and sitemap settings say.
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 typed by kind:
ImageObject, DigitalDocument, Article, TextDigitalDocument,
VideoObject, AudioObject, or MediaObject for anything else. A video's
video_type (documentary, interview, and so on) becomes both genre and
additionalType on its VideoObject node when set.
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.
An archive holds two very different kinds of thing: work its subject created, and work by other people that the subject kept. A library that cannot tell them apart ends up claiming authorship of everything it contains.
Every record therefore states how it relates to the archive's subject, chosen from a fixed vocabulary on the document's own metadata form:
| Relation | Means | Emits |
|---|---|---|
| Authored by | The subject wrote the original work | author, creator |
| About | The record is chiefly about the subject | about |
| Evidence for | Documents or substantiates a claim | about, subjectOf |
| Published by | Published by a named organisation | publisher |
| Translated by | The subject made the translation | translator |
| Collected by | Someone else's work, kept as a source | contributor (Archivist) |
| Appeared in | First appeared in a named publication | isPartOf |
| Performed by | The subject performed or recited it | performer |
| Client work | Professional work for a client | creator, about |
| Issued by | A certificate issued by an institution | creator, publisher |
| Mentions | Refers to the subject in passing | mentions |
| Archived by | Deliberately kept. Claims nothing more | contributor (Archive curator) |
Where you make no choice, Folio infers one from the folder the file sits in —
works/ suggests authorship, sources/ suggests collection, translations/
suggests translation, and so on, with the longest matching prefix winning. Where
the path suggests nothing, it falls back to Archived by, which claims only
that the item is deliberately kept, never that the subject wrote it. Your own
choice always overrides inference, and the form labels an inferred value as
inferred so a guess does not quietly acquire the authority of a decision.
Published by and Issued by name a second party, so they stay silent until you name an organisation on the same form. Naming no one is deliberate: pointing them at the subject would assert that an author published his own book.
Publishers, universities and the subject's own books are declared once on the Entities screen and referenced by identifier everywhere else, so six documents naming one publisher describe one publisher rather than six unrelated strings. See Entities below.
A book is not the same thing as the PDF of it. Buddhism: A Muslim Primer was written once and published by Jahabersa in 2005; the file in the library is one copy of that work, made at some later date. Collapsing the two makes the file's modification date look like a publication date and loses the publisher altogether.
Where a file is a copy of a declared book, say so with Copy of on the document's metadata form. Three identifiers then describe three genuinely different things:
| Identifier | What it is |
|---|---|
…#book |
the work — author, publisher, year, language, ISBN, OCLC |
…#page |
the archive page where a reader finds it |
…#file |
the file itself — format, size, modification date |
The file points at the work with exampleOfWork and inherits its publication
year and language, while keeping its own modification date. Everything else
about the work stays on the book, stated once.
alumniOf and affiliation on the subject upgrade from plain strings to entity
references automatically when a declared organisation has the same name. A name
matching nothing stays a plain string, so nothing you have already typed stops
working.
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, Contact, and pages you add), plus contact-form status and the test-email button |
| Redirects | ?action=redirects |
Explicit 301/302 rules for old addresses, and the 404 Monitor |
| 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.
The screen is grouped into four tabs — Site, Publisher, AI Policy and Advanced — so around thirty fields are not one long scroll. Without JavaScript every panel stays visible and the page reads as labelled sections, so nothing is ever hidden from a text browser or a page search.
Three Publisher settings are worth calling out because they are easy to misread:
- Canonical identity ID pins the identifier used for the person or organisation the library is about. Leave it empty and the identifier is derived from this library's own address, which is right when the library and the subject's main page are the same site. Set it only when this library is a satellite archive about someone whose canonical page lives on another domain, so the two sites do not describe the same person as two different entities.
- Portrait or logo is one image reused across the structured data, the
About page and
vcard.vcf. Left empty it falls back to the site icon, which is what Folio used before this setting existed — a favicon makes a poor portrait, so set this if you publish a person. - Contact type and Contact languages label the published contact address. The defaults reproduce what earlier versions emitted.
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. Follows the llms.txt Specification (v1.7.0): aLang:header after the H1, a# Contactsection built from whichever publisher email, phone, or URL you have configured (genuinely omitted, not filled with placeholder text, when none are set — Diagnostics flags this as informational rather than an error), and the specification attribution as a closing footer. Toggle it off to return 404. It also returns 404 while the whole site is non-indexable.Each category lists at most
LLMS_MAX_PER_SECTIONdocuments (default 30); beyond that it names how many more exist and points tolibrary.yamlfor the complete list. llms.txt is meant to orient a reader, not enumerate the whole library — that is library.yaml's job, and it has no cap. Set the constant to0inconfig.phpto list everything with no limit. -
identity.json. A Schema.org identity document at
/identity.json(or?action=identity) describing who the site is and who it is about: aPerson(the subject the library documents) and theWebSiteitself, in a linked@graph. This is the "who and what is this site" facet for AI systems and search engines, distinct from the sitemap and YAML index (what the library contains) and llms.txt (a reading map). It cross-references its siblings and is cross-referenced by them. Toggle it off to return 404.The Person node is bare by default — name, URL, the library's own description as a fallback — until
PUBLISHER_BIO,PUBLISHER_OCCUPATION,PUBLISHER_ALT_NAMES,PUBLISHER_NATIONALITY,PUBLISHER_ALUMNI_OF, andPUBLISHER_AFFILIATIONare set inconfig.php, each rendering only when filled in.PUBLISHER_BIOin particular replaces the library's own description as the Person's, since without it the one sentence identity.json had to say about its subject ended up describing the collection rather than the person.PUBLISHER_RELATED_SITE_URL(and an optionalPUBLISHER_RELATED_SITE_LABEL) names a second site about the same person — a blog alongside this library, say — explicitly, rather than leaving it for a reader to infer from an unlabelledsameAsentry. Every document's ownauthor/publisherlink, and the WebSite'spublisherlink, resolve to the exact same@idas this Person node, so anything that finds one of those references and then finds identity.json separately can tell they describe the same entity, rather than two unlinked records that merely happen to share a name. -
vcard.vcf. A downloadable vCard (RFC 2426, version 3.0) at
/vcard.vcf(or?action=vcard) for identity.json's subject, so a visitor or a contacts app can save the site's publisher as a contact. Built from the publisher name, URL, description, icon (embedded), and same-as profiles (oneX-SOCIALPROFILEper network) already declared for identity.json, plus a handful of optional fields available only to the vCard — nickname, email, phone, country — each rendered only when set in config.php. Linked from the footer alongside the other index formats. Requires identity.json to be enabled, and has its own toggle on top of that; off, it returns 404. -
library.yaml AI usage policy. The YAML library index carries a top-level
permissions:block declaring what AI systems may do with the content — quote, summarise, train, commercial use — plus an optional note, all set on the Settings screen. It states terms; it does not enforce them (robots.txt and the access gates do that). -
Footer link order. The Crawlers screen also controls which discovery-file links (robots.txt, llms.txt, YAML, vCard, JSON, HTML, XML) appear in the site footer and in what order, independent of each link's own enable/disable toggle above — removing one from the footer only hides it there. Default order: robots.txt, llms.txt, YAML, vCard, JSON, HTML, XML.
-
robots.txt. Generated automatically, reflecting the settings above. Served live at your domain root once Folio is installed there; a subfolder install needs one rewrite rule at the domain root's own config, shown filled in with the real path on this screen — see "Getting documents found" above for the exact line.
-
AI crawler tracker. A panel on the same screen logs every request to the seven discovery files above (robots.txt, llms.txt, library.yaml, vcard.vcf, identity.json, sitemap.html, sitemap.xml) from a bot in Folio's own registry — GPTBot, ClaudeBot, PerplexityBot, and the rest — to
data/crawler-log.jsonl. Independent of the AI permission toggles above: those state a policy, this records who actually showed up, including a bot that ignores it. No raw user-agent string or ordinary visitor is ever logged, only{timestamp, bot, route}.Three tabs:
- Overview — 30-day totals, a per-file hit count fixed in the ROBOTS, LLMS, YAML, vCard, JSON, HTML, XML order (never re-sorted by count), a per-bot breakdown (operator, purpose, hits, last seen, top file), and the on/off toggle plus retention (7–365 days, default 90).
- Recent hits — the last 50 hits with an Export CSV button and a Clear log button (confirmed before it runs).
- Known bots — the full registry for reference.
Retention is enforced opportunistically on write rather than by a cron job Folio does not have: roughly 1 in 200 logged hits triggers a prune of entries past the retention window.
-
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, Restricted (notice shown, file withheld), 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 |
ffmpeg |
A short moving preview clip on video hover, plus its static poster frame; also the frame source for a restricted video's blurred preview (with Imagick) |
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 |
ffmpeg |
No hover/listing preview for video at all — clip or frame; the play glyph shows instead. A restricted video's card also falls back to a plain archival texture instead of a blurred preview |
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. If a record is one you no longer want — its file is gone for good and you don't intend to replace it — the Forget button on that row deletes the catalogue entry. Only records whose file is genuinely missing can be forgotten, and no file on disk is ever removed.
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 for hidden PDFs and restricted video | 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 four sitemaps:
| Sitemap | Lists |
|---|---|
/sitemap.xml |
Record pages and standalone pages |
/sitemap-pdf.xml |
The public PDF files themselves |
/sitemap-video.xml |
The public video files themselves, with video: title, description, thumbnail, and publication date |
/sitemap-categories.xml |
The category archive pages |
The PDF and video sitemaps matter 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; a video sitemap
gives Google a title, description, and thumbnail for each video without
waiting on a crawl and render of the page it lives on. The video sitemap is
empty while the video-routing guard (Crawlers screen) is on, since its
content_loc would need to be a signed URL that could expire before the
sitemap is next crawled. All four 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.
Generated automatically, reflecting the settings above — there is no file
to edit or upload. If Folio is installed at your domain root, /robots.txt
resolves through it directly once .htaccess is in place. If Folio lives
in a subfolder, /robots.txt at the true domain root is outside anything
Folio's own .htaccess can reach, so add one rewrite rule to your domain
root's own config — the Crawlers screen shows it with your actual path
filled in:
RewriteRule ^robots\.txt$ /your-folio-folder/index.php?action=robots [L]
Unlike the sitemaps above, robots.txt never returns 404: it is what
announces non-indexability in the first place (Disallow: / instead of
Allow: /), so it always has to respond.
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. A category or tag chip also narrows the subfolders shown, to the ones containing a match somewhere below them — text search narrows the files shown, but not the folder list, since matching it would mean checking every descendant file's title and description on every keystroke rather than a fixed list already in hand.
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.
Log in and click Entities, or open index.php?action=entities. This is
where you declare the organisations and works the archive refers to more than
once, so each gets a stable identifier that every record can point at.
Organisations are publishers, universities, institutions — anything that issues or publishes. Each takes a name, a type (organisation, college or university, government body, NGO, publisher), an optional website, and optional authority links such as a Wikidata record.
Books are works the subject authored. Each takes a title, a publisher, a publication date, a language, and optional ISBN, OCLC and page values. Page on this site is the address of the book's own page in the library, if it has one; leave it empty for a book with no page here. Type the publisher's name directly; existing organisations are offered as suggestions, and a name that is not one yet is created as an organisation when you save, so you never enter it twice.
Two details matter more than they look:
- The publisher is a separate organisation, never the author. Leave it as Not stated rather than implying a work was self-published. A book published by an external press is not self-published, and the structured data should not say it was.
- Language belongs to the book, not to its publisher. A press can and does publish in more than one language, so this is never inherited.
Identifiers are derived from the name — "Langgam Fikir" becomes
…/#langgam-fikir — and shown under each entry once saved. To remove an entry,
tick Delete on save on it and save. Removing an organisation that a book or
document still refers to leaves that reference silent rather than broken, and
you can add it back at any time.
Entities appear in identity.json and on the records that reference them. An
archive with none declared emits exactly what it did before.
Folio can optionally publish informational pages alongside the library: an About slot, a FAQ slot, a Contact slot that carries a working contact form, and as many general custom slots as you add. 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/, /contact/,
and /<slug>/ for pages you add, 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),
ContactPage for Contact, and WebPage for pages you add. 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.
Put a WebVTT file next to a media file over FTP, with the same name —
interview.vtt beside interview.mp4 — and a captions track appears on the
player. There is nothing to set in the admin, and the caption file does not
show up in your library as a document of its own; it belongs to the media file
beside it.
WebVTT is the only format browsers accept for this, so a .srt will not work.
Most transcription tools export WebVTT directly, and converting is a one-step
job in any subtitle editor.
A .vtt with no matching media file is treated as an ordinary document and
stays listed, since hiding a file with nothing to belong to would only make it
unreachable.
Everything you type into Folio — titles, descriptions, categories, tags,
dates, access settings, redaction regions — lives in one file,
data/metadata.json. Everything else Folio keeps is derived: thumbnails,
extracted text, previews and OCR results all rebuild themselves if lost. This
one does not. Your documents are safe on FTP; the catalogue describing them
is the part with no second copy.
Log in, click Catalogue, and use Download a copy. The file is named for your site and the date. Keep it somewhere other than the server it came from — a backup living on the machine it protects is not a backup.
To restore, put the file back as data/metadata.json over FTP. It is the
same file, unmodified, so nothing needs converting and no import step exists
to go wrong.
Enabling the Contact page under Pages puts a working contact form beneath whatever you write there. A visitor supplies their name, email, subject and message, and optionally attaches a file or two; you receive it as an ordinary email and can reply to it directly.
Messages go to your publisher email, the same address vcard.vcf and
identity.json already use. There is deliberately no second setting for it: a
site with two different owner addresses is a site where one of them is quietly
wrong. Visitors never see the address. It is read on the server at the moment
the message is built and appears nowhere in the page, so there is no way for a
request to redirect a message somewhere else.
The email is sent as your site, with the visitor's address as the reply address. Sending it as the visitor would look like forgery to most mail providers and land it in spam.
By default this goes through PHP's built-in mail(), which only ever
confirms a message reached the server's local mail queue — never that it
actually left the server or was accepted anywhere. Most modern hosts, cloud
VPS providers especially, block outbound port 25 by default, so that handoff
quietly succeeds while the message goes nowhere and nothing bounces back to
say so. If a test email is not arriving and is not in spam either, this is
almost always why.
Configure SMTP under Settings → Advanced to send authenticated mail on
port 587 instead. A real mail provider actually confirms delivery, and when
something does go wrong it reports the server's own reason rather than a bare
failure. Leave the SMTP host empty to keep using mail(), unchanged from
before this setting existed. The password is stored in plain text in
data/settings.php, the same way any application that sends authenticated
SMTP has to store it — it has to be readable to be used, unlike a login
password, which never does.
Before relying on the form, click Send a test email on the Pages screen. It sends only to your own address and confirms delivery works. If it is greyed out, the text beside it says why — either no publisher email is set, or the host provides no mail function. Diagnostics reports the same. The first test may land in spam, which is normal for a new sender rather than a fault.
Attachments are optional: up to three files, 5 MB each and 10 MB in total
by default, and never more than your server itself accepts. Accepted types are
an allowlist of documents, images and archives, and a file's actual content
must match what its extension claims — so anything executable, or disguised, is
refused. Attachments are forwarded with the email and deleted immediately. They
never enter uploads/, never become documents, and never get a URL.
Spam is handled by a hidden field bots fill in and people never see, a minimum time before a submission is accepted, simple content checks, and a limit of five messages an hour. None of the rejections say which check objected, since telling a bot what it tripped tells whoever wrote it what to change. The rate limit stores a salted, shortened fingerprint of the sender's network, never their address, and forgets it after an hour.
Nothing a visitor sends is stored. There is no inbox and no message log — the email is the only copy.
Folio already keeps a document's address working through renames and moves on its own. When a folder is restructured over FTP, or a document is retired deliberately, there is nothing for that to work from — and that is what Redirects is for.
Log in and click Redirects. Add the old address and where it should go, and choose whether the move is permanent (301, search engines update their index) or temporary (302, they keep the old address). A rule is only ever used when a request would otherwise have been a "not found", so it can never take a working address off the air — and Folio refuses to save an active rule pointing at an address that still works, in case you meant a different one.
Loops are refused outright. If one rule points at another, visitors are sent straight to the final destination in a single hop rather than bounced along the chain, and the screen flags it so you can tidy it up.
Test an address before relying on it. Type an old address and Folio reports what actually happens: whether a rule answers it, which one, the full chain if there is more than one, and where a visitor ends up. It also warns when a rule points somewhere that does not exist, so you can catch a redirect sending people from one dead address to another. Testing changes nothing.
Back up and restore. Download your rules as a file, and import one back. Importing replaces every rule, and the whole file is checked before anything is written — one bad entry stops the import entirely rather than leaving half of it applied, and you are told exactly which entries were wrong and why. Hit counts stay with your site: they are not written into the file, since they describe where it came from, and they survive an import for any rule whose address has not changed.
The Slug history tab shows old document addresses Folio already redirects on its own, after you rename something. Nothing there needs maintaining — it works whether or not you ever look at it — but it is worth a glance before writing a rule, in case Folio is already handling the address.
The 404 Monitor tab lists addresses people actually asked for that led nowhere, with how many times and when. Any of them can be turned into a rule in one click, which fills in the old address and leaves the destination to you. Counts only are kept: no visitor addresses, and the list trims itself.
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. robots.txt is generated by Folio itself, so it stops resolving once
the folder is gone — nothing to edit. If you added the one-line rewrite for a
subfolder install to your domain root's own config, remove that too.
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.
Folio ships one test suite: an integration smoke test that exercises a real installation over HTTP. There is no unit-test framework and no browser-driver suite, which is a deliberate trade for a single-file application with no Composer dependency — but it does mean the coverage boundaries below are worth reading before you rely on a green run.
From the Folio root:
bash tests/smoke.shIt prints one PASS: line per group, exits 0 when everything passes, and
exits 1 on the first failure with a FAIL: line naming what broke.
- PHP command line, matching the version the site runs on. The suite uses PHP's built-in development server, so no separate web server is needed.
- curl, and standard POSIX tools (
grep,sed,mktemp,touch). - The
mbstringextension, which Folio requires at runtime. - The suite deliberately avoids optional extensions. It parses XML with pattern matching rather than SimpleXML, because SimpleXML is absent from some PHP builds and a test suite should not fail for a reason unrelated to the code under test.
Set FOLIO_TEST_PORT if the default port 18765 is in use:
FOLIO_TEST_PORT=19000 bash tests/smoke.shEach run builds a throwaway installation in a temporary directory: a fresh
config.php with a generated password hash, an empty data/, and a small
fixture library. It starts PHP's development server against that directory,
runs every check over HTTP as a real client would, then removes the directory.
Nothing touches your live installation, and no test depends on the order you ran it in — except where noted below, since a few checks deliberately build on the state left by the previous one.
The fixture library is small but awkward on purpose:
| Fixture | Why it exists |
|---|---|
foo.pdf and Foo!.pdf |
Two files whose names normalise to the same slug, to prove collisions are disambiguated |
foo.jpg |
Same stem as foo.pdf, to prove the extension participates in addressing |
evil.html |
An active format that must be forced to download, never rendered in-origin |
notes.txt |
A plain file used for metadata and lastmod checks |
host.txt |
A symbolic link to /etc/hostname, which must never be served |
| Group | What it proves |
|---|---|
| Anonymous caching and canonical host | Public pages set no cookie, stay cacheable, and build canonical URLs from SITE_URL even when the request Host header claims otherwise |
| Symbolic-link containment | A symlink pointing outside uploads/ is not followed or served |
| Collision-safe addressing | Colliding names get distinct slugs; unambiguous legacy slugs redirect; ambiguous ones 404 |
| Controlled file delivery | Active and unknown formats are forced to application/octet-stream with an attachment disposition, a sandboxing CSP, and noindex |
| Invalid directory | A nonexistent folder returns 404 rather than an error page |
| Metadata storage | An authenticated edit is written atomically and leaves a last-known-good .bak |
| Category sitemap | Categories get their own sitemap; the main sitemap never duplicates them |
Hidden pdf_access |
Every path to a hidden PDF's bytes — direct URL, ?action=raw — is blocked for the public |
Viewer pdf_access |
The record page still renders; the file itself requires a valid, unexpired signed URL (see Signed URLs below) |
video_access gate |
Hidden is admin-only; viewer requires a valid signed URL and rejects a forged one; public streams with range-request support |
| Hidden-file indexability | A hidden video is pulled from the folder listing but its record page stays sitemap-indexable, and an admin still sees it in the listing — the same policy a hidden PDF's record page already follows |
pdf_access and page-level indexing |
A pdf_access setting never leaks into the record page's sitemap presence, robots meta, or llms.txt — it only ever gates the raw file |
| llms.txt Specification conformance | Lang: immediately after the H1, a required # Contact section built from configured publisher fields, and the specification attribution as a closing footer rather than an inline link — per the llms.txt Specification (v1.7.0) |
| Live robots.txt | Served as text/plain, always responds (never 404, unlike every other discovery endpoint), reflects Allow: / and the current Sitemap: references while the site is indexable. Does not yet cover the Disallow: / branch, since that needs SITE_INDEXABLE toggled at runtime and this suite provisions one static config per run — same gap already noted for the X-Robots-Tag fix |
| JSON-LD injection | Metadata containing </script>, mixed-case variants, ampersands, and quotes cannot terminate the structured-data element or create markup |
| Malformed metadata | A corrupt store is rejected rather than overwritten, and the valid copy survives |
| Session revocation | Resetting a password invalidates sessions already holding the old auth_version |
| Logout protection | GET logout is refused with 405, a tokenless POST is refused, and only a valid CSRF POST ends the session |
| Installer headers | The installer emits a CSP with no unsafe-inline, forbids framing, and is not cacheable |
Sitemap lastmod |
Editing one document's metadata moves only that entry's date, without touching the file on disk |
| Analytics CSP | Inline analytics scripts are allowed by a sha256 source hash, never by unsafe-inline |
| Excluded files | A file matching EXCLUDE_PATTERNS is absent from every public surface, not just the listing |
| Derivative images | Generated thumbnails and previews are written under a bounded cache path and never overwrite or expose the original file |
| Restricted-PDF thumbnails | A restricted PDF is refused a thumbnail rather than rendering one from gated content |
| External utilities | Each optional tool (ffmpeg, Imagick, qpdf, etc.) is invoked without a shell, and only when actually available and gated on |
| Optional utilities | Every utility Folio can use is genuinely optional; Ghostscript specifically is never invoked unless explicitly allowed |
| Utility inventory | Every utility the documentation advertises is actually referenced somewhere in the code — no stale claims |
| Root icon | A request for the root favicon is answered with a real icon file, not a 404 or a redirect loop |
| Release-asset versioning | The manifest's recorded size still matches the shipped source, so an upgrade is never served a stale cached stylesheet |
| PDF file sitemap | Lists only PDFs the public can actually fetch — a hidden or viewer-only PDF's page stays indexed elsewhere, but its raw file is absent from this one; excluded files and folders never appear; non-PDFs never appear; served with index, follow |
| Video sitemap | Serves valid XML with the video: namespace; the same "indexed page, gated file" split as the PDF file sitemap — a restricted or hidden video's raw file is absent; excluded files and non-video files never appear. Does not yet cover a real, decodable public video with a derivable thumbnail actually being listed, since the suite's video fixtures are random bytes, not playable video |
| Canonical slugs and aliases | A renamed or migrated document keeps working addresses; ambiguous or colliding slugs behave predictably |
| Reconciliation and relinking | The admin tools for repairing broken metadata associations are gated to admins and never modify files on disk |
| Sitemap partitioning | Small libraries stay a single urlset; invalid, negative, and out-of-range part numbers 404 |
| Stateless sitemap | The sitemap generates without a session and reflects the current library |
A gated PDF or video is served through ?action=raw behind a short-lived
HMAC-signed URL rather than its plain path. The two are deliberately
namespaced so a token for one can never validate the other:
PDF: hash_hmac('sha256', "{rel}|{expires}", FOLIO_URL_SIGNING_KEY)
Video: hash_hmac('sha256', "video|{rel}|{expires}", FOLIO_URL_SIGNING_KEY)
A test building a video token from the PDF payload (no video| prefix)
will construct a token the real validator rejects — this exact mistake
shipped once and is why it's called out here explicitly.
The JSON-LD check writes real attack strings through the ordinary metadata
form and then inspects the rendered page. The payloads include
Report </script><img src=x onerror=alert(1)><script>, a mixed-case
</ScRiPt><svg onload=alert(2)>, and values containing ampersands,
apostrophes, and quotation marks.
The expected result is that every one survives as data: each JSON-LD block
still parses as JSON, the raw text contains no closing script tag, and no
img, svg, iframe, or form element appears in the document. A failure
here means output encoding regressed, which is a release blocker.
This check was confirmed to fail against the pre-1.0.1 encoder, so it is known to detect the bug it guards rather than merely passing.
Being explicit about this matters more than the list above, because a green run is easy to over-read.
- Concurrency. PHP's development server handles one request at a time, so the suite cannot exercise parallel logins or simultaneous administrative writes. The login throttle's locking was verified separately by running eight processes performing twenty-five increments each against one counter and confirming all two hundred were recorded; that harness is not part of the suite. Concurrent administrative edits are a known gap.
- External services. No test contacts IndexNow, and nothing is mocked,
because the suite never triggers a submission. Batching is verified by
reasoning about
array_chunkboundaries rather than by observing requests. - Scale. There is no large-library dataset. Sitemap partitioning is
verified structurally, by lowering
SITEMAP_MAX_URLSin a scratch copy and confirming the index and parts are correct, rather than by generating fifty thousand files. - Browsers. No JavaScript is executed. Client-side search, hover cards, the PDF flip reader, keyboard navigation, and CSP enforcement in a real browser are all unverified by this suite.
- Upgrades. There is no automated test that upgrades a populated older installation.
Follow the existing shape: perform the request, assert on the response, then
call pass 'short description'. Use fail 'what went wrong' for a failure so
the suite exits non-zero.
Two habits are worth keeping. Assert on the smallest thing that proves the behaviour, so a failure names the cause rather than a symptom. And before trusting a new regression test, break the fix it guards and confirm the test actually fails — a test that cannot fail is worse than no test, because it looks like coverage.
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.
The human-readable HTML sitemap (sitemap.html) uses js-yaml 5.3.0 by Vitaly
Puzrin, distributed under the MIT licence, vendored in lib/js-yaml/ with its
licence text. Like pdf.js it is served from your own domain, needs no
Content-Security-Policy exception, and loads only on that page.
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.69.2. Single-file application with separated CSS and JS assets.