diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index cd58da60c..a4d885bcd 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -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('trashed'); + 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((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); + }); +}); diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 77697c07e..fc5eb3e68 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -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) diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 7c7ecad76..f7edc9e97 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -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); diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts index af5dd10a5..3ed51f92c 100644 --- a/src/backend/stores/subdomain/SubdomainStore.test.ts +++ b/src/backend/stores/subdomain/SubdomainStore.test.ts @@ -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(); + const found = await store.getBySubdomains( + [cachedGone, uncachedGone, live], + { cachedMisses: batchMisses }, + ); + expect([...found.keys()]).toEqual([live]); + expect([...batchMisses]).toEqual([cachedGone]); + + const singleMisses = new Set(); + 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(); + } + }); +}); diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index e75173fcd..ce5018aa5 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -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 } = {}, ): Promise { 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 } = {}, ): Promise> { const result = new Map(); 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> { + const trashed = new Set(); + 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 diff --git a/src/backend/util/hostedAppBacking.test.ts b/src/backend/util/hostedAppBacking.test.ts index fc8bb1258..391c6d4d2 100644 --- a/src/backend/util/hostedAppBacking.test.ts +++ b/src/backend/util/hostedAppBacking.test.ts @@ -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 }) => { + 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]); + }); }); diff --git a/src/backend/util/hostedAppBacking.ts b/src/backend/util/hostedAppBacking.ts index d7b966c25..1f9f43e6b 100644 --- a/src/backend/util/hostedAppBacking.ts +++ b/src/backend/util/hostedAppBacking.ts @@ -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>; + 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, + subdomainStore: TrashLookup, +): Promise> { + 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) => + 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: Pick & TrashLookup; config: HostedDomainConfig | undefined | null; }): Promise { const subdomain = extractPuterHostedSubdomain(app.index_url, config); if (!subdomain) return false; - let row = await subdomainStore.getBySubdomain(subdomain); - if (!row) { + const cachedMisses = new Set(); + 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: Pick & TrashLookup; config: HostedDomainConfig | undefined | null; }): Promise { 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(); + 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); }); } diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 1e026122d..f8d38a1fd 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -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);