openvibers/openvibe.watch Commits

Watch: a link can start the new-watch form filled in (bounded values only) (#48)

OP OpenVibers committed 12 Oct 2026 · ea3e4eb2f8 · 2 files, +31 −1

A link from another OpenVibe site can open `/watches/new` with the form already filled in: `name`, `value_path`, `op` and `value` join the existing `url`. Host's "Watch this price" on a plan page uses it (template `json`, the plan's API URL, `value_path=plan.price_monthly`, `op=changed`).

These are form defaults only. The person still reviews and submits, and the POST validates everything as before. Each value is bounded:

- `name` is control-character free and at most 80 characters;
- `value_path` must match a path pattern;
- `op` must be one the template has;
- `value` is at most 200 characters.

Anything unexpected is dropped, never an error.

Test: a prefill case in `test/site.test.js`, including a malformed path and an unknown op. The full suite passes (16/16).

Changes

ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex aa0057f..9a86b37 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -508,6 +508,20 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl
const getTemplate = (q) => (TEMPLATE_NAMES.includes(String(q)) ? String(q) : 'page');
++ /**
++ * A link from another OpenVibe site may start the form filled in (Host's "Watch this price": template json, the plan's
++ * API URL, value_path plan.price_monthly, op changed). Only form defaults: the person still reviews and submits, and
++ * the POST validates everything as usual. Each value is bounded; anything unexpected is dropped, never an error.
++ */
++ function prefilled(template, q = {}) {
++ const fields = { ...blankFields(template), url: typeof q.url === 'string' ? q.url.slice(0, 2000) : '' };
++ if (typeof q.name === 'string') fields.name = q.name.replace(/[\u0000-\u001f]/g, '').slice(0, 80);
++ if (typeof q.value_path === 'string' && /^[A-Za-z0-9_$][A-Za-z0-9_.$\[\]-]{0,199}$/.test(q.value_path)) fields.value_path = q.value_path;
++ if (typeof q.op === 'string' && TEMPLATES[template].ops.includes(q.op)) fields.op = q.op;
++ if (typeof q.value === 'string') fields.value = q.value.slice(0, 200);
++ return fields;
++ }
++
r.get('/watches/new', (req, res) => {
if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
const template = getTemplate(req.query.template);
@@@ -516,7 +530,7 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl
body: html`<h1>New watch</h1>
<p class="muted">${TEMPLATES[template].title}.</p>
${templateChooser(template)}
--${watchForm({ template, fields: { ...blankFields(template), url: String(req.query.url || '') }, errors: {}, action: '/watches/new', submit: 'Create this watch' })}`,
++${watchForm({ template, fields: prefilled(template, req.query), errors: {}, action: '/watches/new', submit: 'Create this watch' })}`,
});
});
ddiff --git a/test/site.test.js b/test/site.test.js
iindex b6e19b3..bbca804 100644
---- a/test/site.test.js
++++ b/test/site.test.js
@@@ -141,6 +141,22 @@ t('notify me off stores an event action instead of a notification, and says so',
assert.deepStrictEqual(read.body.watch.action, [{ kind: 'event', event_type: 'watch.watch.triggered' }]);
});
++t('a link can start the form filled in, with bounded values only', async () => {
++ const q = new URLSearchParams({ template: 'json', url: 'https://openvibe.host/api/v1/catalog/plans/hpl_x', value_path: 'plan.price_monthly', op: 'changed', name: 'Linode Nanode 1GB price' });
++ const form = await svc.get(`/watches/new?${q}`, { cookie: session });
++ assert.strictEqual(form.status, 200);
++ assert.match(form.text, /value="https:\/\/openvibe\.host\/api\/v1\/catalog\/plans\/hpl_x"/);
++ assert.match(form.text, /value="plan\.price_monthly"/);
++ assert.match(form.text, /value="Linode Nanode 1GB price"/);
++ assert.match(form.text, /<option value="changed" selected/);
++ const odd = new URLSearchParams({ template: 'json', value_path: 'a b<script>', op: 'drop-table' });
++ const ignored = await svc.get(`/watches/new?${odd}`, { cookie: session });
++ assert.strictEqual(ignored.status, 200);
++ assert.doesNotMatch(ignored.text, /a b&lt;script&gt;|a b<script>/, 'a malformed path is dropped');
++ assert.doesNotMatch(ignored.text, /<option value="drop-table"/, 'an op the template does not have is dropped');
++ assert.match(ignored.text, /<option value="lt" selected/, 'the template default op stays selected');
++});
++
t('the plain create and edit forms save an Actor agent action', async () => {
const form = await svc.get('/watches/new?template=page', { cookie: session });
assert.match(form.text, /Run one of my agents on OpenVibe\.Actor/);