fix(hosting): track trashed roots and cached backing misses (#4363)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-26 21:58:05 -07:00
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);
});
});
+20 -33
View File
@@ -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)
+4 -1
View File
@@ -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();
}
});
});
+42 -16
View File
@@ -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
+160
View File
@@ -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]);
});
});
+72 -16
View File
@@ -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);
});
}
+2 -5
View File
@@ -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);