Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/server/src/navigation/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,37 @@ describe("navigation routes", () => {
expect(await repeated!.json()).toEqual(await added!.json());
});

it("removes a project once, only from the same origin, leaving it to be added again", async () => {
let { router, github, cookie } = await setup();
github.repositories.set("octo-org/score", repository("R_score", "octo-org", "score"));
let headers = { "content-type": "application/json", origin: "https://chopin.test" };
let add = () =>
router.handle(request("/api/navigation/projects", cookie, {
method: "POST",
headers,
body: JSON.stringify({ owner: "octo-org", repository: "score" }),
}));
let remove = (origin = "https://chopin.test", id = "R_score") =>
router.handle(request(`/api/navigation/projects/${id}`, cookie, {
method: "DELETE",
headers: { origin },
}));
expect((await add())!.status).toBe(201);
expect((await remove("https://elsewhere.test"))!.status).toBe(403);
expect(
(await router.handle(request(`/api/navigation/projects/R_score`, undefined, {
method: "DELETE",
headers: { origin: "https://chopin.test" },
})))!.status,
).toBe(401);
expect((await remove())!.status).toBe(204);
expect((await remove())!.status).toBe(404);
expect((await remove(undefined, "not%20an%20id"))!.status).toBe(404);
let listed = await (await router.handle(request("/api/navigation", cookie)))!.json();
expect(listed.projects).toEqual([]);
expect((await add())!.status).toBe(201);
});

it("rejects cross-origin navigation mutations", async () => {
let { router, cookie } = await setup();
let headers = { "content-type": "application/json", origin: "https://elsewhere.test" };
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/navigation/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,29 @@ export function registerNavigationRoutes(
}
});

/** Take a repository out of the person's projects. Their documents and access are unchanged. */
router.on("DELETE", "/api/navigation/projects/:repositoryId", async (request, _url, params) => {
if (!ownOrigin(auth.config, request.headers.get("origin"))) {
return json({ error: "origin is not allowed" }, 403);
}
try {
let session = await auth.sessions.authenticate(request);
if (!session) return json({ error: "authentication required" }, 401);
let repositoryId = params.repositoryId ?? "";
if (!/^[A-Za-z0-9_=-]{1,128}$/.test(repositoryId)) {
return json({ error: "project not found" }, 404);
}
let removed = await storage.navigation.removeProject(
session.user.id,
repositoryId,
auth.clock(),
);
return removed ? empty(204) : json({ error: "project not found" }, 404);
} catch (err) {
return failure(err);
}
});

router.on("PATCH", "/api/navigation", async request => {
if (!ownOrigin(auth.config, request.headers.get("origin"))) {
return json({ error: "origin is not allowed" }, 403);
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/storage/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,53 @@ export function storageContract(name: string, factory: Factory): void {
}
});

it("removes a project, forgetting the last document only when it is in that repository", async () => {
let storage = await opened(factory);
try {
let now = new Date("2026-01-02T03:04:05.000Z");
let { userId, channelId, repositoryId } = await userAndChannel(storage);
let channel = (await storage.channels.get(channelId))!;
for (let [id, name] of [[repositoryId, channel.repositoryName], ["R_other", "other"]]) {
await storage.navigation.addProject({
userId,
repositoryId: id!,
repositoryOwner: channel.repositoryOwner,
repositoryName: name!,
now,
});
}
let visited = await storage.navigation.setLastDocument(userId, channelId, now);

// Another repository: the last document stays.
expect(await storage.navigation.removeProject(userId, "R_other", now)).toBe(true);
expect(await storage.navigation.get(userId)).toEqual(visited);
expect(await storage.navigation.removeProject(userId, "R_other", now)).toBe(false);

// The last document's own repository: it is forgotten, with a new revision.
let later = new Date(now.getTime() + 1);
expect(await storage.navigation.removeProject(userId, repositoryId, later)).toBe(true);
expect(await storage.navigation.projects(userId)).toEqual([]);
expect(await storage.navigation.get(userId)).toMatchObject({
lastDocumentId: undefined,
revision: visited.revision + 1,
});

// Visiting one of its documents adds it back.
await storage.navigation.recordVisit({
userId,
repositoryId,
repositoryOwner: channel.repositoryOwner,
repositoryName: channel.repositoryName,
documentId: channelId,
now: later,
});
expect((await storage.navigation.projects(userId)).map(value => value.repositoryId))
.toEqual([repositoryId]);
} finally {
await storage.close();
}
});

it("stores the last document with a monotonic navigation revision", async () => {
let storage = await opened(factory);
try {
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/storage/memory/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ export class MemoryStorage implements StorageAdapter {
return Promise.resolve(undefined);
},
addProject: input => this.#addProject(input),
removeProject: (userId, repositoryId, now) =>
Promise.resolve(this.#removeProject(userId, repositoryId, now)),
get: userId => {
this.#requireUser(userId);
let found = this.#navigation.get(userId);
Expand Down Expand Up @@ -445,6 +447,25 @@ export class MemoryStorage implements StorageAdapter {
return Promise.resolve({ project: project(saved), added: true });
}

#removeProject(userId: string, repositoryId: string, now: Date): boolean {
this.#requireUser(userId);
let projects = this.#projects.get(userId) ?? [];
let kept = projects.filter(value => value.repositoryId !== repositoryId);
if (kept.length === projects.length) return false;
this.#projects.set(userId, kept);
let saved = this.#navigation.get(userId);
let last = saved?.lastDocumentId ? this.#channels.get(saved.lastDocumentId) : undefined;
if (saved && last?.repositoryId === repositoryId) {
this.#navigation.set(userId, {
...saved,
lastDocumentId: undefined,
revision: saved.revision + 1,
updatedAt: now,
});
}
return true;
}

async #setLastDocument(
userId: string,
documentId: string | undefined,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/storage/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export interface NavigationStore {
projects(userId: string): Promise<UserProject[]>;
firstDocument(userId: string, repositoryIds: string[]): Promise<string | undefined>;
addProject(input: AddUserProject): Promise<AddUserProjectResult>;
/**
* Takes a repository out of a person's projects; false when it wasn't one. Their last
* document is forgotten when it is in that repository. Visiting one of its documents adds
* the project again.
*/
removeProject(userId: string, repositoryId: string, now: Date): Promise<boolean>;
get(userId: string): Promise<UserNavigation | undefined>;
setLastDocument(
userId: string,
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/storage/postgres/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,29 @@ export class PostgresNavigationStore implements NavigationStore {
() => this.#sql.begin(transaction => this.#addProject(transaction, input)),
);

readonly removeProject = (userId: string, repositoryId: string, now: Date): Promise<boolean> =>
this.#run("remove project", () =>
this.#sql.begin(async transaction => {
await this.#lockUser(transaction, userId);
let removed = await transaction<{ repositoryId: string }[]>`
DELETE FROM user_projects
WHERE user_id = ${userId} AND repository_id = ${repositoryId}
RETURNING repository_id AS "repositoryId"
`;
if (!removed.length) return false;
await transaction`
UPDATE user_navigation SET
last_document_id = NULL,
revision = revision + 1,
updated_at = ${now}
WHERE user_id = ${userId}
AND last_document_id IN (
SELECT id FROM channels WHERE repository_id = ${repositoryId}
)
`;
return true;
}));

readonly get = (userId: string): Promise<UserNavigation | undefined> =>
this.#run("read navigation", async () => {
await this.#requireUser(userId);
Expand Down
Loading
Loading