feat: add authenticated share publishing APIs

This commit is contained in:
2026-08-16 00:50:55 +07:00 Unverified
parent b8e6389ac3
commit 5bf5d2a2c0
8 changed files with 407 additions and 0 deletions
+5
View File
@@ -3,6 +3,7 @@ import "server-only";
import {
optionalEnvironmentValue,
readFeatureEnvironment,
requiredEnvironmentValue,
requiredEnvironmentUrl,
} from "@/lib/config/environment";
@@ -32,6 +33,10 @@ export function getDiscordServerConfig() {
};
}
export function getShareBotSecret() {
return requiredEnvironmentValue("SHARE_BOT_SECRET");
}
export function getYoutubeFollowerConfig() {
const config = readFeatureEnvironment("YouTube follower counts", [
"YOUTUBE_API_KEY",
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import { db } from "@/db";
import { cdn, shareTexts } from "@/db/schema";
import type { ShareSource } from "@/db/schema/share";
import type { ValidatedImage } from "@/lib/share/validation";
export type ShareAuthor = {
userId: string;
discordId: string;
displayName: string;
avatarUrl: string | null;
};
export async function createShare({
content,
description,
image,
source,
author,
}: {
content: string;
description: string | null;
image: ValidatedImage | null;
source: ShareSource;
author: ShareAuthor;
}) {
return db.transaction(async (tx) => {
let imageCdnId: string | null = null;
if (image) {
const [record] = await tx
.insert(cdn)
.values({
data: image.bytes,
name: image.name,
type: image.type,
size: image.size,
ownerDiscordId: author.discordId,
})
.returning({ id: cdn.id });
imageCdnId = record.id;
}
const [share] = await tx
.insert(shareTexts)
.values({
content,
description,
imageCdnId,
source,
authorId: author.userId,
authorDiscordId: author.discordId,
authorName: author.displayName,
authorAvatarUrl: author.avatarUrl,
})
.returning({
id: shareTexts.id,
imageCdnId: shareTexts.imageCdnId,
createdAt: shareTexts.createdAt,
});
return share;
});
}
+21
View File
@@ -0,0 +1,21 @@
export class ShareHttpError extends Error {
constructor(
message: string,
readonly status: number,
readonly retryAfter?: number
) {
super(message);
this.name = "ShareHttpError";
}
}
export function shareErrorResponse(error: unknown) {
const known = error instanceof ShareHttpError;
const status = known ? error.status : 500;
const message = known ? error.message : "Internal server error";
const headers = new Headers();
if (known && error.retryAfter) {
headers.set("Retry-After", String(error.retryAfter));
}
return Response.json({ success: false, error: message }, { status, headers });
}
+44
View File
@@ -0,0 +1,44 @@
import "server-only";
import { getRedisClient } from "@/lib/redis";
import { ShareHttpError } from "@/lib/share/http-error";
const FIXED_WINDOW_SCRIPT = `
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
`;
export async function enforceShareRateLimit({
key,
limit,
windowSeconds,
}: {
key: string;
limit: number;
windowSeconds: number;
}) {
const nowSeconds = Math.floor(Date.now() / 1000);
const window = Math.floor(nowSeconds / windowSeconds);
const retryAfter = windowSeconds - (nowSeconds % windowSeconds);
try {
const redis = await getRedisClient();
const count = Number(
await redis.send("EVAL", [
FIXED_WINDOW_SCRIPT,
"1",
`erika:share:${key}:${window}`,
String(windowSeconds + 1),
])
);
if (count > limit) {
throw new ShareHttpError("Rate limit exceeded", 429, retryAfter);
}
} catch (error) {
if (error instanceof ShareHttpError) throw error;
throw new ShareHttpError("Rate limiting is temporarily unavailable", 503);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { validateImageUploadFile } from "@/lib/cdn-images";
import { ShareHttpError } from "@/lib/share/http-error";
export const MAX_SHARE_TEXT_BYTES = 5 * 1024 * 1024;
export const MAX_SHARE_DESCRIPTION_LENGTH = 5_000;
export const MAX_SHARE_COMMENT_LENGTH = 2_000;
export type ValidatedImage = {
bytes: Buffer;
name: string | null;
type: string;
size: number;
};
export function validateShareText(content: unknown) {
if (typeof content !== "string") {
throw new ShareHttpError("Text is required", 400);
}
if (!content.trim()) {
throw new ShareHttpError("Text cannot be blank", 422);
}
if (Buffer.byteLength(content, "utf8") > MAX_SHARE_TEXT_BYTES) {
throw new ShareHttpError("Text must be 5 MiB or smaller", 413);
}
return content;
}
export function validateShareDescription(value: unknown) {
if (value === null || value === undefined || value === "") return null;
if (typeof value !== "string") {
throw new ShareHttpError("Description must be text", 400);
}
if (value.length > MAX_SHARE_DESCRIPTION_LENGTH) {
throw new ShareHttpError("Description must be 5,000 characters or fewer", 413);
}
return value;
}
export function validateShareComment(value: unknown) {
if (typeof value !== "string" || !value.trim()) {
throw new ShareHttpError("Comment cannot be blank", 422);
}
if (value.length > MAX_SHARE_COMMENT_LENGTH) {
throw new ShareHttpError("Comment must be 2,000 characters or fewer", 413);
}
return value;
}
export async function validateTextFile(file: File) {
if (!file.name.toLowerCase().endsWith(".txt")) {
throw new ShareHttpError("File must use the .txt extension", 415);
}
if (file.size > MAX_SHARE_TEXT_BYTES) {
throw new ShareHttpError("Text file must be 5 MiB or smaller", 413);
}
if (file.size === 0) {
throw new ShareHttpError("Text file cannot be empty", 422);
}
try {
const bytes = await file.arrayBuffer();
const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return validateShareText(content);
} catch (error) {
if (error instanceof ShareHttpError) throw error;
throw new ShareHttpError("Text file must contain valid UTF-8", 422);
}
}
export async function validateOptionalImage(value: FormDataEntryValue | null) {
if (!(value instanceof File) || value.size === 0) return null;
const bytes = Buffer.from(await value.arrayBuffer());
try {
validateImageUploadFile(value, bytes);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid image";
const status = message.includes("50MB") ? 413 : 415;
throw new ShareHttpError(message, status);
}
return {
bytes,
name: value.name || null,
type: value.type,
size: value.size,
} satisfies ValidatedImage;
}