openvibers/openvibe.space Commits

Expose published spaces to the Feed (#284)

OP OpenVibers committed 2 days ago · b53794fb48 · 3 files, +31 −1

* Expose public spaces for network activity feed

* Advance public gallery cursor over consumed rows

Changes

ddiff --git a/server/spaces/api.js b/server/spaces/api.js
iindex c11ed58..8963ef4 100644
---- a/server/spaces/api.js
++++ b/server/spaces/api.js
@@@ -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 {
ddiff --git a/server/spaces/spaces.js b/server/spaces/spaces.js
iindex 50fb733..0f56f26 100644
---- a/server/spaces/spaces.js
++++ b/server/spaces/spaces.js
@@@ -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'
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,
ddiff --git a/test/spaces.test.js b/test/spaces.test.js
iindex cd83638..7e78f4f 100644
---- a/test/spaces.test.js
++++ b/test/spaces.test.js
@@@ -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');