Skip to content

Submission portals

Paid add-on. A public view becomes a portal: a private list, searchable, filterable and paginated, with targeted export, a publication workflow and expiring private links. It targets directories, membership applications, registrations, job applications and client case files.

This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Publishing a list of submissions”.

An existing view does not change

The portal is switched on by a setting. As long as it is off — and it is off for every view already published — the view renders exactly as before: same query, same markup, same protections.

No migration was needed for that. Writing "portal":{"enabled":false} into every view would have rewritten all the site’s metadata to arrive at the behaviour the absence of the setting already produces.

Publishing is not a submission status

Approving is deciding that an entry appears in this portal. The decision therefore carries the portal/submission pair, and not the submission alone: the same entry appears in one directory and stays hidden in another.

Refusing trashes nothing. Turning a refusal to publish into a deferred deletion would eventually erase data nobody asked to erase, and the core would keep the trace of an act that was never its own.

In the other direction, the core always wins: a submission trashed or marked as spam disappears from the portal instantly, whatever its publication state. It is the query’s status = 'active' condition that guarantees this — not a tidy-up of the index, which could fall behind.

The four states and their transitions

           ┌──────────┐
           │ pending  │◄────────────┐
           └────┬─────┘             │
        ┌───────┴────────┐          │
        ▼                ▼          │
  ┌──────────┐     ┌──────────┐     │
  │ approved │────►│ rejected │─────┘
  └────┬─────┘     └──────────┘
       │ ▲
       ▼ │
   ┌────────┐
   │ hidden │
   └────────┘

The graph is not complete, and that is the point.

rejected leads only to pending. A refusal is re-examined before it appears. Allowing rejected → approved in one click would turn a list gesture into the publication of content that had been set aside, without anyone having re-read it.

hidden leads only to approved. Hiding is a temporary withdrawal — for the duration of a past event, of a check — and not a refusal. Whoever wants to refuse goes through the state that says so, and the log keeps the difference.

The absence of a decision counts as pending. Not approved. Otherwise, the day you tick “require approval”, everything you had just decided to re-read would appear all at once.

Being named a reviewer opens no case file

The capability solis_forms_moderate_entries is new, and the meta capability solis_forms_moderate_form resolves it per form. Moderating is deciding what appears: it is neither editing the form, nor refunding a payment, nor cancelling a subscription. A reviewer therefore receives the first without receiving the others, and a manager of their own forms moderates only theirs.

The capability is required first, and scope then says which forms. The order matters: the reverse would have let every form manager moderate without it having been granted, when that is precisely what this capability exists to separate.

The capabilities also reach sites already installed

Capabilities::add_to_administrator() runs only on activation, and an update by file copy never passes through it again. A capability added in a new version would therefore have been granted to nobody: the moderation screen would have answered “you do not have permission” to the administrator themselves.

The schema already had its catch-up; the capabilities did not. They have one now, triggered by a fingerprint of the list rather than by a version number to increment — a number gets forgotten, a fingerprint changes because the list changed.

What a portal can query

The searchable, filterable and sortable fields are subsets of the fields the view exposes, and the intersection is recomputed every time the setting is read. A field removed from the view’s whitelist immediately stops being searchable, without the portal’s screen needing to be reopened.

That is this module’s most likely leak. Making a field searchable means copying its value into an index table; if that copy could cover a field that is not exposed, a well-chosen search would make rows appear or disappear according to a value the portal does not show — revealing it by deduction, question after question.

UseTypes allowedWhy not the others
Searchtext, paragraph, email, URL, phone, name, addressA choice field filters better than it searches, and what is indexed for it is the value when the visitor would type the label
Filterchoices and checkboxes (equality), dates and numbers (range)A filter on free text would force you to guess somebody else’s exact spelling, and would never find anything
Sortdates and numbersSorting text depends on the database collation: an order that changes from one host to another is not an order

Attachments, signatures and layout fields enter no list. Nor does a hidden field: it carries a technical value the visitor never saw.

A field added to the form later is never indexed or exported automatically. It has to be added explicitly.

The index, and why it exists

Searching in slf_entry_meta would mean a LIKE on a LONGTEXT column: no index applies there, and the cost grows with the whole table on every keystroke.

The slf_portal_index table therefore files away, separately, only the values a query needs. It carries two kinds of row:

  • kind = 'value': the whole value, which a filter compares and on which a sort rests. Three typed columns — text, number, date — because “9” comes after “10” in a text sort, and a date range compared as text goes wrong the moment formats differ.
  • kind = 'word': a word, and as many rows as the value contains.

Without the word rows, searching “durand” in “Boulangerie Durand” would mean a LIKE '%durand%'. A leading wildcard forbids any use of an index: the database re-reads the whole table on every keystroke, which is precisely what we wanted to avoid. One row per word brings the search back to LIKE 'durand%'.

The trade-off is accepted: the search finds a word prefix, not a substring. “oulangerie” does not find “Boulangerie”. That is the price of an index that is good for something, and V1 holds to it until a full-text engine has been measured on real data.

The text is normalised on both sides by the same function: lowercased, accents folded, punctuation reduced to separators. “Crèmerie” is found by typing “cremerie”, and “Saint-Exupéry” by typing “exupery”. Non-Latin scripts pass through untouched and keep matching themselves.

The index is not a source: the value displayed always comes from slf_entry_meta, intact. It is rebuilt when the view’s settings are saved, updated when a submission arrives and when it is corrected, and disappears with it.

The URL parameters

slf_view     the portal targeted, when several share a page
slf_q        search, 2 to 100 characters, by word prefix
slf_filter[] equality on a choice field
slf_from[]   lower bound of a date or a number
slf_to[]     upper bound
slf_sort     a declared sort field, or nothing for the receipt date
slf_order    asc or desc
slf_page     page, from 1

Everything is checked against the configuration: an undeclared field does not exist, an option absent from the field is set aside, a sort on a non-sortable field falls back to the date. An unknown parameter is ignored, not refused — a link pasted from an earlier version of the portal must show the list, not an error.

slf_view exists because two portals on the same page share the address bar: without it, searching in one would paginate the other.

Links are built by http_build_query, not by add_query_arg: the latter does not encode the values handed to it, and a search term containing an ampersand would break the next page’s link — which would then show something other than the first.

Everything works without JavaScript: a GET form and links. Search, filters, sorting and pagination are address changes, not interactions. They are shared, bookmarked, opened in a new tab, read by a screen reader, and survive a script error in the theme.

Two reviewers do not overwrite each other

Every decision carries a revision, which the screen carries along and the write requires. Re-reading the row before writing would leave exactly the interval we are trying to close.

A bulk action is not a transaction: each entry is checked on its own, and the detail is reported. An all-or-nothing batch would have cancelled ninety-nine legitimate decisions because one entry had just been trashed; a silent batch would have left people believing all hundred went through. A hundred entries at most per batch.

The export

It is distinct from the general CSV export, and it starts from the same query as the screen.

The link does not carry the list of rows to export: it carries the criteria. The server recomputes the scope — the same as the render’s, through the same door — then re-reads. An export receiving ids would export whatever it was asked for, and adding one would be enough.

The columns are the exposed fields, in the form’s order. Excluded from them are:

  • attachments and signatures, even when exposed: the portal shows their image through a signed and expiring address, whereas a CSV column could only carry the file’s path — that is, permanent access, right next to the control we have just established;
  • payment data and sensitive fields;
  • internal notes and technical ids.

The receipt date and the publication state appear only if the portal has explicitly enabled them, and the state only for those who can moderate: to a reader, every visible row is approved, and the column would say nothing they do not already know.

The ceiling is a refusal, not a truncation. Beyond ten thousand rows, the screen asks for the filters to be tightened. Truncating silently would have produced an incomplete file believed to be complete — and an export is exactly where that goes unnoticed.

Nothing is written to disk: the file is produced as a stream, with a byte order mark so that Excel does not turn “Noël” into “Noël”. An export dropped into the media folder would outlive the request, stay readable at its address, and end up in the site’s backups.

Every export is logged: who, when, under what criteria, how many rows. Never the contents.

A reviewer can create a link with a mandatory lifetime, from one hour to thirty days. It is read-only, exports only if its creator explicitly wanted it to, and carries an internal label to tell them apart.

The secret exists only once

It comes from random_bytes(), is displayed once on screen, and only its HMAC fingerprint is kept. Whoever reads the table can forge no link.

Creation renders the page directly, with no redirect: going through a redirect would have meant putting the secret somewhere between the two requests — a temporary option, that is, the database, that is, precisely the place where we decided it would never go.

It does not stay in the address bar

A URL is copied into a message, saved as a bookmark, goes out in the Referer header of the page’s first external image, and is written into the server’s logs as into the browser’s history.

The link is therefore consumed once: validation sets a short-lived HttpOnly, Secure, SameSite=Lax cookie, and the request goes back to the same page stripped of the parameter. We redirect to the current address minus one parameter: there is therefore no target to validate, and no open redirect possible.

The cookie exempts nothing from checking: it carries the same payload as the link, re-verified on every request — fingerprint, expiry, revocation. A revocation cuts the session at the next check, with no need to keep a list of open sessions.

It restricts, it never widens:

  • its filters are sealed at creation and add to the view’s; a URL parameter searches within its scope, never beyond;
  • it never shows an entry awaiting review;
  • it sees no field outside the whitelist;
  • it moderates nothing.

It does, on the other hand, exempt from the view’s audience rule — login required, roles, author. That is a private link’s very purpose: it is given to somebody who is not part of the audience. On a view restricted to the author, a link therefore shows everything its filters let through, and the creation screen says so.

Expired links are not erased. They are refused, but the row stays: it is what allows you to answer, six months later, “who had access to this portal and until when”. A register that erases itself documents nothing any more.

Caching and indexing

A portal that is not open to all — or that a private link has just opened — makes its page answer:

Cache-Control: private, no-store, max-age=0
Vary: Cookie
X-Robots-Tag: noindex, nofollow, noarchive

and sets the DONOTCACHEPAGE constant, which WP Rocket, W3 Total Cache, LiteSpeed and Batcache all recognise — they do not listen to headers.

no-store and not merely private: private forbids shared caches, not the browser’s, and a shared workstation would keep the list of applications available to the next person through the back button.

The decision is taken on wp, before a single byte has gone out. A portal renders during the_content, that is, long after the headers have left: a header() placed there would do nothing, and worse, would make it look as though something had been done.

The log

A single table for decisions, exports and private links. After the fact, the question that arises is never “who approved” or “who exported” separately: it is “what left this portal, when, and at whose request”.

It contains neither field values nor link secrets. A log that copies what it watches becomes the leak it was meant to document — and it would outlive the data it had copied.

It disappears with what it describes: a moderation note talks about somebody, and there is no right to erasure that would spare the note while erasing the answer.

For developers

An action is emitted after every decision:

add_action(
	'solis_forms_pro_portal_decided',
	function ( int $entry_id, int $view_id, string $from, string $to ): void {
		// …
	},
	10,
	4
);

The meta capability is used like the others:

if ( current_user_can( 'solis_forms_moderate_form', $form_id ) ) {
	// …
}

Tables: {prefix}slf_portal_moderation (current decision, (view_id, entry_id) unique), {prefix}slf_portal_log (the log, append-only), {prefix}slf_portal_index (search and filter index), {prefix}slf_portal_shares (link register, fingerprints only).

What V1 does not do

  • No free editing of entries from the moderation screen, no discussion, no reviewer assignment, no multi-stage workflow — that is the role of internal processing, which is another module.
  • No search across several forms, no search inside file contents, no external engine.
  • No permanent public URL for an entry, no public API, no syndication feed.