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
7 changes: 4 additions & 3 deletions app/api/organization/[organizationId]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import {
MAX_ORGANIZATION_ARCHIVE_MIB,
ORGANIZATION_ARCHIVE_MEDIA_TYPE,
ORGANIZATION_EXPORT_COOLDOWN_DAYS,
ORGANIZATION_EXPORT_COOLDOWN_HOURS,
ORGANIZATION_EXPORT_INTENT_HEADER,
ORGANIZATION_EXPORT_INTENT_VALUE,
OrganizationArchiveError,
Expand Down Expand Up @@ -54,7 +54,7 @@ function jsonError(
}

/**
* Verify browser intent before a request can consume the monthly export slot.
* Verify browser intent before a request can consume the export slot.
*
* @param request - Candidate workspace export request.
* @returns True for the application's non-simple same-origin POST.
Expand Down Expand Up @@ -128,9 +128,10 @@ export async function POST(
});
} catch (error) {
if (error instanceof OrganizationExportLimitError) {
const hoursLeft = Math.ceil(error.retryAfterSeconds / 3600);
return jsonError(
"export_limit_reached",
`You can generate one workspace archive every ${ORGANIZATION_EXPORT_COOLDOWN_DAYS} days.`,
`You can generate one workspace archive every ${ORGANIZATION_EXPORT_COOLDOWN_HOURS} hours. Try again in ${hoursLeft === 1 ? "1 hour" : `${hoursLeft} hours`}.`,
429,
{ "retry-after": String(error.retryAfterSeconds) },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useRef, useState, useTransition } from "react";
import { Button } from "@/components/shared/Button";
import { IconCheck, IconDoc } from "@/components/shared/icons";
import {
ORGANIZATION_EXPORT_COOLDOWN_DAYS,
ORGANIZATION_EXPORT_COOLDOWN_HOURS,
ORGANIZATION_EXPORT_INTENT_HEADER,
ORGANIZATION_EXPORT_INTENT_VALUE,
organizationArchiveFilename,
Expand Down Expand Up @@ -108,7 +108,7 @@ export function WorkspaceExportSection({
activity history as one JSON archive. Member accounts and other
members' private notes are not included. Your own private
notes are included. You can generate one archive every{" "}
{ORGANIZATION_EXPORT_COOLDOWN_DAYS} days.
{ORGANIZATION_EXPORT_COOLDOWN_HOURS} hours.
</p>
<p className="mt-2 text-xs leading-relaxed text-text-muted">
The archive can include confidential content, deleted notes, and
Expand Down
12 changes: 6 additions & 6 deletions lib/data/organization-portability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { deriveIdentifier } from "@/lib/graph/identifier";
import {
MAX_ORGANIZATION_ARCHIVE_BYTES,
MAX_ORGANIZATION_ARCHIVE_ROWS,
ORGANIZATION_EXPORT_COOLDOWN_DAYS,
ORGANIZATION_EXPORT_COOLDOWN_HOURS,
OrganizationArchiveError,
decodeOrganizationArchive,
serializeOrganizationArchiveRow,
Expand Down Expand Up @@ -155,16 +155,16 @@ async function reserveOrganizationExport(
target: organizationExportLimits.userId,
set: { lastStartedAt: sql`clock_timestamp()` },
setWhere: sql`${organizationExportLimits.lastStartedAt} <= clock_timestamp() - interval '${sql.raw(
String(ORGANIZATION_EXPORT_COOLDOWN_DAYS),
)} days'`,
String(ORGANIZATION_EXPORT_COOLDOWN_HOURS),
)} hours'`,
})
.returning({ startedAt: organizationExportLimits.lastStartedAt });
if (reservations.length === 0) {
const [limit] = await tx
.select({
retryAfterSeconds: sql<number>`GREATEST(1, CEIL(EXTRACT(EPOCH FROM (${organizationExportLimits.lastStartedAt} + interval '${sql.raw(
String(ORGANIZATION_EXPORT_COOLDOWN_DAYS),
)} days' - clock_timestamp()))))::integer`,
String(ORGANIZATION_EXPORT_COOLDOWN_HOURS),
)} hours' - clock_timestamp()))))::integer`,
})
.from(organizationExportLimits)
.where(eq(organizationExportLimits.userId, userId));
Expand Down Expand Up @@ -209,7 +209,7 @@ function assertOrganizationExportWithinBounds(raw: RawReadRows): void {
}

/**
* Verify ownership and size before the monthly export slot is consumed.
* Verify ownership and size before the export slot is consumed.
*
* @param userId - Authenticated exporting user id.
* @param organizationId - Organization whose rows should be measured.
Expand Down
2 changes: 1 addition & 1 deletion lib/organization-portability/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export {
MAX_ORGANIZATION_ARCHIVE_MIB,
MAX_ORGANIZATION_IMPORT_BYTES,
MAX_ORGANIZATION_IMPORT_MIB,
ORGANIZATION_EXPORT_COOLDOWN_DAYS,
ORGANIZATION_EXPORT_COOLDOWN_HOURS,
ORGANIZATION_EXPORT_INTENT_HEADER,
ORGANIZATION_EXPORT_INTENT_VALUE,
ORGANIZATION_ARCHIVE_MEDIA_TYPE,
Expand Down
2 changes: 1 addition & 1 deletion lib/organization-portability/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const MAX_ORGANIZATION_IMPORT_BYTES =
MAX_ORGANIZATION_IMPORT_MIB * 1024 * 1024;

/** Rolling cooldown between admitted workspace archive generations. */
export const ORGANIZATION_EXPORT_COOLDOWN_DAYS = 30;
export const ORGANIZATION_EXPORT_COOLDOWN_HOURS = 24;

/** Header required on the state-changing workspace export request. */
export const ORGANIZATION_EXPORT_INTENT_HEADER = "x-piyaz-export";
Expand Down
9 changes: 5 additions & 4 deletions tests/api/organization-portability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ describe("POST /api/organization/[organizationId]/export", () => {
expect(archive.projects).toHaveLength(1);
});

test("enforces one workspace export per user every 30 days", async () => {
test("enforces one workspace export per user every 24 hours", async () => {
const fixture = await seedUserOrgProject("route-monthly-limit");
const [otherOrganization] = await superuserPool()<{ id: string }[]>`
INSERT INTO piyaz_auth."organization" ("name", "slug", "createdAt")
Expand All @@ -320,15 +320,16 @@ describe("POST /api/organization/[organizationId]/export", () => {
expect(limited.status).toBe(429);
expect(await limited.json()).toEqual({
code: "export_limit_reached",
error: "You can generate one workspace archive every 30 days.",
error:
"You can generate one workspace archive every 24 hours. Try again in 24 hours.",
});
const retryAfter = Number(limited.headers.get("retry-after"));
expect(retryAfter).toBeGreaterThan(0);
expect(retryAfter).toBeLessThanOrEqual(30 * 24 * 60 * 60);
expect(retryAfter).toBeLessThanOrEqual(24 * 60 * 60);

await superuserPool()`
UPDATE organization_export_limits
SET last_started_at = clock_timestamp() - interval '31 days'
SET last_started_at = clock_timestamp() - interval '25 hours'
WHERE user_id = ${fixture.userId}
`;
const retried = await request(otherOrganization.id);
Expand Down
Loading