mirror of
https://github.com/wassname/talk.git
synced 2026-07-09 14:49:20 +08:00
[CORL-588] Download and token fixes (#2519)
* fix: fixed up download handler to improve error handling * fix: fixed invalid expiry date being set * fix: nick found bug
This commit is contained in:
committed by
Kim Gardner
parent
a696ef9be5
commit
ac1ed45e4b
@@ -1,5 +1,5 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { Button, HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
@@ -10,6 +10,11 @@ interface Props {
|
||||
}
|
||||
|
||||
const DownloadForm: FunctionComponent<Props> = ({ token }) => {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const onClick = useCallback(() => {
|
||||
setSubmitted(true);
|
||||
}, [setSubmitted]);
|
||||
|
||||
return (
|
||||
<HorizontalGutter size="double">
|
||||
<form
|
||||
@@ -23,7 +28,8 @@ const DownloadForm: FunctionComponent<Props> = ({ token }) => {
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
fullWidth={false}
|
||||
disabled={submitted}
|
||||
onClick={onClick}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
Download My Comment History
|
||||
|
||||
@@ -17,7 +17,7 @@ import styles from "./DownloadRoute.css";
|
||||
const fetcher = createFetch(
|
||||
"downloadToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
await rest.fetch<void>("/account/downloadcheck", {
|
||||
await rest.fetch<void>("/account/download", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
|
||||
@@ -1,173 +1,38 @@
|
||||
import archiver from "archiver";
|
||||
import stringify from "csv-stringify";
|
||||
import DataLoader from "dataloader";
|
||||
import { Response } from "express";
|
||||
import Joi from "joi";
|
||||
import { kebabCase } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { getLatestRevision } from "coral-server/models/comment";
|
||||
import { Comment } from "coral-server/models/comment";
|
||||
import { retrieveManyStories } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import { verifyDownloadTokenString } from "coral-server/services/users/download/download";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import {
|
||||
redeemDownloadToken,
|
||||
sendUserDownload,
|
||||
verifyDownloadTokenString,
|
||||
} from "coral-server/services/users/download";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
const USER_ID_LIMITER_TTL = "1d";
|
||||
|
||||
export type DownloadOptions = Pick<
|
||||
export type AccountDownloadOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "signingConfig" | "config"
|
||||
>;
|
||||
|
||||
export type AdminDownloadOptions = Pick<AppOptions, "mongo" | "signingConfig">;
|
||||
|
||||
async function sendExport(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
user: Readonly<User>,
|
||||
latestContentDate: Date,
|
||||
res: Response
|
||||
) {
|
||||
// Create the date formatter to format the dates for the CSV.
|
||||
const formatter = Intl.DateTimeFormat(tenant.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Create a DataLoader to load stories for each batch.
|
||||
const getStories = new DataLoader((ids: string[]) =>
|
||||
retrieveManyStories(mongo, tenant.id, ids)
|
||||
);
|
||||
|
||||
// Create a cursor to iterate over the user's comment's in order.
|
||||
const cursor = mongo
|
||||
.collection<Readonly<Comment>>("comments")
|
||||
.find({
|
||||
tenantID: tenant.id,
|
||||
authorID: user.id,
|
||||
createdAt: {
|
||||
$lt: latestContentDate,
|
||||
},
|
||||
})
|
||||
.sort({ createdAt: 1 });
|
||||
|
||||
// Collect all the user's comments in batches.
|
||||
let commentBatch: Array<Readonly<Comment>> = [];
|
||||
|
||||
// Generate the filename of the file that the user will download.
|
||||
const filename = `talk-${kebabCase(user.username)}-${kebabCase(
|
||||
formatter.format(latestContentDate)
|
||||
)}.zip`;
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename=${filename}`,
|
||||
});
|
||||
|
||||
// Create the zip archive we'll use to write all the exported files to.
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// Pipe this to the response writer directly.
|
||||
archive.pipe(res);
|
||||
|
||||
// Create all the csv writers that'll write the data to the archive.
|
||||
const csv = stringify();
|
||||
|
||||
// Add all the streams as files to the archive.
|
||||
archive.append(csv, { name: "comments-export/my_comments.csv" });
|
||||
|
||||
csv.write([
|
||||
"Comment ID",
|
||||
"Published Timestamp",
|
||||
"Article URL",
|
||||
"Comment URL",
|
||||
"Comment Text",
|
||||
]);
|
||||
|
||||
/**
|
||||
* writeAndFlushBatch will write the given batch of comments to the CSV and
|
||||
* flush out the batchfor the next run.
|
||||
*/
|
||||
const writeAndFlushBatch = async () => {
|
||||
const stories = await getStories.loadMany(
|
||||
commentBatch.map(({ storyID }) => storyID)
|
||||
);
|
||||
|
||||
for (let i = 0; i < commentBatch.length; i++) {
|
||||
const comment = commentBatch[i];
|
||||
const story = stories[i];
|
||||
if (!story) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
const commentID = comment.id;
|
||||
const createdAt = formatter.format(new Date(comment.createdAt));
|
||||
const storyURL = story.url;
|
||||
const commentURL = `${storyURL}?commentID=${commentID}`;
|
||||
const body = revision.body;
|
||||
|
||||
csv.write([commentID, createdAt, storyURL, commentURL, body]);
|
||||
}
|
||||
|
||||
commentBatch = [];
|
||||
};
|
||||
|
||||
while (await cursor.hasNext()) {
|
||||
const comment = await cursor.next();
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
|
||||
commentBatch.push(comment);
|
||||
|
||||
if (commentBatch.length >= BATCH_SIZE) {
|
||||
await writeAndFlushBatch();
|
||||
}
|
||||
}
|
||||
|
||||
if (commentBatch.length > 0) {
|
||||
await writeAndFlushBatch();
|
||||
}
|
||||
|
||||
csv.end();
|
||||
|
||||
// Mark the end of adding files, no more files can be added after this. Once
|
||||
// all the stream readers have finished writing, and have closed, the
|
||||
// archiver will close which will finish the HTTP request.
|
||||
await archive.finalize();
|
||||
}
|
||||
|
||||
export interface DownloadBody {
|
||||
export interface AccountDownloadBody {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const DownloadBodySchema = Joi.object().keys({
|
||||
export const AccountDownloadBodySchema = Joi.object().keys({
|
||||
token: Joi.string().trim(),
|
||||
});
|
||||
|
||||
export const downloadHandler = ({
|
||||
export const accountDownloadHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
signingConfig,
|
||||
config,
|
||||
}: DownloadOptions): RequestHandler => {
|
||||
}: AccountDownloadOptions): RequestHandler => {
|
||||
const userIDLimiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: USER_ID_LIMITER_TTL,
|
||||
@@ -176,29 +41,33 @@ export const downloadHandler = ({
|
||||
config,
|
||||
});
|
||||
|
||||
return async (req: Request, res, next) => {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Get the fields from the body. Validate will throw an error if the body
|
||||
// does not conform to the specification.
|
||||
const { token }: DownloadBody = validate(DownloadBodySchema, req.body);
|
||||
|
||||
// Decode the token so we can rate limit based on the user's ID.
|
||||
const { sub: userID } = decodeJWT(token);
|
||||
if (!userID) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
await userIDLimiter.test(req, userID);
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Get the fields from the body. Validate will throw an error if the body
|
||||
// does not conform to the specification.
|
||||
const { token }: AccountDownloadBody = validate(
|
||||
AccountDownloadBodySchema,
|
||||
req.body
|
||||
);
|
||||
|
||||
// Decode the token so we can rate limit based on the user's ID.
|
||||
const { sub: userID } = decodeJWT(token);
|
||||
if (!userID) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
await userIDLimiter.test(req, userID);
|
||||
|
||||
const {
|
||||
token: { iat },
|
||||
user,
|
||||
} = await verifyDownloadTokenString(
|
||||
} = await redeemDownloadToken(
|
||||
mongo,
|
||||
redis,
|
||||
tenant,
|
||||
signingConfig,
|
||||
token,
|
||||
@@ -209,7 +78,7 @@ export const downloadHandler = ({
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Send the export down the response.
|
||||
await sendExport(mongo, tenant, user, latestContentDate, res);
|
||||
await sendUserDownload(res, mongo, tenant, user, latestContentDate);
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
@@ -218,61 +87,21 @@ export const downloadHandler = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const adminDownloadHandler = ({
|
||||
mongo,
|
||||
signingConfig,
|
||||
}: AdminDownloadOptions): RequestHandler => {
|
||||
return async (req: Request, res, next) => {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
const { token } = req.query;
|
||||
|
||||
const { sub: userID } = decodeJWT(token);
|
||||
if (!userID) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
token: { iat },
|
||||
user,
|
||||
} = await verifyDownloadTokenString(
|
||||
mongo,
|
||||
tenant,
|
||||
signingConfig,
|
||||
token,
|
||||
coral.now
|
||||
);
|
||||
|
||||
// Only load comments since this download token was issued.
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Send the export down the response.
|
||||
await sendExport(mongo, tenant, user, latestContentDate, res);
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export type DownloadCheckOptions = Pick<
|
||||
export type AccountDownloadCheckOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "signingConfig" | "config"
|
||||
>;
|
||||
|
||||
export const downloadCheckHandler = ({
|
||||
export const accountDownloadCheckHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
signingConfig,
|
||||
config,
|
||||
}: DownloadCheckOptions): RequestHandler => {
|
||||
}: AccountDownloadCheckOptions): RequestHandler => {
|
||||
const userIDLimiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: USER_ID_LIMITER_TTL,
|
||||
max: 1,
|
||||
ttl: "10m",
|
||||
max: 10,
|
||||
prefix: "userID",
|
||||
config,
|
||||
});
|
||||
@@ -297,6 +126,7 @@ export const downloadCheckHandler = ({
|
||||
|
||||
await verifyDownloadTokenString(
|
||||
mongo,
|
||||
redis,
|
||||
tenant,
|
||||
signingConfig,
|
||||
tokenString,
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./graphql";
|
||||
export * from "./health";
|
||||
export * from "./install";
|
||||
export * from "./version";
|
||||
export * from "./user";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { decodeJWT } from "coral-server/services/jwt";
|
||||
import {
|
||||
redeemDownloadToken,
|
||||
sendUserDownload,
|
||||
} from "coral-server/services/users/download";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
type AdminDownloadOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "signingConfig"
|
||||
>;
|
||||
|
||||
export const userDownloadHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
signingConfig,
|
||||
}: AdminDownloadOptions): RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
const { token } = req.query;
|
||||
|
||||
const { sub: userID } = decodeJWT(token);
|
||||
if (!userID) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
token: { iat },
|
||||
user,
|
||||
} = await redeemDownloadToken(
|
||||
mongo,
|
||||
redis,
|
||||
tenant,
|
||||
signingConfig,
|
||||
token,
|
||||
coral.now
|
||||
);
|
||||
|
||||
// Only load comments since this download token was issued.
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Send the export down the response.
|
||||
await sendUserDownload(res, mongo, tenant, user, latestContentDate);
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./download";
|
||||
@@ -91,17 +91,9 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
|
||||
// Grab the JTI from the decoded token.
|
||||
const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded);
|
||||
if (jti && exp) {
|
||||
// Compute the number of seconds that the token will be valid for.
|
||||
const validFor = Math.round(
|
||||
DateTime.fromJSDate(now)
|
||||
.plus({ seconds: -exp })
|
||||
.toSeconds()
|
||||
);
|
||||
if (validFor > 0) {
|
||||
// Invalidate the token, the expiry is in the future and it needs to be
|
||||
// revoked.
|
||||
await revokeJWT(redis, jti, validFor, now);
|
||||
}
|
||||
// Invalidate the token, the expiry is in the future and it needs to be
|
||||
// revoked.
|
||||
await revokeJWT(redis, jti, exp, now);
|
||||
}
|
||||
|
||||
// Clear the cookie.
|
||||
@@ -132,9 +124,7 @@ export async function handleSuccessfulLogin(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{
|
||||
expiresIn: Math.round(expiresIn.toSeconds()),
|
||||
},
|
||||
{ expiresIn: "1d" },
|
||||
coral.now
|
||||
);
|
||||
|
||||
@@ -194,9 +184,7 @@ export async function handleOAuth2Callback(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{
|
||||
expiresIn: Math.round(expiresIn.toSeconds()),
|
||||
},
|
||||
{ expiresIn: "1d" },
|
||||
req.coral!.now
|
||||
);
|
||||
res.cookie(
|
||||
|
||||
@@ -3,12 +3,11 @@ import express from "express";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import {
|
||||
adminDownloadHandler,
|
||||
accountDownloadCheckHandler,
|
||||
accountDownloadHandler,
|
||||
confirmCheckHandler,
|
||||
confirmHandler,
|
||||
confirmRequestHandler,
|
||||
downloadCheckHandler,
|
||||
downloadHandler,
|
||||
inviteCheckHandler,
|
||||
inviteHandler,
|
||||
} from "coral-server/app/handlers";
|
||||
@@ -34,14 +33,13 @@ export function createNewAccountRouter(
|
||||
router.get("/invite", inviteCheckHandler(app));
|
||||
router.put("/invite", jsonMiddleware, inviteHandler(app));
|
||||
|
||||
router.get("/downloadcheck", downloadCheckHandler(app));
|
||||
router.get("/download", adminDownloadHandler(app));
|
||||
router.get("/download", accountDownloadCheckHandler(app));
|
||||
router.post(
|
||||
"/download",
|
||||
bodyParser.urlencoded({
|
||||
extended: true,
|
||||
}),
|
||||
downloadHandler(app)
|
||||
accountDownloadHandler(app)
|
||||
);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { tenantMiddleware } from "coral-server/app/middleware/tenant";
|
||||
|
||||
import { createNewAccountRouter } from "./account";
|
||||
import { createNewAuthRouter } from "./auth";
|
||||
import { createNewUserRouter } from "./user";
|
||||
|
||||
export interface RouterOptions {
|
||||
/**
|
||||
@@ -55,6 +56,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
|
||||
// Create the auth router.
|
||||
router.use("/auth", createNewAuthRouter(app, options));
|
||||
router.use("/account", createNewAccountRouter(app, options));
|
||||
router.use("/user", createNewUserRouter(app));
|
||||
|
||||
// Configure the GraphQL route.
|
||||
router.use(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import express from "express";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { userDownloadHandler } from "coral-server/app/handlers";
|
||||
|
||||
export function createNewUserRouter(app: AppOptions) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/download", userDownloadHandler(app));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -354,20 +354,26 @@ function generateJTIRevokedKey(jti: string) {
|
||||
*
|
||||
* @param redis the Redis instance to revoke the JWT with
|
||||
* @param jti the JTI claim of the JWT token being revoked
|
||||
* @param validFor number of seconds that the token was valid for
|
||||
* @param exp time that the token expired at
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function revokeJWT(
|
||||
redis: Redis,
|
||||
jti: string,
|
||||
validFor: number,
|
||||
exp: number,
|
||||
now = new Date()
|
||||
) {
|
||||
await redis.setex(
|
||||
generateJTIRevokedKey(jti),
|
||||
Math.round(validFor),
|
||||
Math.round(DateTime.fromJSDate(now).toSeconds())
|
||||
const validFor = Math.round(
|
||||
DateTime.fromSeconds(exp).diff(DateTime.fromJSDate(now), "seconds").seconds
|
||||
);
|
||||
|
||||
if (validFor > 0) {
|
||||
await redis.setex(
|
||||
generateJTIRevokedKey(jti),
|
||||
validFor,
|
||||
Math.round(DateTime.fromJSDate(now).toSeconds())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,142 +1,139 @@
|
||||
import Joi from "joi";
|
||||
import { isNull } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import archiver from "archiver";
|
||||
import stringify from "csv-stringify";
|
||||
import DataLoader from "dataloader";
|
||||
import { Response } from "express";
|
||||
import { kebabCase } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import { Config } from "coral-server/config";
|
||||
import { TokenInvalidError, UserNotFoundError } from "coral-server/errors";
|
||||
import { getLatestRevision } from "coral-server/models/comment";
|
||||
import { Comment } from "coral-server/models/comment";
|
||||
import { retrieveManyStories } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { retrieveUser } from "coral-server/models/user";
|
||||
import {
|
||||
JWTSigningConfig,
|
||||
signString,
|
||||
StandardClaims,
|
||||
StandardClaimsSchema,
|
||||
verifyJWT,
|
||||
} from "coral-server/services/jwt";
|
||||
import { User } from "coral-server/models/user";
|
||||
|
||||
interface DownloadToken extends Required<StandardClaims> {
|
||||
aud: "download";
|
||||
}
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
const DownloadTokenSchema = StandardClaimsSchema.keys({
|
||||
aud: Joi.string().only("download"),
|
||||
});
|
||||
|
||||
export async function generateDownloadToken(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const nowDate = DateTime.fromJSDate(now);
|
||||
const nowSeconds = Math.round(nowDate.toSeconds());
|
||||
const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds());
|
||||
|
||||
const downloadToken: DownloadToken = {
|
||||
jti: uuid.v4(),
|
||||
iss: tenant.id,
|
||||
sub: userID,
|
||||
exp: expiresAt,
|
||||
iat: nowSeconds,
|
||||
nbf: nowSeconds,
|
||||
aud: "download",
|
||||
};
|
||||
|
||||
return await signString(signingConfig, downloadToken);
|
||||
}
|
||||
|
||||
export async function generateDownloadLink(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const token = await generateDownloadToken(
|
||||
userID,
|
||||
tenant,
|
||||
config,
|
||||
signingConfig,
|
||||
now
|
||||
);
|
||||
|
||||
return constructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
`/account/download#downloadToken=${token}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateAdminDownloadLink(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const token = await generateDownloadToken(
|
||||
userID,
|
||||
tenant,
|
||||
config,
|
||||
signingConfig,
|
||||
now
|
||||
);
|
||||
|
||||
return constructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
`/api/account/download?token=${token}`
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDownloadToken(
|
||||
token: DownloadToken | object
|
||||
): Error | null {
|
||||
const { error } = Joi.validate(token, DownloadTokenSchema, {
|
||||
presence: "required",
|
||||
});
|
||||
return error || null;
|
||||
}
|
||||
|
||||
export function isDownloadToken(
|
||||
token: DownloadToken | object
|
||||
): token is DownloadToken {
|
||||
return isNull(validateDownloadToken(token));
|
||||
}
|
||||
|
||||
export async function verifyDownloadTokenString(
|
||||
export async function sendUserDownload(
|
||||
res: Response,
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
signingConfig: JWTSigningConfig,
|
||||
tokenString: string,
|
||||
now: Date
|
||||
user: Readonly<User>,
|
||||
latestContentDate: Date
|
||||
) {
|
||||
const token = verifyJWT(tokenString, signingConfig, now, {
|
||||
issuer: tenant.id,
|
||||
audience: "download",
|
||||
// Create the date formatter to format the dates for the CSV.
|
||||
const formatter = Intl.DateTimeFormat(tenant.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
if (!isDownloadToken(token)) {
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"does not conform to the download token schema"
|
||||
// Create a DataLoader to load stories for each batch.
|
||||
const getStories = new DataLoader((ids: string[]) =>
|
||||
retrieveManyStories(mongo, tenant.id, ids)
|
||||
);
|
||||
|
||||
// Create a cursor to iterate over the user's comment's in order.
|
||||
const cursor = mongo
|
||||
.collection<Readonly<Comment>>("comments")
|
||||
.find({
|
||||
tenantID: tenant.id,
|
||||
authorID: user.id,
|
||||
createdAt: {
|
||||
$lt: latestContentDate,
|
||||
},
|
||||
})
|
||||
.sort({ createdAt: 1 });
|
||||
|
||||
// Collect all the user's comments in batches.
|
||||
let commentBatch: Array<Readonly<Comment>> = [];
|
||||
|
||||
// Generate the filename of the file that the user will download.
|
||||
const filename = `talk-${kebabCase(user.username)}-${kebabCase(
|
||||
formatter.format(latestContentDate)
|
||||
)}.zip`;
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename=${filename}`,
|
||||
});
|
||||
|
||||
// Create the zip archive we'll use to write all the exported files to.
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// Pipe this to the response writer directly.
|
||||
archive.pipe(res);
|
||||
|
||||
// Create all the csv writers that'll write the data to the archive.
|
||||
const csv = stringify();
|
||||
|
||||
// Add all the streams as files to the archive.
|
||||
archive.append(csv, { name: "comments-export/my_comments.csv" });
|
||||
|
||||
csv.write([
|
||||
"Comment ID",
|
||||
"Published Timestamp",
|
||||
"Article URL",
|
||||
"Comment URL",
|
||||
"Comment Text",
|
||||
]);
|
||||
|
||||
/**
|
||||
* writeAndFlushBatch will write the given batch of comments to the CSV and
|
||||
* flush out the batchfor the next run.
|
||||
*/
|
||||
const writeAndFlushBatch = async () => {
|
||||
const stories = await getStories.loadMany(
|
||||
commentBatch.map(({ storyID }) => storyID)
|
||||
);
|
||||
|
||||
for (let i = 0; i < commentBatch.length; i++) {
|
||||
const comment = commentBatch[i];
|
||||
const story = stories[i];
|
||||
if (!story) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
const commentID = comment.id;
|
||||
const createdAt = formatter.format(new Date(comment.createdAt));
|
||||
const storyURL = story.url;
|
||||
const commentURL = `${storyURL}?commentID=${commentID}`;
|
||||
const body = revision.body;
|
||||
|
||||
csv.write([commentID, createdAt, storyURL, commentURL, body]);
|
||||
}
|
||||
|
||||
commentBatch = [];
|
||||
};
|
||||
|
||||
while (await cursor.hasNext()) {
|
||||
const comment = await cursor.next();
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
|
||||
commentBatch.push(comment);
|
||||
|
||||
if (commentBatch.length >= BATCH_SIZE) {
|
||||
await writeAndFlushBatch();
|
||||
}
|
||||
}
|
||||
|
||||
const { sub: userID, iss } = token;
|
||||
|
||||
const user = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(userID);
|
||||
if (commentBatch.length > 0) {
|
||||
await writeAndFlushBatch();
|
||||
}
|
||||
|
||||
if (iss !== tenant.id) {
|
||||
throw new TokenInvalidError(tokenString, "invalid tenant");
|
||||
}
|
||||
csv.end();
|
||||
|
||||
return { token, user };
|
||||
// Mark the end of adding files, no more files can be added after this. Once
|
||||
// all the stream readers have finished writing, and have closed, the
|
||||
// archiver will close which will finish the HTTP request.
|
||||
await archive.finalize();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./download";
|
||||
export * from "./token";
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import { isNull } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import { Config } from "coral-server/config";
|
||||
import { TokenInvalidError, UserNotFoundError } from "coral-server/errors";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { retrieveUser } from "coral-server/models/user";
|
||||
import {
|
||||
isJWTRevoked,
|
||||
JWTSigningConfig,
|
||||
revokeJWT,
|
||||
signString,
|
||||
StandardClaims,
|
||||
StandardClaimsSchema,
|
||||
verifyJWT,
|
||||
} from "coral-server/services/jwt";
|
||||
|
||||
interface DownloadToken extends Required<StandardClaims> {
|
||||
aud: "download";
|
||||
}
|
||||
|
||||
const DownloadTokenSchema = StandardClaimsSchema.keys({
|
||||
aud: Joi.string().only("download"),
|
||||
});
|
||||
|
||||
export async function generateDownloadToken(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const nowDate = DateTime.fromJSDate(now);
|
||||
const nowSeconds = Math.round(nowDate.toSeconds());
|
||||
const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds());
|
||||
|
||||
const downloadToken: DownloadToken = {
|
||||
jti: uuid.v4(),
|
||||
iss: tenant.id,
|
||||
sub: userID,
|
||||
exp: expiresAt,
|
||||
iat: nowSeconds,
|
||||
nbf: nowSeconds,
|
||||
aud: "download",
|
||||
};
|
||||
|
||||
return await signString(signingConfig, downloadToken);
|
||||
}
|
||||
|
||||
export async function generateDownloadLink(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const token = await generateDownloadToken(
|
||||
userID,
|
||||
tenant,
|
||||
config,
|
||||
signingConfig,
|
||||
now
|
||||
);
|
||||
|
||||
return constructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
`/account/download#downloadToken=${token}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateAdminDownloadLink(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const token = await generateDownloadToken(
|
||||
userID,
|
||||
tenant,
|
||||
config,
|
||||
signingConfig,
|
||||
now
|
||||
);
|
||||
|
||||
return constructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
`/api/user/download?token=${token}`
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDownloadToken(
|
||||
token: DownloadToken | object
|
||||
): Error | null {
|
||||
const { error } = Joi.validate(token, DownloadTokenSchema, {
|
||||
presence: "required",
|
||||
});
|
||||
return error || null;
|
||||
}
|
||||
|
||||
export function isDownloadToken(
|
||||
token: DownloadToken | object
|
||||
): token is DownloadToken {
|
||||
return isNull(validateDownloadToken(token));
|
||||
}
|
||||
|
||||
export async function verifyDownloadTokenString(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
tenant: Tenant,
|
||||
signingConfig: JWTSigningConfig,
|
||||
tokenString: string,
|
||||
now: Date
|
||||
) {
|
||||
const token = verifyJWT(tokenString, signingConfig, now, {
|
||||
issuer: tenant.id,
|
||||
audience: "download",
|
||||
});
|
||||
|
||||
if (!isDownloadToken(token)) {
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"does not conform to the download token schema"
|
||||
);
|
||||
}
|
||||
|
||||
const { sub: userID, iss } = token;
|
||||
|
||||
const user = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
if (iss !== tenant.id) {
|
||||
throw new TokenInvalidError(tokenString, "invalid tenant");
|
||||
}
|
||||
|
||||
// Check to see if the token was revoked.
|
||||
if (await isJWTRevoked(redis, token.jti)) {
|
||||
throw new TokenInvalidError(tokenString, "token was revoked");
|
||||
}
|
||||
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
export async function redeemDownloadToken(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
tenant: Tenant,
|
||||
signingConfig: JWTSigningConfig,
|
||||
tokenString: string,
|
||||
now: Date
|
||||
) {
|
||||
// Verify the download token.
|
||||
const { token, user } = await verifyDownloadTokenString(
|
||||
mongo,
|
||||
redis,
|
||||
tenant,
|
||||
signingConfig,
|
||||
tokenString,
|
||||
now
|
||||
);
|
||||
|
||||
// Revoke the download token.
|
||||
await revokeJWT(redis, token.jti, token.exp, now);
|
||||
|
||||
return { token, user };
|
||||
}
|
||||
@@ -71,7 +71,7 @@ import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
|
||||
import {
|
||||
generateAdminDownloadLink,
|
||||
generateDownloadLink,
|
||||
} from "./download/download";
|
||||
} from "./download/token";
|
||||
import { validateEmail, validatePassword, validateUsername } from "./helpers";
|
||||
|
||||
export type InsertUser = InsertUserInput;
|
||||
|
||||
Reference in New Issue
Block a user