mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-03 20:43:22 +00:00
fix(hosting): track trashed roots and cached backing misses (#4363)
This commit is contained in:
1 parent
e2f6c2ed05
commit
bbc056cc49
8 files changed
+444
-72
No files matched your search
@@ -2591,3 +2591,68 @@ describe('createPuterSiteMiddleware — private-app gate decisions', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('site rooted in the owner tree, root moved to Trash', () => {
|
||||
it('stops serving a trashed folder and resumes when restored', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const dir = await server.services.fs.mkdir(owner.id, {
|
||||
path: `/${owner.username}/Documents/site`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const body = Buffer.from('<html>trashed</html>');
|
||||
await server.services.fs.write(owner.id, {
|
||||
fileMetadata: {
|
||||
path: `/${owner.username}/Documents/site/index.html`,
|
||||
size: body.byteLength,
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
const sub = `tr-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: dir.id,
|
||||
});
|
||||
const serveIndex = async () => {
|
||||
const mw = createPuterSiteMiddleware(hostingConfig, {
|
||||
clients: server.clients,
|
||||
stores: server.stores,
|
||||
services: server.services,
|
||||
});
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
expect((await serveIndex()).statusCode).toBe(200);
|
||||
|
||||
const trash = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Trash`,
|
||||
))!;
|
||||
const entry = (await server.stores.fsEntry.getEntryById(dir.id))!;
|
||||
await server.services.fs.move(owner.id, {
|
||||
source: entry,
|
||||
destinationParent: trash,
|
||||
});
|
||||
const moved = (await server.stores.fsEntry.getEntryById(dir.id))!;
|
||||
expect(moved.path.startsWith(`/${owner.username}/Trash/`)).toBe(true);
|
||||
|
||||
const out = await serveIndex();
|
||||
expect(out.statusCode).toBe(404);
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Documents`,
|
||||
))!;
|
||||
await server.services.fs.move(owner.id, {
|
||||
source: moved,
|
||||
destinationParent: documents,
|
||||
});
|
||||
expect((await serveIndex()).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
import { awaitLater } from '../../../util/concurrency';
|
||||
import { isTrashedPath } from '../../../util/hostedAppBacking';
|
||||
import { makeActor } from '../../actor';
|
||||
import {
|
||||
buildAppCenterFallback,
|
||||
@@ -109,10 +110,6 @@ const isServableEntryPath = (
|
||||
return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath);
|
||||
};
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
|
||||
/**
|
||||
* Suggested value for `config.hosting_csp`. Not applied unless configured.
|
||||
*
|
||||
@@ -574,7 +571,7 @@ export const createPuterSiteMiddleware = (
|
||||
.send(SUBDOMAIN_404);
|
||||
return;
|
||||
}
|
||||
if (!rootEntry.isDir) {
|
||||
if (!rootEntry.isDir || isTrashedPath(rootEntry.path)) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send(SUBDOMAIN_404);
|
||||
@@ -584,37 +581,27 @@ export const createPuterSiteMiddleware = (
|
||||
// A site rooted in someone else's directory stands on the `manage`
|
||||
// grant that authorized publishing it, and serves that whole subtree
|
||||
// with the ACL bypassed — so the grant is re-read on every request.
|
||||
// Trash is its own check: a grant is keyed on the node, not its
|
||||
// location, so it survives the owner deleting the directory. Sites
|
||||
// rooted in their publisher's own tree skip all of this.
|
||||
if (rootEntry.userId !== site.user_id) {
|
||||
let stillPublishable = false;
|
||||
if (!isTrashedPath(rootEntry.path)) {
|
||||
try {
|
||||
stillPublishable = await layers.services.acl.check(
|
||||
makeActor({
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: rootEntry.path,
|
||||
resolveAncestors: () =>
|
||||
layers.services.fs.getAncestorChain(
|
||||
rootEntry.path,
|
||||
),
|
||||
try {
|
||||
stillPublishable = await layers.services.acl.check(
|
||||
makeActor({
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
MANAGE_PERM_PREFIX,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail closed — an unreadable grant is not a held one.
|
||||
console.warn(
|
||||
'[puter-site] publish grant recheck failed',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{
|
||||
path: rootEntry.path,
|
||||
resolveAncestors: () =>
|
||||
layers.services.fs.getAncestorChain(rootEntry.path),
|
||||
},
|
||||
MANAGE_PERM_PREFIX,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail closed — an unreadable grant is not a held one.
|
||||
console.warn('[puter-site] publish grant recheck failed', e);
|
||||
}
|
||||
if (!stillPublishable) {
|
||||
res.status(404)
|
||||
|
||||
@@ -2322,7 +2322,10 @@ describe('AppDriver.select query-count regression', () => {
|
||||
expect(large.itemCount).toBe(30);
|
||||
// Sanity check the classifier actually saw the shapes it's counting.
|
||||
expect(small.counts.indexUrlIn).toBeGreaterThan(0);
|
||||
expect(small.counts.subdomains).toBeGreaterThan(0);
|
||||
// Live and dangling backings both come from the warm subdomain cache
|
||||
// (negative entries included), so no subdomains read on either path.
|
||||
expect(small.counts.subdomains).toBe(0);
|
||||
expect(small.pread).toBe(0);
|
||||
|
||||
expect(large.counts).toEqual(small.counts);
|
||||
expect(large.pread).toBe(small.pread);
|
||||
|
||||
@@ -69,8 +69,10 @@ describe('SubdomainStore cache coherency', () => {
|
||||
const row = await store.create({ userId, subdomain, rootDirId: 111 });
|
||||
|
||||
// Force the next lookup down the cache-miss path, which is where the
|
||||
// read populates the cache.
|
||||
// read populates the cache. Past the create's change window, so the
|
||||
// read takes the replica path.
|
||||
await server.clients.redis.del(cacheKey(subdomain));
|
||||
await server.clients.redis.del(`${cacheKey(subdomain)}:deleted`);
|
||||
|
||||
// Stall just the read path's own `set`. The update path writes
|
||||
// through `pipeline()`, so this leaves it untouched and lets the
|
||||
@@ -323,6 +325,27 @@ describe('SubdomainStore in a peer region behind a lagging replica', () => {
|
||||
expect(found?.root_dir_id).toBe(2);
|
||||
});
|
||||
|
||||
it('finds a just-created site, and caches no miss, behind a lagging replica', async () => {
|
||||
const userId = await makeUser();
|
||||
const name = newName('create');
|
||||
const other = newName('create-batch');
|
||||
|
||||
await applyOnPeer(name, null, async () => {
|
||||
await store.create({ userId, subdomain: name, rootDirId: 1 });
|
||||
await store.create({ userId, subdomain: other, rootDirId: 2 });
|
||||
});
|
||||
await server.clients.redis.del(cacheKey(other));
|
||||
|
||||
const [found, batch] = await withLaggingReplica([], async () => [
|
||||
await store.getBySubdomain(name),
|
||||
await store.getBySubdomains([other]),
|
||||
]);
|
||||
expect(found?.root_dir_id).toBe(1);
|
||||
expect(batch.get(other)?.root_dir_id).toBe(2);
|
||||
expect(await settledCache(name)).not.toBe('__none__');
|
||||
expect(await settledCache(other)).not.toBe('__none__');
|
||||
});
|
||||
|
||||
it('sends a recently changed name in a batch to the primary', async () => {
|
||||
const userId = await makeUser();
|
||||
const name = newName('batch');
|
||||
@@ -1038,6 +1061,37 @@ describe('SubdomainStore.getBySubdomains', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reports negative-cache hits, and only those, in `cachedMisses`', async () => {
|
||||
const userId = await makeUser();
|
||||
const cachedGone = `gbs-cm-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const uncachedGone = `gbs-cm-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const live = `gbs-cm-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await store.create({ userId, subdomain: live });
|
||||
await server.clients.redis.set(cacheKey(cachedGone), '__none__');
|
||||
|
||||
const batchMisses = new Set<string>();
|
||||
const found = await store.getBySubdomains(
|
||||
[cachedGone, uncachedGone, live],
|
||||
{ cachedMisses: batchMisses },
|
||||
);
|
||||
expect([...found.keys()]).toEqual([live]);
|
||||
expect([...batchMisses]).toEqual([cachedGone]);
|
||||
|
||||
const singleMisses = new Set<string>();
|
||||
expect(
|
||||
await store.getBySubdomain(cachedGone, {
|
||||
cachedMisses: singleMisses,
|
||||
}),
|
||||
).toBeNull();
|
||||
await server.clients.redis.del(cacheKey(uncachedGone));
|
||||
expect(
|
||||
await store.getBySubdomain(uncachedGone, {
|
||||
cachedMisses: singleMisses,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect([...singleMisses]).toEqual([cachedGone]);
|
||||
});
|
||||
|
||||
it('treats a cached JSON null as a miss and reads the DB, like getBySubdomain', async () => {
|
||||
const owner = await makeUser();
|
||||
const name = `gbs-null-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -1071,3 +1125,27 @@ describe('SubdomainStore.getBySubdomains', () => {
|
||||
expect(healedFound.get(name)?.subdomain).toBe(name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubdomainStore.trashedRootDirIds', () => {
|
||||
it('names the root dirs that sit in their owner Trash', async () => {
|
||||
const fsEntry = server.stores.fsEntry;
|
||||
const spy = vi.spyOn(fsEntry, 'getEntriesByIds').mockResolvedValue(
|
||||
new Map([
|
||||
[1, { id: 1, path: '/alice/Trash/uid-1' }],
|
||||
[2, { id: 2, path: '/alice/Trash' }],
|
||||
[3, { id: 3, path: '/alice/Documents/Trash/site' }],
|
||||
[4, { id: 4, path: '/alice/Trashy' }],
|
||||
[5, { id: 5, path: '/alice/Public' }],
|
||||
]) as never,
|
||||
);
|
||||
try {
|
||||
expect([
|
||||
...(await store.trashedRootDirIds([1, 2, 3, 4, 5])),
|
||||
]).toEqual([1, 2]);
|
||||
expect(await store.trashedRootDirIds([])).toEqual(new Set());
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { escapeLike } from '../../util/sqlLike';
|
||||
import { isTrashedPath } from '../../util/hostedAppBacking';
|
||||
import { runWithConcurrencyLimit } from '../../util/concurrency';
|
||||
import { CACHE_TOMBSTONE_TTL_SECONDS, PuterStore } from '../types';
|
||||
|
||||
@@ -155,10 +156,16 @@ export class SubdomainStore extends PuterStore {
|
||||
* which may hold a stale negative marker — and reads the primary instead of
|
||||
* a possibly-lagging replica. The result still refreshes the cache, healing
|
||||
* any stale marker.
|
||||
*
|
||||
* `cachedMisses` collects the name when the null came from a negative cache
|
||||
* entry rather than a database read.
|
||||
*/
|
||||
async getBySubdomain(
|
||||
subdomain: string,
|
||||
{ primary = false }: { primary?: boolean } = {},
|
||||
{
|
||||
primary = false,
|
||||
cachedMisses,
|
||||
}: { primary?: boolean; cachedMisses?: Set<string> } = {},
|
||||
): Promise<SubdomainRow | null> {
|
||||
if (!subdomain) return null;
|
||||
|
||||
@@ -166,7 +173,10 @@ export class SubdomainStore extends PuterStore {
|
||||
if (!primary) {
|
||||
try {
|
||||
const raw = await this.clients.redis.get(cacheKey);
|
||||
if (raw === NEGATIVE_CACHE_MARKER) return null;
|
||||
if (raw === NEGATIVE_CACHE_MARKER) {
|
||||
cachedMisses?.add(subdomain);
|
||||
return null;
|
||||
}
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as SubdomainRow | null;
|
||||
if (parsed) return parsed;
|
||||
@@ -205,11 +215,15 @@ export class SubdomainStore extends PuterStore {
|
||||
* Batched sibling of {@link getBySubdomain} — one cache pipeline plus one
|
||||
* chunked `IN (…)` query for the misses, instead of one lookup per name.
|
||||
* Keyed by the requested names; a name with no row (negative-cached or
|
||||
* genuinely absent) is simply missing from the returned map.
|
||||
* genuinely absent) is simply missing from the returned map. `cachedMisses`
|
||||
* collects the names that were negative-cached.
|
||||
*/
|
||||
async getBySubdomains(
|
||||
names: string[],
|
||||
{ primary = false }: { primary?: boolean } = {},
|
||||
{
|
||||
primary = false,
|
||||
cachedMisses,
|
||||
}: { primary?: boolean; cachedMisses?: Set<string> } = {},
|
||||
): Promise<Map<string, SubdomainRow>> {
|
||||
const result = new Map<string, SubdomainRow>();
|
||||
const uniqueNames = [
|
||||
@@ -233,7 +247,10 @@ export class SubdomainStore extends PuterStore {
|
||||
for (let i = 0; i < uniqueNames.length; i++) {
|
||||
const name = uniqueNames[i]!;
|
||||
const raw = cacheResults[i]?.[1];
|
||||
if (raw === NEGATIVE_CACHE_MARKER) continue; // cached miss
|
||||
if (raw === NEGATIVE_CACHE_MARKER) {
|
||||
cachedMisses?.add(name);
|
||||
continue;
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
@@ -324,6 +341,23 @@ export class SubdomainStore extends PuterStore {
|
||||
return rowsByName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `rootDirIds` sit in their owner's Trash, where sites don't
|
||||
* serve.
|
||||
*/
|
||||
async trashedRootDirIds(rootDirIds: number[]): Promise<Set<number>> {
|
||||
const trashed = new Set<number>();
|
||||
const fsEntry = this.stores?.fsEntry;
|
||||
if (rootDirIds.length === 0 || !fsEntry?.getEntriesByIds) {
|
||||
return trashed;
|
||||
}
|
||||
const entries = await fsEntry.getEntriesByIds(rootDirIds);
|
||||
for (const [id, entry] of entries) {
|
||||
if (entry?.path && isTrashedPath(entry.path)) trashed.add(id);
|
||||
}
|
||||
return trashed;
|
||||
}
|
||||
|
||||
async listByUserId(
|
||||
userId: number,
|
||||
{
|
||||
@@ -599,7 +633,9 @@ export class SubdomainStore extends PuterStore {
|
||||
preamble_version: preambleVersion,
|
||||
protected: isProtected ? 1 : 0,
|
||||
};
|
||||
await this.#refreshCache(row);
|
||||
// Tombstoned so a peer whose replica lacks the row yet can't cache a
|
||||
// negative marker for the new name.
|
||||
await this.#publishChanged([row as unknown as SubdomainRow]);
|
||||
await this.#invalidateRootDirEntry(row.root_dir_id);
|
||||
|
||||
return row;
|
||||
@@ -924,16 +960,6 @@ export class SubdomainStore extends PuterStore {
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshCache(row: { subdomain?: string }) {
|
||||
if (!row?.subdomain) return;
|
||||
await this.publishCacheKeys({
|
||||
keys: [this.#cacheKey(row.subdomain)],
|
||||
serializedData: JSON.stringify(row),
|
||||
ttlSeconds: CACHE_TTL_SECONDS,
|
||||
broadcast: true,
|
||||
});
|
||||
}
|
||||
|
||||
// FSEntryStore caches each row with an embedded `subdomains_agg` JSON
|
||||
// (uuid + subdomain) keyed on `root_dir_id`. Without this, a deleted or
|
||||
// renamed subdomain keeps showing up under its old folder in the GUI
|
||||
|
||||
@@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PuterServer } from '../server.js';
|
||||
import { setupTestServer } from '../testUtil.js';
|
||||
import { generateDefaultFsentries } from './userProvisioning.js';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
buildHostedSubdomainIndexUrlCandidates,
|
||||
@@ -272,6 +273,71 @@ describe('hostedIndexUrlBackingIsUnavailable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('trusts a negative-cache hit without asking the primary', async () => {
|
||||
const getBySubdomain = vi.fn(
|
||||
async (sub: string, opts?: { cachedMisses?: Set<string> }) => {
|
||||
opts?.cachedMisses?.add(sub);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await hostedIndexUrlBackingIsUnavailable({
|
||||
app: {
|
||||
index_url: 'https://gone.puter.site/',
|
||||
owner_user_id: 7,
|
||||
},
|
||||
subdomainStore: { getBySubdomain } as never,
|
||||
config: CONFIG,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(getBySubdomain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is true when the site root is in Trash, and false once it is not', async () => {
|
||||
const trashed = new Set([5]);
|
||||
const subdomainStore = {
|
||||
getBySubdomain: async () => ({ user_id: 7, root_dir_id: 5 }),
|
||||
trashedRootDirIds: vi.fn(async () => trashed),
|
||||
} as never;
|
||||
const app = { index_url: 'https://mine.puter.site/', owner_user_id: 7 };
|
||||
expect(
|
||||
await hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
subdomainStore,
|
||||
config: CONFIG,
|
||||
}),
|
||||
).toBe(true);
|
||||
trashed.clear();
|
||||
expect(
|
||||
await hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
subdomainStore,
|
||||
config: CONFIG,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('counts the site as served when the Trash lookup fails', async () => {
|
||||
expect(
|
||||
await hostedIndexUrlBackingIsUnavailable({
|
||||
app: {
|
||||
index_url: 'https://mine.puter.site/',
|
||||
owner_user_id: 7,
|
||||
},
|
||||
subdomainStore: {
|
||||
getBySubdomain: async () => ({
|
||||
user_id: 7,
|
||||
root_dir_id: 5,
|
||||
}),
|
||||
trashedRootDirIds: async () => {
|
||||
throw new Error('fs down');
|
||||
},
|
||||
} as never,
|
||||
config: CONFIG,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when either owner id is not a usable integer', async () => {
|
||||
// `undefined` and non-numeric strings coerce to NaN, so neither
|
||||
// side can be compared — withhold rather than guess.
|
||||
@@ -513,4 +579,98 @@ describe('hostedIndexUrlBackingsAreUnavailable (batched, against a real store)',
|
||||
getBySubdomains.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips the primary for a name already negative-cached', async () => {
|
||||
const owner = await makeUser();
|
||||
const dangling = {
|
||||
index_url: hostedUrl(
|
||||
`gone-c-${Math.random().toString(36).slice(2, 6)}`,
|
||||
),
|
||||
owner_user_id: owner.id,
|
||||
};
|
||||
// The first call reads the primary and caches the miss.
|
||||
expect(
|
||||
await hostedIndexUrlBackingsAreUnavailable({
|
||||
apps: [dangling],
|
||||
subdomainStore: server.stores.subdomain,
|
||||
config: HOSTING_CONFIG,
|
||||
}),
|
||||
).toEqual([true]);
|
||||
|
||||
const getBySubdomains = vi.spyOn(
|
||||
server.stores.subdomain,
|
||||
'getBySubdomains',
|
||||
);
|
||||
const pread = vi.spyOn(server.clients.db, 'pread');
|
||||
try {
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
await hostedIndexUrlBackingsAreUnavailable({
|
||||
apps: [dangling],
|
||||
subdomainStore: server.stores.subdomain,
|
||||
config: HOSTING_CONFIG,
|
||||
}),
|
||||
).toEqual([true]),
|
||||
);
|
||||
getBySubdomains.mockClear();
|
||||
pread.mockClear();
|
||||
expect(
|
||||
await hostedIndexUrlBackingsAreUnavailable({
|
||||
apps: [dangling],
|
||||
subdomainStore: server.stores.subdomain,
|
||||
config: HOSTING_CONFIG,
|
||||
}),
|
||||
).toEqual([true]);
|
||||
expect(getBySubdomains).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
pread.mock.calls.filter(([sql]) =>
|
||||
String(sql).includes('FROM `subdomains`'),
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
getBySubdomains.mockRestore();
|
||||
pread.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a site rooted in Trash as unavailable', async () => {
|
||||
const owner = await makeUser();
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
owner,
|
||||
);
|
||||
const trash = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Trash`,
|
||||
);
|
||||
const publicDir = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Public`,
|
||||
);
|
||||
const trashedSub = `trashed-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const liveSub = `served-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: trashedSub,
|
||||
rootDirId: trash!.id,
|
||||
});
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: liveSub,
|
||||
rootDirId: publicDir!.id,
|
||||
});
|
||||
|
||||
expect(
|
||||
await hostedIndexUrlBackingsAreUnavailable({
|
||||
apps: [
|
||||
{
|
||||
index_url: hostedUrl(trashedSub),
|
||||
owner_user_id: owner.id,
|
||||
},
|
||||
{ index_url: hostedUrl(liveSub), owner_user_id: owner.id },
|
||||
],
|
||||
subdomainStore: server.stores.subdomain,
|
||||
config: HOSTING_CONFIG,
|
||||
}),
|
||||
).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,10 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess';
|
||||
|
||||
export const HOSTED_BACKING_UNAVAILABLE_REASON = 'hosted_backing_unavailable';
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
export const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
|
||||
interface HostedDomainConfig {
|
||||
static_hosting_domain?: unknown;
|
||||
static_hosting_domain_alt?: unknown;
|
||||
@@ -58,6 +62,13 @@ interface AppBackingRow {
|
||||
owner_user_id?: unknown;
|
||||
}
|
||||
|
||||
interface BackingRow {
|
||||
user_id?: unknown;
|
||||
root_dir_id?: unknown;
|
||||
}
|
||||
|
||||
type TrashLookup = Partial<Pick<SubdomainStore, 'trashedRootDirIds'>>;
|
||||
|
||||
function normalizeHostedDomainValue(domainValue: unknown): string | null {
|
||||
if (typeof domainValue !== 'string') return null;
|
||||
const normalizedDomain = domainValue
|
||||
@@ -187,7 +198,7 @@ export function extractPuterHostedSubdomain(
|
||||
*/
|
||||
function backingRowIsUnavailable(
|
||||
app: AppBackingRow,
|
||||
row: { user_id?: unknown } | null,
|
||||
row: BackingRow | null,
|
||||
): boolean {
|
||||
if (!row) return true; // subdomain no longer exists → dangling
|
||||
|
||||
@@ -200,9 +211,37 @@ function backingRowIsUnavailable(
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the app's puter-hosted subdomain is missing, or is currently owned
|
||||
* by a different user than the app's owner (it was reclaimed — launching would
|
||||
* leak the token to the new owner). Non-hosted index_urls return false.
|
||||
* Root dir ids of `rows` that are in Trash, where the site 404s. Best-effort:
|
||||
* the owner's own site is no token risk, so a failed lookup counts as served.
|
||||
*/
|
||||
async function trashedRootsOf(
|
||||
rows: Array<BackingRow | null>,
|
||||
subdomainStore: TrashLookup,
|
||||
): Promise<Set<number>> {
|
||||
if (!subdomainStore.trashedRootDirIds) return new Set();
|
||||
const ids = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => Number(row?.root_dir_id ?? NaN))
|
||||
.filter((id) => Number.isInteger(id)),
|
||||
),
|
||||
];
|
||||
if (ids.length === 0) return new Set();
|
||||
try {
|
||||
return await subdomainStore.trashedRootDirIds(ids);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const rootIsTrashed = (row: BackingRow | null, trashed: Set<number>) =>
|
||||
row?.root_dir_id != null && trashed.has(Number(row.root_dir_id));
|
||||
|
||||
/**
|
||||
* True when the app's puter-hosted subdomain is missing, is currently owned by
|
||||
* a different user than the app's owner (it was reclaimed — launching would
|
||||
* leak the token to the new owner), or is rooted in Trash. Non-hosted
|
||||
* index_urls return false.
|
||||
*/
|
||||
export async function hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
@@ -210,30 +249,34 @@ export async function hostedIndexUrlBackingIsUnavailable({
|
||||
config,
|
||||
}: {
|
||||
app: AppBackingRow;
|
||||
subdomainStore: Pick<SubdomainStore, 'getBySubdomain'>;
|
||||
subdomainStore: Pick<SubdomainStore, 'getBySubdomain'> & TrashLookup;
|
||||
config: HostedDomainConfig | undefined | null;
|
||||
}): Promise<boolean> {
|
||||
const subdomain = extractPuterHostedSubdomain(app.index_url, config);
|
||||
if (!subdomain) return false;
|
||||
|
||||
let row = await subdomainStore.getBySubdomain(subdomain);
|
||||
if (!row) {
|
||||
const cachedMisses = new Set<string>();
|
||||
let row = await subdomainStore.getBySubdomain(subdomain, { cachedMisses });
|
||||
if (!row && !cachedMisses.has(subdomain)) {
|
||||
// A freshly-created subdomain may not have reached a replica or the
|
||||
// local cache yet; confirm against the primary before treating the
|
||||
// backing as gone (mirrors the create/update ownership check in
|
||||
// `AppDriver#ensurePuterSiteSubdomainIsOwned`).
|
||||
// `AppDriver#ensurePuterSiteSubdomainIsOwned`). A negative-cache hit
|
||||
// needs no second look: creating a site clears and tombstones it.
|
||||
row = await subdomainStore.getBySubdomain(subdomain, {
|
||||
primary: true,
|
||||
});
|
||||
}
|
||||
return backingRowIsUnavailable(app, row);
|
||||
if (backingRowIsUnavailable(app, row)) return true;
|
||||
return rootIsTrashed(row, await trashedRootsOf([row], subdomainStore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched sibling of {@link hostedIndexUrlBackingIsUnavailable} — one
|
||||
* `getBySubdomains` call (plus a single primary follow-up for whatever's still
|
||||
* missing) instead of per-app round trips. Returns a boolean per `apps` entry,
|
||||
* in input order, with the same semantics as the single-app function above.
|
||||
* missing and not negative-cached) instead of per-app round trips. Returns a
|
||||
* boolean per `apps` entry, in input order, with the same semantics as the
|
||||
* single-app function above.
|
||||
*/
|
||||
export async function hostedIndexUrlBackingsAreUnavailable({
|
||||
apps,
|
||||
@@ -241,7 +284,7 @@ export async function hostedIndexUrlBackingsAreUnavailable({
|
||||
config,
|
||||
}: {
|
||||
apps: AppBackingRow[];
|
||||
subdomainStore: Pick<SubdomainStore, 'getBySubdomains'>;
|
||||
subdomainStore: Pick<SubdomainStore, 'getBySubdomains'> & TrashLookup;
|
||||
config: HostedDomainConfig | undefined | null;
|
||||
}): Promise<boolean[]> {
|
||||
const subdomains = apps.map((app) =>
|
||||
@@ -252,8 +295,13 @@ export async function hostedIndexUrlBackingsAreUnavailable({
|
||||
];
|
||||
if (names.length === 0) return apps.map(() => false);
|
||||
|
||||
let rowsByName = await subdomainStore.getBySubdomains(names);
|
||||
const missing = names.filter((name) => !rowsByName.has(name));
|
||||
const cachedMisses = new Set<string>();
|
||||
let rowsByName = await subdomainStore.getBySubdomains(names, {
|
||||
cachedMisses,
|
||||
});
|
||||
const missing = names.filter(
|
||||
(name) => !rowsByName.has(name) && !cachedMisses.has(name),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
const primaryRows = await subdomainStore.getBySubdomains(missing, {
|
||||
primary: true,
|
||||
@@ -261,10 +309,18 @@ export async function hostedIndexUrlBackingsAreUnavailable({
|
||||
rowsByName = new Map([...rowsByName, ...primaryRows]);
|
||||
}
|
||||
|
||||
return apps.map((app, i) => {
|
||||
const rows = apps.map((app, i) => {
|
||||
const subdomain = subdomains[i];
|
||||
if (!subdomain) return null;
|
||||
const row = rowsByName.get(subdomain) ?? null;
|
||||
return backingRowIsUnavailable(app, row) ? null : row;
|
||||
});
|
||||
const trashed = await trashedRootsOf(rows, subdomainStore);
|
||||
|
||||
return subdomains.map((subdomain, i) => {
|
||||
if (!subdomain) return false;
|
||||
return backingRowIsUnavailable(app, rowsByName.get(subdomain) ?? null);
|
||||
const row = rows[i] ?? null;
|
||||
return !row || rootIsTrashed(row, trashed);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1971,11 +1971,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
if ( is_trashing ) {
|
||||
// if trashing dir...
|
||||
if ( $(el_item).attr('data-is_dir') === '1' ) {
|
||||
// disassociate all its websites
|
||||
// todo, some client-side check to see if this dir has at least one associated website before sending ajax request
|
||||
// FIXME: dir_uuid is not defined, is this the same as the data-uid attribute?
|
||||
// puter.hosting.delete(dir_uuid)
|
||||
|
||||
// Its websites stay registered: the server doesn't
|
||||
// serve a root in Trash, and restoring brings them back.
|
||||
$(`.mywebsites-dir-path[data-uuid="${$(el_item).attr('data-uid')}"]`).remove();
|
||||
// remove the website badge from all instances of the dir
|
||||
$(`.item[data-uid="${$(el_item).attr('data-uid')}"]`).find('.item-has-website-badge').fadeOut(300);
|
||||
|
||||
Reference in new issue
Block a user