API Reference¶
REST endpoints exposed by IntroVox. All routes are defined in appinfo/routes.php.
There are two kinds of tour. The welcome tour (id welcome) is the tour new users get after their first login; its steps live in wizard_steps_<lang> with the Transifex-translated defaults behind them. Tours for an app or page (any other id) start the first time someone opens that app or page; they only have the steps an admin wrote. See Tours.
Public API¶
Tour endpoints are reachable by any logged-in user, not only admins. None of them works without logging in.
GET /apps/introvox/api/steps¶
Returns the steps of one tour for the current user, filtered by language, group visibility and enabled state. Disabled steps and the visibleToGroups lists of saved steps stay on the server; a step with a malformed group list is left out.
Attributes: #[NoAdminRequired]
Query: tour (optional, default welcome)
The welcome tour (tour absent or welcome)¶
Response (200):
{
"success": true,
"steps": [
{
"id": "welcome",
"title": "👋 Welcome to Nextcloud",
"text": "<p>Nice to have you here!</p>",
"attachTo": "",
"position": "right",
"enabled": true
}
],
"useDefault": false,
"enabled": true,
"language": "en",
"version": "1"
}
Special response fields:
useDefault: true— no admin override exists for this language; thestepsarray is the auto-translated Transifex default set (the client should use it as-is and not rebuild defaults locally)enabled: false— Tours for everyone (wizard_enabled) is off, or the welcome tour is switched off in the Tours list;stepsis then empty,languageisenand there is noversionversion—wizard_version, as a string; raised by the welcome tour's Show again to everyone
The
languageDisabled: truefield was removed in v1.7.0 along with theenabled_languagesopt-in concept.
A tour for an app or page (tour=<id>)¶
Response (200):
{
"success": true,
"tour": "talk",
"version": 2,
"steps": [
{
"id": "start-a-call",
"title": "Start a call",
"text": "<p>…</p>",
"attachTo": "",
"position": "right",
"enabled": true,
"page": "/apps/spreed/"
}
]
}
steps— in the user's language (e.g.pt_BR), else its base language (pt), else the tour's main language; disabled and group-restricted steps removed,visibleToGroupsstripped. A tour of your own has no translated defaults: a language without saved steps falls through to the next. A built-in tour (builtin-files,builtin-talk,builtin-deck,builtin-calendar) does: without the admin's steps in the user's language or its base language, it uses its Transifex translation for the user's language (or its base language) where there is one, then the admin's steps in the main language, then the English defaults.version— the tour's version (integer); raised by Show again to everyone
This route does not check whether the tour would start by itself (seen before, Start tours by themselves off, audience). That is the runtime's decision, so that a tour started by hand from the personal settings still loads.
Response (404): { "success": false } for everything refused: a malformed or unknown id, Tours for everyone off, the tour switched off, a user outside the tour's groups, or a user who cannot open the tour's app. One answer for all of these, so the response says nothing about which tours exist.
Implementation: ApiController::getWizardSteps()
POST /apps/introvox/api/wizard/start¶
Records that the current user started the welcome tour.
Attributes: #[NoAdminRequired], #[UserRateLimit(limit: 10, period: 3600)] (the same on complete and skip; each counts once per user)
Response: { "success": true }
POST /apps/introvox/api/wizard/complete¶
Records that the current user completed the welcome tour.
Attributes: #[NoAdminRequired], #[UserRateLimit(limit: 10, period: 3600)]
Response: { "success": true }
POST /apps/introvox/api/wizard/skip¶
Records that the current user clicked Skip and don't show again.
Attributes: #[NoAdminRequired], #[UserRateLimit(limit: 10, period: 3600)]
Response: { "success": true }
These three are for the welcome tour only. Marking it done for the user (wizard_disabled) is a separate call, POST /personal/settings with wizardDisabled: true.
POST /apps/introvox/api/tours/{id}/event¶
Records that a tour for an app or page started, was completed or was skipped for the current user. {id} matches [a-z0-9][a-z0-9-]{0,31}.
Attributes: #[NoAdminRequired], #[UserRateLimit(limit: 30, period: 3600)]
Body: { "event": "start" | "complete" | "skip" }
Responses:
start→{ "success": true, "first": true }. Stores the tour's current version intour_<id>_seen; from then on the tour does not start by itself for this user, in any browser, until its version is raised.firstisfalsewhen that version was already stored, for example because another tab or device started it a moment before; the frontend then only shows the tour if it was started by hand.complete/skip→{ "success": true }. Stores the time intour_<id>_completed/tour_<id>_skipped, the first time only.- Any other
event→400 { "success": false } 404 { "success": false }— the same rule asGET /api/steps?tour=<id>, so nobody can write state for a tour they don't get. The welcome tour (welcome) also gets 404 here: it uses/api/wizard/*.
Implementation: ApiController::tourEvent()
POST /apps/introvox/api/tours/{id}/reached¶
Records that a step of a tour was shown to the current user, for How far people get in the statistics. The welcome tour too (welcome). The frontend sends it only during a run that started by itself (not a start by hand, not a preview), and only for a step further than this run got before.
Attributes: #[NoAdminRequired], #[UserRateLimit(limit: 300, period: 3600)]
Body: { "step": "<step id>", "index": 2 } — index is the step's place in the run as this user saw it, from 1
Responses:
{ "success": true }. Stores<version>|<index>|<step id>intour_<id>_reachedwhen that is further than what is stored for the tour's current version, or when the stored value is from an older version (Show again to everyone starts the count over). Going back a step changes nothing.400 { "success": false }—stepis not a step id (letters, digits,_,-,.; at most 100) orindexis not 1–100404 { "success": false }— for anyone who does not get the tour, an unknown tour, or a tour that is off; the welcome tour while it or Tours for everyone is off
Each user writes only their own value, so concurrent runs lose nothing; the admin only ever sees totals.
Implementation: ApiController::tourReached(), TourStats
POST /apps/introvox/api/checklist/{id}¶
An admin's own task of the checklist on the Dashboard was opened from the list: done for the current user (checklist_<id>). The page the task opens carries ?introvox-check=<id>, which the bootstrap takes out of the address and reports here. 404 { "success": false } for a task that is not there or a checklist that is off. At most 60 requests per hour per user.
GET /apps/introvox/media/{file}¶
Serves an image, video or captions file used in a step. {file} is a 32-character hex id with the extension png, jpg, gif, mp4, webm or vtt. Only for a logged-in user who may see a step that uses the file; everything refused gets the same 404.
Attributes: #[NoAdminRequired], #[NoCSRFRequired], #[StrictCookiesRequired] — see CSRF Protection
Share Links¶
Copy link gives /index.php/apps/introvox/tour/<id> (LinkController::open). Someone who is not logged in goes through the login first, which keeps that path (it drops a query string). The route then redirects to the tour's page (/apps/files/ for the welcome tour and a tour that starts anywhere) with ?introvox-tour=<id>, or to the default page for a tour this user may not get. It has no CSRF check, as a link in a mail cannot carry a token; it changes nothing.
Any page of this Nextcloud with ?introvox-tour=<id> starts that tour there, as a start by hand (src/main.js, chooseTour()), also over a tour that was underway. The server reads the parameter with the page and passes it on in the runtime state (link), because an app that sends its start page on at once (Deck goes to Upcoming cards) has dropped it from the address by the time the bootstrap runs. The bootstrap takes the parameter out of the address straight away, checks it is a tour id, and the tour then loads its steps through GET /api/steps?tour=<id> — so it starts only for someone who may get the tour, and anyone else gets the page without one. It is not counted in the statistics.
Personal Settings API¶
Per-user state. Requires authentication; 401 without a user.
GET /apps/introvox/personal/settings¶
Response:
{
"success": true,
"wizardDisabledByUser": false,
"toursDisabled": false,
"tours": [
{
"id": "talk",
"name": "Talk",
"page": "/apps/spreed/",
"app": { "name": "Talk", "icon": "/apps/spreed/img/app.svg" },
"seen": false
}
]
}
wizardDisabledByUser— the user preferencewizard_disabled: the welcome tour is done for this user (Done, Skip and don't show again, or Start tours by themselves switched off)toursDisabled— the user preferencetours_disabled: Start tours by themselves is offtours— the tours for an app or page this user may get (switched on, Tours for everyone on, in the tour's groups, and able to open its app); not the welcome tour.pageis/apps/<app>/for an app tour, or the page path.appis the name and icon from the user's own apps menu,nullfor a page tour.seenistrueonce any version of the tour started for the user.
POST /apps/introvox/personal/settings¶
Body: either field, or both:
wizardDisabled— writeswizard_disabled('true'/'false'). The tour sendstrueon Done and Skip and don't show again; Restart tour now sendsfalse.toursDisabled— writestours_disabled('true'/'false').truealso setswizard_disabledto'true';falseleaveswizard_disabledas it is.
Response: { "success": true, "message": "Settings saved" }
Admin API¶
All admin endpoints require administrator privileges. CSRF protection is enforced on every endpoint (see CSRF Protection).
Tours¶
| Method | URL | Purpose |
|---|---|---|
GET |
/apps/introvox/admin/tours |
List every tour, the welcome tour first |
POST |
/apps/introvox/admin/tours |
Create a tour for an app or page |
PUT |
/apps/introvox/admin/tours/{id} |
Change a tour, or switch it on or off |
DELETE |
/apps/introvox/admin/tours/{id} |
Delete a tour for an app or page |
POST |
/apps/introvox/admin/tours/{id}/reshow |
Show again to everyone |
GET |
/apps/introvox/admin/tours/{id}/statistics |
Started, completed, skipped, and how far people get, step by step |
GET |
/apps/introvox/admin/tours/{id}/export |
The whole tour as a .zip |
POST |
/apps/introvox/admin/tours/import |
A tour from such a .zip |
GET |
/apps/introvox/admin/apps |
The apps in the admin's apps menu, for Where does it start?: { "success": true, "apps": [{ "id", "name", "icon" }] } |
{id} matches [a-z0-9][a-z0-9-]{0,31}. A non-admin gets 403: Nextcloud refuses the request itself (none of these routes has #[NoAdminRequired]), and ToursController checks again ({ "success": false, "error": "forbidden" }).
GET /admin/tours response:
{
"success": true,
"toursEnabled": true,
"tours": [
{
"id": "welcome",
"name": "",
"trigger": { "type": "login" },
"enabled": true,
"enabledAt": 0,
"audience": "everyone",
"visibleToGroups": [],
"mainLanguage": "en",
"version": 3,
"builtin": true,
"languages": ["nl"],
"app": null,
"stepCount": 0
},
{
"id": "talk",
"name": "Talk",
"trigger": { "type": "app", "app": "spreed" },
"enabled": true,
"enabledAt": 1767225600,
"audience": "everyone",
"visibleToGroups": [],
"mainLanguage": "en",
"version": 1,
"builtin": false,
"languages": ["en", "nl"],
"app": { "name": "Talk", "icon": "/apps/spreed/img/app.svg" },
"stepCount": 4
}
]
}
Tour object:
| Field | Description |
|---|---|
id |
Set by the server: an ASCII slug of the name (at most 28 characters, -2, -3… when taken, never welcome, no underscores) |
name |
1–100 characters; empty for the welcome tour, which the UI names itself |
trigger |
{ "type": "login" } (welcome tour only), { "type": "app", "app": "<app id>" }, { "type": "page", "page": "/settings/user" } or { "type": "any" } (on whichever page the user opens next, never IntroVox's admin page). A page inside an app is stored as that app: /index.php/apps/spreed/… and /call/… become spreed, /f/… becomes files. A page may only hold letters, digits, -, _ and slashes (no %, dots, backslashes or another host), at most 200 characters |
enabled |
On or off. A new tour starts off |
enabledAt |
When the tour was first switched on (Unix time, 0 before that); the start of "new users" |
audience |
everyone, or new: only users whose first login came at or after enabledAt |
visibleToGroups |
Group ids that get the tour; [] = all users |
startAfterDays |
0–365: it starts by itself only this many days after the user's first login (0 = straight away; an unknown first login counts as long ago) |
startFrom, startUntil |
YYYY-MM-DD or "": it starts by itself only on and between these days, both included, in the user's own time zone. startFrom may not be after startUntil |
mainLanguage |
Language the steps are written in first (en, pt_BR); the last fallback for users |
version |
Raised by Show again to everyone. For the welcome tour this is wizard_version |
builtin |
true for the welcome tour and the built-in tours (builtin-files, builtin-talk, builtin-deck, builtin-calendar; listed only while their app is installed). Their name is translated; their name, trigger and mainLanguage cannot be changed, and they cannot be deleted |
languages, app, stepCount |
Only in the list, worked out per request: the languages with saved steps (for the welcome tour: those with an override), the app's name and icon from the admin's apps menu, and the number of steps in the main language (0 for the welcome tour) |
POST /admin/tours
Body: { "tour": { "name": "Talk", "trigger": { "type": "app", "app": "spreed" }, "visibleToGroups": [], "mainLanguage": "en", "audience": "everyone", "startAfterDays": 0, "startFrom": "", "startUntil": "" } } — only name and trigger are required; the tour is always created switched off.
Response: { "success": true, "tour": { … } }. Errors: 400 { "success": false, "error": "Invalid tour: <reason>" }, including Invalid tour: at most 50 tours.
PUT /admin/tours/{id}
Body: { "tour": { … } }. For a tour for an app or page, the given fields (name, trigger, visibleToGroups, mainLanguage, audience, enabled, startAfterDays, startFrom, startUntil; TourValidator::FIELDS) replace the stored ones and the result is validated as on create. For a built-in tour only enabled, visibleToGroups, audience and the three schedule fields count (BuiltinTours::EDITABLE); anything else sent is ignored. For the welcome tour only { "tour": { "enabled": true | false } } counts.
Switching a tour on needs at least one enabled step in its main language, otherwise 400 { "success": false, "error": "needs_steps" }. Unknown id: 404 { "success": false, "error": "not_found" }.
Response: { "success": true, "tour": { … } }.
DELETE /admin/tours/{id}
Removes the tour from the index, its steps in every language (tour_steps_<id>_*) and every user's state of it (tour_<id>_seen, _completed, _skipped, _reached). A JSON backup of the tour and its steps goes to IntroVox's app data folder (backups/); the last ten are kept, with no route to restore them. The welcome tour and the built-in tours cannot be deleted: 404 { "success": false, "error": "not_found" }, as for an unknown id.
Response: { "success": true }.
POST /admin/tours/{id}/reshow
For a tour for an app or page: raises its version, so it starts by itself again for everyone whose tour_<id>_seen is lower. For the welcome tour: deletes every user's wizard_disabled and raises wizard_version. Neither touches tours_disabled.
Response: { "success": true, "tour": { … } }; unknown id: 404 { "success": false, "error": "not_found" }.
GET /admin/tours/{id}/statistics
Query: ?lang=<code> — the language whose steps the funnel follows. Unknown or absent: the tour's main language (the welcome tour: en).
{
"success": true,
"statistics": {
"started": 12,
"completed": 7,
"skipped": 2,
"language": "en",
"languages": ["en", "nl"],
"steps": [
{ "id": "t-1", "title": "Welcome to Talk", "enabled": true, "reached": 12 },
{ "id": "t-2", "title": "Start a conversation", "enabled": true, "reached": 9 }
],
"other": 1,
"counted": 12
}
}
started,completed,skipped— unique users, counted from their preferences: for a tour for an app or pagetour_<id>_seen,_completed,_skipped; for the welcome tourwizard_started,wizard_completed,wizard_skippedsteps— every step oflanguagein its current order (switched-off ones too), withreached: how many users got at least that far in their last automatic run (tour_<id>_reached). For the welcome tour, the admin's own steps for that language, or the translated defaultsother— users whose furthest step is not in this list: deleted since, or a step of another languagecounted— users with atour_<id>_reachedvalue; the percentages in the UI are of this
Nothing here goes into telemetry. Unknown id: 404 { "success": false, "error": "not_found" }.
GET /admin/tours/{id}/export
The whole tour as introvox-tour-<id>.zip (Content-Type: application/zip): tour.json and media/<file> for every image, video and captions file a step uses. tour.json:
{
"format": "introvox-tour",
"version": 1,
"exportedAt": "2026-10-01T12:00:00+00:00",
"introvox": "2.0.0",
"tour": { "kind": "app", "name": "Talk", "trigger": { "type": "app", "app": "spreed" }, "mainLanguage": "en", "visibleToGroups": [], "audience": "everyone" },
"languages": { "en": [ … ], "nl": [ … ] }
}
For the welcome tour tour is { "kind": "welcome" } and languages holds only the admin's own steps (wizard_steps_<lang>), never the translated defaults; with none, 400 { "success": false, "error": "nothing_to_export" }. Ids, enabled, enabledAt and version are not exported.
POST /admin/tours/import
Either the form field file (an upload), or { "path": "/Tours/talk.zip" } for a file in the admin's own Files — for packages over PHP's upload limit; a share that may only be viewed is refused. replaceWelcome: "1" confirms a welcome tour package (below).
Reading a package trusts nothing: only tour.json and media/<32 hex>.<png|jpg|gif|mp4|webm|vtt> may be in it, each once, at most 501 files and tour_package_limit bytes unpacked (default 100 MiB; occ config:app:set introvox tour_package_limit --value=<bytes>), checked against the zip's directory before reading and again while reading. The tour and its steps go through the same checks as the editor (TourValidator, StepValidator); the media through the same checks as an upload, as fresh copies with new ids.
- A tour for an app or page becomes a new tour, switched off:
{ "success": true, "confirm": false, "kind": "app", "languages": ["en", "nl"], "tour": { … }, "droppedGroups": ["staff"], "mediaLeftOut": 0, "appMissing": null }. Groups this Nextcloud lacks are left out (droppedGroups); media missing from the package or over this server's limits is left out of its step (mediaLeftOut);appMissingnames the trigger's app when it is not installed here. - A welcome tour package without
replaceWelcomechanges nothing:409 { "success": false, "error": "confirm_welcome", "languages": ["en"] }. With it, the welcome tour's own steps are replaced for those languages. - Refused:
400 { "success": false, "error": "<reason>", "detail": "…" }withnot_a_package,no_manifest,unexpected_file,too_many_files,too_large,too_large_for_server,newer_version,bad_tour,too_many_tours,no_file,not_foundorview_only.
Implementation: ToursController, TourRepository, TourValidator, TourStatistics, TourTransfer, TourPackage
Assistant¶
Translating a tour and drafting its steps with the Nextcloud Assistant: a core:text2text task for whatever provider is set up. Tasks run in the background; the admin page asks for the result, and saves what the admin checked through POST /admin/steps. Nothing is stored here.
| Method | URL | Purpose |
|---|---|---|
GET |
/apps/introvox/admin/assistant |
{ "success": true, "available": true }: whether a provider for free prompts is set up |
POST |
/apps/introvox/admin/assistant/translate |
Body { "tour", "from", "to" }: the steps of from translated into to. Answers { "success": true, "task": 42 } |
POST |
/apps/introvox/admin/assistant/draft |
Body { "tour", "description", "lang" }: three to six new steps in lang, from a description of at most 2000 characters |
GET |
/apps/introvox/admin/assistant/tasks/{id} |
{ "status": "running" }, { "status": "done", "steps": [ … ], "language": "nl" } or { "status": "failed", "error": "<reason>" } |
Refused: 400 with invalid, or no_steps when from has nothing to translate; 503 unavailable without a provider; 404 for a task that is not the admin's own or not IntroVox's. A failed task's error is failed (the provider gave up), cut_off (the answer stopped before its end: the provider's limit on its length), unreadable (no steps in the answer, or steps StepValidator refuses) or changed (the tour, or the steps that were sent, changed while it ran). Each scheduling route takes 60 calls per admin per hour.
A translation changes only the words: ids, elements, pages, media and groups come from the steps as they are when the answer is read. Drafted steps are centered, on the tour's page, with ids ai-<task>-<n>.
Implementation: AssistantController, TourAssistant, TourPrompts
Step CRUD¶
Every step endpoint takes a tour parameter (default welcome). For the welcome tour the steps live in wizard_steps_<lang>; for any other tour in tour_steps_<id>_<lang>. An unknown tour gets 404 { "success": false, "error": "Unknown tour" }.
| Method | URL | Purpose |
|---|---|---|
GET |
/apps/introvox/admin/steps |
Get the steps of a tour in a language (query: ?lang=<code>&tour=<id>). For the welcome tour and a built-in tour without the admin's own steps: the translated defaults. For another tour: its saved steps or [], and the response also carries "tour" |
POST |
/apps/introvox/admin/steps |
Save the full step list (body: { "steps": [...], "lang": "<code>", "tour": "<id>" }) |
POST |
/apps/introvox/admin/reset |
Delete a language's steps (body: { "lang", "tour" }). For the welcome tour the next GET serves the auto-translated defaults; another tour has no defaults, so that language is simply empty |
Import/Export¶
| Method | URL | Purpose |
|---|---|---|
POST |
/apps/introvox/admin/export |
Export a tour's steps for a language as JSON (body: { "lang", "tour" }). Filename introvox_steps_<lang>.json for the welcome tour, introvox_<id>_steps_<lang>.json otherwise |
POST |
/apps/introvox/admin/import |
Import steps into a tour (body: { "fileContent": "<JSON>", "tour": "<id>" }). The file is { "language": "<code>", "steps": [...] }, as written by export; the language comes from the file |
These move one language of a tour's steps. A whole tour, with its settings, every language and its media, moves with GET /admin/tours/{id}/export and POST /admin/tours/import (above).
Settings¶
| Method | URL | Purpose |
|---|---|---|
GET |
/apps/introvox/admin/settings |
{ "success": true, "enabled": true, "userMenu": true, "checklist": { … } } — Tours for everyone (wizard_enabled), Show Tours in the user menu (user_menu), and the checklist on the Dashboard: { "enabled": false, "onDashboard": false, "tasks": [{ "id": "upload", "title": "Upload a file", "hint": "…", "note": "", "chosen": true }, …], "custom": [{ "id": "c-1a2b3c4d", "title": "Open Talk", "text": "…", "page": "/apps/spreed/" }] } |
POST |
/apps/introvox/admin/settings |
Body with any of enabled, userMenu and checklist (any of enabled, tasks as a list of task ids, custom as the whole list of own tasks — without id for a new one — and onDashboard); only what is sent changes. Own tasks that do not pass give 400 with error title, text, page, too_many or invalid, and nothing changes. Showing the welcome tour again is its reshow, above |
GET |
/apps/introvox/admin/languages |
List every Nextcloud-supported language (for the "Add language override" picker), with native display names |
GET |
/apps/introvox/admin/overrides |
Languages for the step editor's dropdown (query: ?tour=<id>). Welcome tour: en plus every language with an override row. Another tour: its main language plus every language it has steps in |
GET |
/apps/introvox/admin/groups |
Get list of Nextcloud groups for the Visible to groups dropdown |
Media¶
| Method | URL | Purpose |
|---|---|---|
POST |
/apps/introvox/admin/media |
Upload an image, video or captions file for a step (form field file; kind: media or captions) |
POST |
/apps/introvox/admin/media/from-files |
Copy one in from the admin's own Files (path, kind) |
Statistics and Telemetry¶
| Method | URL | Purpose |
|---|---|---|
GET |
/apps/introvox/admin/statistics |
Instance totals and the welcome tour's counters, as telemetry sends them; per tour see GET /admin/tours/{id}/statistics |
POST |
/apps/introvox/admin/telemetry |
Toggle telemetry on/off |
POST |
/apps/introvox/admin/telemetry/send |
Manually trigger telemetry send |
POST |
/apps/introvox/admin/telemetry/asked |
Record that the admin tour asked the telemetry question |
POST |
/apps/introvox/admin/intro/done |
Record that this admin has seen the admin tour (admin_intro_done) |
License (optional subscription key)¶
LicenseController validates an optional subscription key against licenses.voxcloud.nl and reports usage to it. Nothing in IntroVox is limited by it, and the admin page does not call these routes.
| Method | URL | Purpose |
|---|---|---|
GET |
/apps/introvox/admin/license/stats |
The key's status (masked) and the step counts per language |
POST |
/apps/introvox/admin/license/settings |
Save the subscription key |
POST |
/apps/introvox/admin/license/validate |
Validate the key against the license server |
POST |
/apps/introvox/admin/license/usage |
Report current usage to the license server |
Step Object Schema¶
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique step identifier |
title |
string | Yes | Step heading, shown as plain text |
text |
string | No | Step body content; HTML, reduced to the supported tags where it is shown |
attachTo |
string | No | CSS selector for element highlight; empty for centered |
position |
string | No | One of right, left, top, bottom |
enabled |
boolean | No | Whether the step is shown (default true) |
visibleToGroups |
string[] | No | Group IDs that can see this step (default [] = all users) |
page |
string | No | App the step belongs to, e.g. /apps/spreed/; the tour opens it first. Empty = any page |
openFirst |
string | No | CSS selector of a button (usually a menu) the tour clicks before the step |
advance |
string | No | click: the step continues when the user clicks its element; input: Next works once the field the step points at (or the field inside it) has text; button (or absent): a Next button |
customTarget |
boolean | No | Set by the admin UI when the element of a default step was changed; the admin's selector then replaces the built-in one |
media |
object | No | An image or video: file (an IntroVox media id such as <32 hex>.png), alt, decorative, size (small, full, wide); for a video also playback (loop, controls) and captions (a .vtt media id) |
The steps of every tour use this schema. Saving and importing check every field's type and size (StepValidator); the HTML in text is cleaned where it is shown (src/utils/stepHtml.js).
Authentication¶
- Tour API (
/api/*,/media/*) — requires Nextcloud session - Personal API (
/personal/*) — requires Nextcloud session - Admin API (
/admin/*) — requires Nextcloud session and admin role; double-checked viaIGroupManager::isAdmin()(v1.5.0+)
No route is reachable without logging in: none has #[PublicPage]. Until 2.0.0, GET /api/steps did, and without a user it skipped the group filter, so anyone could read every step, group-restricted ones included.
CSRF Protection¶
Every endpoint requires a valid CSRF token, GET requests included, with two exceptions. GET /media/{file}, because an <img> or <video> cannot send a token: that route only reads, needs a logged-in user with strict cookies (which no cross-site request carries), and answers everything it refuses with the same 404. GET /tour/{id}, a share link from a mail, which cannot carry one either: it needs a logged-in user, changes nothing, and only redirects (see Share Links). Until 2.0.0 the tracking endpoints (POST /api/wizard/start, /complete, /skip) and the read-only GET endpoints skipped the check, so any website could fire the tracking calls on behalf of a logged-in visitor.
Use @nextcloud/axios from the frontend — it injects the token automatically. A bare fetch() sends no token and gets 412 Precondition Failed.
tests-js/controller-security.test.js fails the build when a route gets #[PublicPage], when any route other than the media route and the share link gets #[NoCSRFRequired], or when the frontend calls the API with fetch().
See Also¶
- Architecture Overview — system context
- Backend Architecture — controller and service details
- Tours — tours for apps, from the admin's side
- Group-Based Visibility —
visibleToGroupssemantics