openvibers/openvibe.space Commits
Expose published spaces to the Feed (#284)
Changes
@@@ -57,6 +57,11 @@ function createSpacesApi({ config, spaces, data, sites, takedowns, log = console} catch (err) { sendError(res, err); }};++ router.get('/gallery', async (req, res) => {++ try { send(res, 200, await spaces.galleryFeed({ before: req.query.cursor, limit: req.query.limit })); }++ catch (err) { sendError(res, err); }++ });++// Preflight: answered for the space's own origin only.router.options('/:id/*', async (req, res) => {try {@@@ -72,6 +72,9 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outbgallery: db.prepare(`SELECT * FROM space_apps WHERE status = 'active' AND site_id IS NOT NULL AND visibility = 'public'AND (? = '' OR name ILIKE ? OR description ILIKE ? OR owner_username ILIKE ?)ORDER BY CASE WHEN ? = 'popular' THEN forks ELSE 0 END DESC, published_at DESC LIMIT ?`),++ galleryFeed: db.prepare(`SELECT * FROM space_apps WHERE status = 'active' AND site_id IS NOT NULL AND visibility = 'public'++ AND (?::bigint IS NULL OR published_at < ?::bigint OR (published_at = ?::bigint AND id < ?))++ ORDER BY published_at DESC, id DESC LIMIT ?`),counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),@@@ -513,11 +516,28 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb}return out;}++ async function galleryFeed({ before = null, limit = 40 } = {}) {++ const match = before ? /^(\d{10,16}):(spc_[0-9A-HJKMNP-TV-Z]{26})$/.exec(String(before)) : null;++ if (before && !match) throw new ApiError(400, 'space.invalid_cursor', 'invalid gallery cursor');++ const at = match ? Number(match[1]) : null;++ const id = match ? match[2] : '';++ const take = Math.min(100, Math.max(1, Number.parseInt(limit, 10) || 40));++ const rows = await q.galleryFeed.all(at, at, at, id, take + 1);++ const visible = [];++ for (const r of rows.slice(0, take)) {++ const site = await sites.get(r.site_id);++ if (site && site.status === 'active' && !(takedowns && await takedowns.ofSite(site))) visible.push(r);++ }++ const last = rows.length > take ? rows[take - 1] : null;++ return { spaces: visible.map((r) => ({ id: r.id, slug: r.slug, name: r.name, description: r.description,++ url: urlOf(r), owner_name: r.owner_name, owner_username: r.owner_username,++ published_at: Number(r.published_at) })), next_cursor: last ? `${last.published_at}:${last.id}` : null };++ }async function mine(viewer) { return await q.mine.all(personOf(viewer).subject); }return {CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,-- startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, mine, hostnameOf, urlOf,++ startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, galleryFeed, mine, hostnameOf, urlOf,personOf, capsOf, checkFiles, DAY,// The person's one agent (plan 2026-10-22 §Z stage Z1)ownedOf, mineOf, viewerOf, setAgentTask, setAgentState, setAgentStep, agentRunning,@@@ -171,6 +171,10 @@ function fakeAi() {});await check('the gallery and the sitemap list a published public space; its page embeds it', async () => {++ const feed = await t.api('GET', '/api/v1/apps/gallery?limit=1');++ assert.strictEqual(feed.status, 200, feed.text);++ assert.ok(feed.json().spaces.some((x) => x.id === space.id && x.url === url));++ assert.strictEqual((await t.api('GET', '/api/v1/apps/gallery?cursor=bad')).status, 400);assert.match((await t.api('GET', '/spaces')).text, new RegExp(`href="/spaces/${slug}"`));assert.match((await t.api('GET', '/sitemap.xml')).text, new RegExp(`/spaces/${slug}</loc>`));const p = await t.api('GET', `/spaces/${slug}`, { session: bob });@@@ -291,6 +295,7 @@ function fakeAi() {await check('unpublishing stops the address and the API; deleting removes the space', async () => {await edit(alice, 'POST', `/spaces/${slug}/unpublish`);++ assert.ok(!(await t.api('GET', '/api/v1/apps/gallery')).json().spaces.some((x) => x.id === space.id));assert.notStrictEqual((await t.get(`${slug}.openvibe.website`, '/')).status, 200);assert.strictEqual((await t.api('GET', `/api/v1/apps/${space.id}/items/notes`, { headers: { origin: url } })).status, 404);assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: bob })).status, 404, 'a draft again');
@@ -57,6 +57,11 @@ function createSpacesApi({ config, spaces, data, sites, takedowns, log = console
@@ -57,6 +57,11 @@ function createSpacesApi({ config, spaces, data, sites, takedowns, log = console
} catch (err) { sendError(res, err); }
} catch (err) { sendError(res, err); }
};
};
+ router.get('/gallery', async (req, res) => {
+ try { send(res, 200, await spaces.galleryFeed({ before: req.query.cursor, limit: req.query.limit })); }
+ catch (err) { sendError(res, err); }
+ });
+
// Preflight: answered for the space's own origin only.
// Preflight: answered for the space's own origin only.
router.options('/:id/*', async (req, res) => {
router.options('/:id/*', async (req, res) => {
try {
try {
@@ -72,6 +72,9 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -72,6 +72,9 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
gallery: db.prepare(`SELECT * FROM space_apps WHERE status = 'active' AND site_id IS NOT NULL AND visibility = 'public'
gallery: db.prepare(`SELECT * FROM space_apps WHERE status = 'active' AND site_id IS NOT NULL AND visibility = 'public'
AND (? = '' OR name ILIKE ? OR description ILIKE ? OR owner_username ILIKE ?)
AND (? = '' OR name ILIKE ? OR description ILIKE ? OR owner_username ILIKE ?)
ORDER BY CASE WHEN ? = 'popular' THEN forks ELSE 0 END DESC, published_at DESC LIMIT ?`),
ORDER BY CASE WHEN ? = 'popular' THEN forks ELSE 0 END DESC, published_at DESC LIMIT ?`),
+ galleryFeed: db.prepare(`SELECT * FROM space_apps WHERE status = 'active' AND site_id IS NOT NULL AND visibility = 'public'
+ AND (?::bigint IS NULL OR published_at < ?::bigint OR (published_at = ?::bigint AND id < ?))
+ ORDER BY published_at DESC, id DESC LIMIT ?`),
counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)
counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)
ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),
ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),
counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),
counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),
@@ -513,11 +516,28 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -513,11 +516,28 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
}
}
return out;
return out;
}
}
+ async function galleryFeed({ before = null, limit = 40 } = {}) {
+ const match = before ? /^(\d{10,16}):(spc_[0-9A-HJKMNP-TV-Z]{26})$/.exec(String(before)) : null;
+ if (before && !match) throw new ApiError(400, 'space.invalid_cursor', 'invalid gallery cursor');
+ const at = match ? Number(match[1]) : null;
+ const id = match ? match[2] : '';
+ const take = Math.min(100, Math.max(1, Number.parseInt(limit, 10) || 40));
+ const rows = await q.galleryFeed.all(at, at, at, id, take + 1);
+ const visible = [];
+ for (const r of rows.slice(0, take)) {
+ const site = await sites.get(r.site_id);
+ if (site && site.status === 'active' && !(takedowns && await takedowns.ofSite(site))) visible.push(r);
+ }
+ const last = rows.length > take ? rows[take - 1] : null;
+ return { spaces: visible.map((r) => ({ id: r.id, slug: r.slug, name: r.name, description: r.description,
+ url: urlOf(r), owner_name: r.owner_name, owner_username: r.owner_username,
+ published_at: Number(r.published_at) })), next_cursor: last ? `${last.published_at}:${last.id}` : null };
+ }
async function mine(viewer) { return await q.mine.all(personOf(viewer).subject); }
async function mine(viewer) { return await q.mine.all(personOf(viewer).subject); }
return {
return {
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
- startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, mine, hostnameOf, urlOf,
+ startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, galleryFeed, mine, hostnameOf, urlOf,
personOf, capsOf, checkFiles, DAY,
personOf, capsOf, checkFiles, DAY,
// The person's one agent (plan 2026-10-22 §Z stage Z1)
// The person's one agent (plan 2026-10-22 §Z stage Z1)
ownedOf, mineOf, viewerOf, setAgentTask, setAgentState, setAgentStep, agentRunning,
ownedOf, mineOf, viewerOf, setAgentTask, setAgentState, setAgentStep, agentRunning,
@@ -171,6 +171,10 @@ function fakeAi() {
@@ -171,6 +171,10 @@ function fakeAi() {
});
});
await check('the gallery and the sitemap list a published public space; its page embeds it', async () => {
await check('the gallery and the sitemap list a published public space; its page embeds it', async () => {
+ const feed = await t.api('GET', '/api/v1/apps/gallery?limit=1');
+ assert.strictEqual(feed.status, 200, feed.text);
+ assert.ok(feed.json().spaces.some((x) => x.id === space.id && x.url === url));
+ assert.strictEqual((await t.api('GET', '/api/v1/apps/gallery?cursor=bad')).status, 400);
assert.match((await t.api('GET', '/spaces')).text, new RegExp(`href="/spaces/${slug}"`));
assert.match((await t.api('GET', '/spaces')).text, new RegExp(`href="/spaces/${slug}"`));
assert.match((await t.api('GET', '/sitemap.xml')).text, new RegExp(`/spaces/${slug}</loc>`));
assert.match((await t.api('GET', '/sitemap.xml')).text, new RegExp(`/spaces/${slug}</loc>`));
const p = await t.api('GET', `/spaces/${slug}`, { session: bob });
const p = await t.api('GET', `/spaces/${slug}`, { session: bob });
@@ -291,6 +295,7 @@ function fakeAi() {
@@ -291,6 +295,7 @@ function fakeAi() {
await check('unpublishing stops the address and the API; deleting removes the space', async () => {
await check('unpublishing stops the address and the API; deleting removes the space', async () => {
await edit(alice, 'POST', `/spaces/${slug}/unpublish`);
await edit(alice, 'POST', `/spaces/${slug}/unpublish`);
+ assert.ok(!(await t.api('GET', '/api/v1/apps/gallery')).json().spaces.some((x) => x.id === space.id));
assert.notStrictEqual((await t.get(`${slug}.openvibe.website`, '/')).status, 200);
assert.notStrictEqual((await t.get(`${slug}.openvibe.website`, '/')).status, 200);
assert.strictEqual((await t.api('GET', `/api/v1/apps/${space.id}/items/notes`, { headers: { origin: url } })).status, 404);
assert.strictEqual((await t.api('GET', `/api/v1/apps/${space.id}/items/notes`, { headers: { origin: url } })).status, 404);
assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: bob })).status, 404, 'a draft again');
assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: bob })).status, 404, 'a draft again');