Skip to content

Repository files navigation

Folio

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

Features

  • 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_PATTERNS in config.php to 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, and WebPage structured 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 in identity.json and vcard.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, and language metadata 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, and vcard.vcf from one shared map, so the three surfaces can't drift out of sync

Design

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).

Supported file formats

Format Extensions Preview Print Detail page In sitemap
PDF .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.

Requirements

  • PHP 8.4 or newer with JSON, password and random support; mbstring is optional and only affects Markdown rendering
  • Apache or LiteSpeed using the supplied .htaccess with mod_mime and mod_headers; mod_rewrite is needed only for optional clean URLs
  • Read permission for PHP on uploads/ and write permission on data/

File structure

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

Installation

See docs/install.md for the complete procedure. In brief:

  1. Unpack the zip and upload the contents of folio/.
  2. Make uploads/ and data/ writable by the web server.
  3. The shipped .htaccess works as-is on Apache or LiteSpeed.
  4. Open install.php. It creates data/install-token.php.
  5. Read that one-time token over FTP, enter it in the installer, and provide the exact canonical SITE_URL for the Folio folder.
  6. Complete the account and site fields, then delete install.php.
  7. 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.

Usage

Managing files

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.

PDF access control

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:

  1. Set FOLIO_URL_SIGNING_KEY in config.php (generate it the same way as FOLIO_AUTH_PEPPER — never reuse that value here).
  2. 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.

Titles, descriptions, categories, tags

  1. Click Admin in the top bar and log in.
  2. Click Edit on any row. Fill in the title, description, category, and comma-separated tags, then save.
  3. Metadata is stored atomically in data/metadata.json, keyed by relative path, with a last-known-good backup at data/metadata.json.bak. Existing uploads/.sfm-meta.json data 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.

Hotlinks

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.

URL slugs

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.

SEO

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.

Regression tests

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.

Structured data

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.

Entity relationships

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.

Books and organisations

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.

Works and their copies

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.

What Folio is not

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_PATTERNS in config.php hides 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.

The admin at a glance

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

Settings screen

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.

Crawler controls

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): a Lang: header after the H1, a # Contact section 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_SECTION documents (default 30); beyond that it names how many more exist and points to library.yaml for 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 to 0 in config.php to 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: a Person (the subject the library documents) and the WebSite itself, 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, and PUBLISHER_AFFILIATION are set in config.php, each rendering only when filled in. PUBLISHER_BIO in 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 optional PUBLISHER_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 unlabelled sameAs entry. Every document's own author/publisher link, and the WebSite's publisher link, resolve to the exact same @id as 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 (one X-SOCIALPROFILE per 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 the robots.txt sitemap 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.xml pointing to sitemap-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}.txt automatically, 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.

Image thumbnails

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.

Turning on PDF access control

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 64

Use 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.

Server utilities and OCR

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

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.

What happens when a tool is missing

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

Document URLs

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.

Editing a URL slug

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.

After renaming or moving files over FTP

Folio finds the file again by its contents.

  1. Rename or move the file however you like over FTP.
  2. Open index.php?action=diagnostics. The catalogue row lists any document whose file has gone missing.
  3. 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.

When a file cannot be matched

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

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.

PHP extensions

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.

Making scanned documents searchable (OCR)

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.

Step 1 — Check what your server has

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.

Step 2 — Ask your host to install what is missing

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.

Step 3 — Choose your languages

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.

Step 4 — Run it

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.

What OCR does and does not touch

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.

When OCR does not work well

  • A poor scan gives poor text. Faint, skewed, or low-resolution originals are hard to read. Installing unpaper lets 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_TIMEOUT defaults to 600 seconds. If a document is cut short, raise it in config.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.

Your own site icon

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.

Getting documents found

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.

robots.txt

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.

Sorting and filtering the listing

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.

Making a PDF smaller

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.

Entities

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.

Standalone pages

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.

Page addresses

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.

Captions for audio and video

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.

Backing up your catalogue

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.

The contact form

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.

Redirects

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.

Hiding the admin link

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.

Accounts

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.

Setting the first password by hand

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.

Publishing to a public repository

Credentials are kept out of the code so the repository can be public:

  • config.php holds the username and password hash and is listed in .gitignore. Only config-sample.php, which contains placeholders, is committed.
  • uploads/ is ignored except for its .htaccess, so documents are not published with the code.
  • .htaccess itself 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.

Upgrading and removing

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.

Site secrets

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_PEPPER is HMAC-mixed into every stored password hash. If data/users.php ever leaks in isolation — through a backup exposure, for example — the hashes remain uncrackable without the pepper sitting in config.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_NAME replaces PHP's default PHPSESSID cookie 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.

Where Folio is going

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 and hardening

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.

Testing

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.

Running the suite

From the Folio root:

bash tests/smoke.sh

It 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.

Requirements

  • 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 mbstring extension, 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.sh

How it works

Each 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

What is covered

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

Signed URLs

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.

Security regression payloads

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.

What is not covered

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_chunk boundaries rather than by observing requests.
  • Scale. There is no large-library dataset. Sitemap partitioning is verified structurally, by lowering SITEMAP_MAX_URLS in 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.

Adding a test

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.

Theme architecture

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.

Adding a theme

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:

  • --accent is used for both text on --leaf and as a button background with --leaf as its text colour, so it needs contrast in both directions.
  • --quiet must stay legible against --paper; it carries all metadata.

Layout

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.

Scoped stylesheets

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.

Third-party components

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.

Licence

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.

Version

1.69.2. Single-file application with separated CSS and JS assets.

Releases

Packages

Contributors

Languages