Files
anonymous_github/src/server/routes/gist-private.ts
T
Thomas DurieuxandClaude Opus 4.8 5ef10dee9e fix: dashboard menu clickability + expiration date UX (#753)
* fix: dashboard menu clickability + expiration date UX

Dashboard actions menu:
- Inactive (expired/removed) rows dimmed via their cells instead of the
  row, so `opacity` no longer creates a stacking context that trapped the
  actions dropdown beneath later rows and made its items unclickable.
- Add an "Extend 6 months" menu item for expired repos/PRs/gists.

Expiration form (anonymize):
- Fix the "After , the content will be removed." blank date: guard the
  helper text and add min/max validation feedback so an invalid pick no
  longer nulls the model into a broken sentence.
- Add a `min` (today) so past dates can no longer be selected, and
  compute min/max from local date parts (not UTC) to avoid a timezone
  off-by-one in the native picker.
- Default expiration is now 6 months (single source of truth, removing a
  latent double-offset bug); max stays at 1 year.
- Block submitting a missing/out-of-range expiration date.

Backend:
- New POST /:id/extend endpoint for repos, PRs and gists that pushes the
  expiration +6 months and re-anonymizes so expired items come back
  online, mirroring the refresh flow. Shared extendExpirationDate helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: add frontend translation for invalid_status error code

The new /extend endpoints throw an "invalid_status" AnonymousError, which
the error-code coverage test requires to have a locale entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:10:38 +02:00

281 lines
7.4 KiB
TypeScript

import * as express from "express";
import { ensureAuthenticated } from "./connection";
import {
getGist,
getUser,
handleError,
isOwnerOrAdmin,
extendExpirationDate,
} from "./route-utils";
import AnonymousError from "../../core/AnonymousError";
import { IAnonymizedGistDocument } from "../../core/model/anonymizedGists/anonymizedGists.types";
import Gist from "../../core/Gist";
import AnonymizedGistModel from "../../core/model/anonymizedGists/anonymizedGists.model";
import { RepositoryStatus } from "../../core/types";
const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
// refresh gist
router.post(
"/:gistId/refresh",
async (req: express.Request, res: express.Response) => {
try {
const gist = await getGist(req, res, { nocheck: true });
if (!gist) return;
const user = await getUser(req);
isOwnerOrAdmin([gist.owner.id], user);
await gist.updateIfNeeded({ force: true });
res.json({ status: gist.status });
} catch (error) {
handleError(error, res, req);
}
}
);
// extend the expiration of a gist (default +6 months) and bring it back online
// if it had expired
router.post(
"/:gistId/extend",
async (req: express.Request, res: express.Response) => {
try {
const gist = await getGist(req, res, { nocheck: true });
if (!gist) return;
if (
gist.status == RepositoryStatus.PREPARING ||
gist.status == RepositoryStatus.REMOVING ||
gist.status == RepositoryStatus.EXPIRING ||
gist.status == RepositoryStatus.REMOVED
) {
throw new AnonymousError("invalid_status", {
object: gist,
httpStatus: 409,
});
}
const user = await getUser(req);
isOwnerOrAdmin([gist.owner.id], user);
const newExpiration = extendExpirationDate(gist.model.options.expirationDate);
gist.model.options.expirationDate = newExpiration;
await AnonymizedGistModel.updateOne(
{ _id: gist.model._id },
{ $set: { "options.expirationDate": newExpiration } }
).exec();
await gist.updateIfNeeded({ force: true });
res.json({ status: gist.status, expirationDate: newExpiration });
} catch (error) {
handleError(error, res, req);
}
}
);
// delete a gist
router.delete(
"/:gistId/",
async (req: express.Request, res: express.Response) => {
const gist = await getGist(req, res, { nocheck: true });
if (!gist) return;
try {
if (gist.status == "removed")
throw new AnonymousError("is_removed", {
object: req.params.gistId,
httpStatus: 410,
});
const user = await getUser(req);
isOwnerOrAdmin([gist.owner.id], user);
await gist.remove();
return res.json({ status: gist.status });
} catch (error) {
handleError(error, res, req);
}
}
);
// fetch GitHub gist details (used by anonymize form)
router.get(
"/source/:gistId",
async (req: express.Request, res: express.Response) => {
const user = await getUser(req);
try {
const gist = new Gist(
new AnonymizedGistModel({
owner: user.id,
source: {
gistId: req.params.gistId,
},
})
);
gist.owner = user;
await gist.download();
res.json(gist.toJSON());
} catch (error) {
handleError(error, res, req);
}
}
);
// get gist information
router.get(
"/:gistId/",
async (req: express.Request, res: express.Response) => {
try {
const gist = await getGist(req, res, { nocheck: true });
if (!gist) return;
const user = await getUser(req);
isOwnerOrAdmin([gist.owner.id], user);
res.json(gist.toJSON());
} catch (error) {
handleError(error, res, req);
}
}
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateNewGist(gistUpdate: any): void {
const validCharacters = /^[0-9a-zA-Z\-_]+$/;
if (
!gistUpdate.gistId ||
!gistUpdate.gistId.match(validCharacters) ||
gistUpdate.gistId.length < 3
) {
throw new AnonymousError("invalid_gistId", {
object: gistUpdate,
httpStatus: 400,
});
}
if (!gistUpdate.source || !gistUpdate.source.gistId) {
throw new AnonymousError("gistId_not_specified", {
object: gistUpdate,
httpStatus: 400,
});
}
if (!gistUpdate.options) {
throw new AnonymousError("options_not_provided", {
object: gistUpdate,
httpStatus: 400,
});
}
if (!Array.isArray(gistUpdate.terms)) {
throw new AnonymousError("invalid_terms_format", {
object: gistUpdate,
httpStatus: 400,
});
}
}
function updateGistModel(
model: IAnonymizedGistDocument,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gistUpdate: any
) {
model.options = {
terms: gistUpdate.terms,
expirationMode: gistUpdate.options.expirationMode,
expirationDate: gistUpdate.options.expirationDate
? new Date(gistUpdate.options.expirationDate)
: undefined,
update: gistUpdate.options.update,
image: gistUpdate.options.image,
link: gistUpdate.options.link,
body: gistUpdate.options.body,
title: gistUpdate.options.title,
username: gistUpdate.options.username,
origin: gistUpdate.options.origin,
content: gistUpdate.options.content,
comments: gistUpdate.options.comments,
date: gistUpdate.options.date,
};
}
// update a gist
router.post(
"/:gistId/",
async (req: express.Request, res: express.Response) => {
try {
const gist = await getGist(req, res, { nocheck: true });
if (!gist) return;
const user = await getUser(req);
isOwnerOrAdmin([gist.owner.id], user);
const gistUpdate = req.body;
validateNewGist(gistUpdate);
gist.model.anonymizeDate = new Date();
updateGistModel(gist.model, gistUpdate);
gist.model.conference = gistUpdate.conference;
await AnonymizedGistModel.updateOne(
{ _id: gist.model._id },
{
$set: {
options: gist.model.options,
conference: gist.model.conference,
anonymizeDate: gist.model.anonymizeDate,
},
}
).exec();
await gist.updateStatus(RepositoryStatus.PREPARING);
await gist.updateIfNeeded({ force: true });
res.json(gist.toJSON());
} catch (error) {
return handleError(error, res, req);
}
}
);
// add gist
router.post("/", async (req: express.Request, res: express.Response) => {
const user = await getUser(req);
const gistUpdate = req.body;
try {
validateNewGist(gistUpdate);
const gist = new Gist(
new AnonymizedGistModel({
owner: user.id,
options: gistUpdate.options,
})
);
gist.model.gistId = gistUpdate.gistId;
gist.model.anonymizeDate = new Date();
gist.model.owner = user.id;
updateGistModel(gist.model, gistUpdate);
gist.source.accessToken = user.accessToken;
gist.source.gistId = gistUpdate.source.gistId;
gist.model.conference = gistUpdate.conference;
await gist.model.save();
await gist.anonymize();
res.send(gist.toJSON());
} catch (error) {
if (
error instanceof Error &&
error.message.indexOf(" duplicate key") > -1
) {
return handleError(
new AnonymousError("gistId_already_used", {
httpStatus: 400,
cause: error,
object: gistUpdate,
}),
res,
req
);
}
return handleError(error, res, req);
}
});
export default router;