mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-13 14:18:57 +02:00
* 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>
281 lines
7.4 KiB
TypeScript
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;
|