wow
This commit is contained in:
parent
b12ebb725d
commit
5207f5d431
25 changed files with 2932 additions and 332 deletions
|
|
@ -7,9 +7,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1030.0",
|
||||
"@elysiajs/jwt": "^1.4.1",
|
||||
"@elysiajs/openapi": "^1.4.14",
|
||||
"elysia": "latest",
|
||||
"mongoose": "^9.4.1"
|
||||
"mongoose": "^9.4.1",
|
||||
"ulid": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
|
|
|
|||
|
|
@ -2,202 +2,17 @@ import { Elysia, t } from "elysia";
|
|||
import config from "../config.toml";
|
||||
import * as mongoose from "mongoose";
|
||||
import openapi from "@elysiajs/openapi";
|
||||
import { MediaUpload } from "@/models/media";
|
||||
import { Tag } from "@/models/tag";
|
||||
import { checkTweetData, fetchTweetData } from "./lib/tweet";
|
||||
import { makeS3FileName, s3Client, uploadToS3 } from "./lib/s3";
|
||||
import { normalizeQueryTags, normalizeTags } from "./lib/tag";
|
||||
|
||||
await mongoose.connect(config.mongodb.uri);
|
||||
|
||||
const inFlightUploads = new Set<string>();
|
||||
|
||||
function buildUploadKey(url: string, selected: boolean[]) {
|
||||
const match = url.match(/\/status\/(\d+)/);
|
||||
const tweetId = match?.[1] ?? url;
|
||||
const selectedIndices = selected
|
||||
.map((isSelected, index) => (isSelected ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
.join(",");
|
||||
|
||||
return `${tweetId}:${selectedIndices}`;
|
||||
}
|
||||
|
||||
async function saveTags(tags: string[]) {
|
||||
await Promise.all(
|
||||
tags.map((tag) =>
|
||||
Tag.updateOne(
|
||||
{ name: tag },
|
||||
{
|
||||
$inc: { usageCount: 1 },
|
||||
$set: { lastUsedAt: new Date() },
|
||||
$setOnInsert: { name: tag },
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const app = new Elysia()
|
||||
.use(openapi())
|
||||
.get("/", () => "어...")
|
||||
|
||||
.get("/total", async ({ query }) => {
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const count = await MediaUpload.countDocuments(filter);
|
||||
return count;
|
||||
}, {
|
||||
query: t.Object({
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/list", async ({ query }) => {
|
||||
const page = query.page;
|
||||
const pageSize = 20;
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const uploads = await MediaUpload.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize);
|
||||
return uploads;
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Number({default: 1, minimum: 1}),
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/tags", async () => {
|
||||
const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 });
|
||||
return tags;
|
||||
})
|
||||
|
||||
.post("/upload", async ({ body, status }) => {
|
||||
if (body.url.startsWith("https://www.pixiv.net/")) {
|
||||
return "저는 저능아입니다";
|
||||
} else if (body.url.startsWith("https://x.com/") || body.url.startsWith("https://twitter.com/") || body.url.startsWith("https://fxtwitter.com/") || body.url.startsWith("https://fixupx.com/") || body.url.startsWith("https://vxwitter.com/")) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, "이미 처리 중인 업로드입니다.");
|
||||
}
|
||||
|
||||
inFlightUploads.add(uploadKey);
|
||||
console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`);
|
||||
|
||||
if (await checkTweetData(body.url, body.selected)) {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
console.log(`[Upload skipped-existing] requestId=${requestId} key=${uploadKey}`);
|
||||
return "이미 저장된 트윗입니다.";
|
||||
}
|
||||
try {
|
||||
const tweetData = await fetchTweetData(body.url);
|
||||
if (tweetData.tweet) {
|
||||
const media = tweetData.tweet.media.photos || [];
|
||||
if (media.length > 0) {
|
||||
const mediaUrls = media.map((m: any) => m.url);
|
||||
// Upload to S3
|
||||
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
const hasExplicitSelection = body.selected.length > 0;
|
||||
for (const [index, url] of mediaUrls.entries()) {
|
||||
const isSelected = hasExplicitSelection
|
||||
? body.selected[index] === true
|
||||
: true;
|
||||
|
||||
if (!isSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, url, index);
|
||||
|
||||
try {
|
||||
if (await s3Client.exists(fileName)) {
|
||||
console.log(`File ${fileName} already exists in S3, skipping upload.`);
|
||||
} else {
|
||||
await uploadToS3(fileName, url);
|
||||
console.log(`Uploaded ${fileName} to S3`);
|
||||
}
|
||||
|
||||
const { media: _media, ...tweetWithoutMedia } = tweetData.tweet;
|
||||
const normalizedTags = normalizeTags(body.tag || ["미분류"]);
|
||||
|
||||
await MediaUpload.create({
|
||||
type: "twitter",
|
||||
tweet: tweetWithoutMedia,
|
||||
mediaIndex: index,
|
||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||
s3Key: fileName,
|
||||
tags: normalizedTags,
|
||||
author: body.author ? body.author : tweetData.tweet.author.name,
|
||||
});
|
||||
|
||||
await saveTags(normalizedTags);
|
||||
savedCount += 1;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
console.error(`[Upload failed] index=${index} url=${url} key=${fileName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === 0 && failedCount === 0) {
|
||||
console.warn("No media uploaded: selected[] did not include any upload target.");
|
||||
}
|
||||
|
||||
console.log(`Saved ${savedCount} media records to MongoDB. Failed: ${failedCount}`);
|
||||
} else {
|
||||
console.log("No media found in the tweet.");
|
||||
}
|
||||
}
|
||||
console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`);
|
||||
console.log(tweetData);
|
||||
} catch (error) {
|
||||
console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error);
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else {
|
||||
return status(400, "어...");
|
||||
}
|
||||
return "아...";
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String(),
|
||||
tag: t.Optional(t.Array(t.String({default: "미분류"}))),
|
||||
author: t.Optional(t.String()),
|
||||
selected: t.Array(t.Boolean()),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/fetch/tweet", async ({ query, status }) => {
|
||||
try {
|
||||
const tweetData = await fetchTweetData(query.url);
|
||||
return tweetData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
url: t.String(),
|
||||
})
|
||||
})
|
||||
.use(import("./routes/tweet"))
|
||||
.use(import("./routes/auth"))
|
||||
.use(import("./routes/post"))
|
||||
.use(import("./routes/pixiv"))
|
||||
|
||||
.listen(config.server.port)
|
||||
;
|
||||
|
|
|
|||
34
apps/backend/src/lib/audit.ts
Normal file
34
apps/backend/src/lib/audit.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { AuditLog } from "@/models/audit";
|
||||
|
||||
type AuditActor = {
|
||||
userId?: string;
|
||||
discordId?: string;
|
||||
username?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type AuditInput = {
|
||||
actor?: AuditActor;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
summary?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function createAuditLog(input: AuditInput) {
|
||||
try {
|
||||
await AuditLog.create({
|
||||
actor: input.actor,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
summary: input.summary,
|
||||
detail: input.detail,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AuditLog] failed to save", error);
|
||||
}
|
||||
}
|
||||
|
||||
export { createAuditLog };
|
||||
33
apps/backend/src/lib/pixiv.ts
Normal file
33
apps/backend/src/lib/pixiv.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
function fetchPixivData(url: string): Promise<any> {
|
||||
// https://www.pixiv.net/artworks/143552616
|
||||
const match = url.match(/\/artworks\/(\d+)/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid Pixiv URL");
|
||||
}
|
||||
const artworkId = match[1];
|
||||
|
||||
|
||||
return fetch(`https://www.phixiv.net/api/info?id=${artworkId}&language=ko`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Pixiv data: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
// if #R-18 in tags, throw error
|
||||
if (data.tags && data.tags.some((tag: string) => tag.includes("R-18"))) {
|
||||
throw new Error("Pixiv artwork is marked as R-18");
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
throw new Error(`Pixiv API error: ${data.message}`);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export { fetchPixivData };
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { S3Client } from "bun";
|
||||
import {
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client as AwsS3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import config from "@/../config.toml";
|
||||
|
||||
// Bun.S3Client 단일화
|
||||
const client = new S3Client({
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_key,
|
||||
|
|
@ -13,108 +9,58 @@ const client = new S3Client({
|
|||
endpoint: config.s3.endpoint,
|
||||
});
|
||||
|
||||
const awsClient = new AwsS3Client({
|
||||
region: "auto",
|
||||
endpoint: config.s3.endpoint,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_key,
|
||||
},
|
||||
});
|
||||
async function warmupS3() {
|
||||
try {
|
||||
await client.exists(`__warmup_${Date.now()}__`);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeS3FileName(authorId: string, tweetId: string, mediaUrl: string, index: number) {
|
||||
const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`;
|
||||
const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`;
|
||||
const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `twitter/${authorId}/${tweetId}/${safeName || `media_${Date.now()}_${index}`}`;
|
||||
return `twitter/${authorId}/${tweetId}/${safeName}`;
|
||||
}
|
||||
|
||||
async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
|
||||
async function existsInS3(key: string) {
|
||||
try {
|
||||
return await client.exists(key);
|
||||
} catch {
|
||||
try {
|
||||
await awsClient.send(new HeadObjectCommand({
|
||||
Bucket: config.s3.bucket,
|
||||
Key: key,
|
||||
}));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToS3(key: string, body: Uint8Array, mediaType?: string | null) {
|
||||
try {
|
||||
await client.write(key, body);
|
||||
return;
|
||||
} catch (bunWriteError) {
|
||||
console.warn(`[S3 bun write failed, fallback to aws-sdk] key=${key}`, bunWriteError);
|
||||
await awsClient.send(new PutObjectCommand({
|
||||
Bucket: config.s3.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: mediaType ?? undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverByPollingExists(reason: string) {
|
||||
for (let probe = 1; probe <= 4; probe++) {
|
||||
await Bun.sleep(probe * 600);
|
||||
try {
|
||||
if (await existsInS3(fileName)) {
|
||||
console.warn(`[S3 upload recovered-${reason}] key=${fileName} probe=${probe}`);
|
||||
return true;
|
||||
}
|
||||
} catch (existsError) {
|
||||
console.error(`[S3 exists probe failed] key=${fileName} probe=${probe}`, existsError);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
// 1. 요청 시작 전 예열 수행
|
||||
await warmupS3();
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
||||
try {
|
||||
const response = await fetch(mediaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media from ${mediaUrl}: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await writeToS3(fileName, buffer, response.headers.get("content-type"));
|
||||
return;
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
// 2. Bun S3 Client만 사용하여 쓰기
|
||||
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
|
||||
await client.write(fileName, buffer, {
|
||||
type: response.headers.get("content-type") || "application/octet-stream",
|
||||
});
|
||||
|
||||
return; // 성공 시 리턴
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName} url=${mediaUrl}`, error);
|
||||
|
||||
const errorCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: "";
|
||||
|
||||
// Some S3 providers return UnknownError even when the object is eventually persisted.
|
||||
if (errorCode === "UnknownError") {
|
||||
if (await recoverByPollingExists("unknown")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName}`, error);
|
||||
|
||||
// UnknownError 발생 시 잠시 대기 후 재시도 (지수 백오프)
|
||||
if (attempt < maxRetry) {
|
||||
await Bun.sleep(attempt * 800);
|
||||
await Bun.sleep(attempt * 1000);
|
||||
// 재시도 전 다시 한번 예열 시도 가능
|
||||
await warmupS3();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final guard: do one last exists check before surfacing failure.
|
||||
if (await recoverByPollingExists("final")) {
|
||||
// 최종 실패 전 마지막 확인 (이미 올라갔을 수도 있음)
|
||||
if (await client.exists(fileName)) {
|
||||
console.warn(`[S3 upload recovered] key=${fileName} was found after error.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ async function checkTweetData(url: string, selected: Array<boolean>) {
|
|||
}
|
||||
const tweetId = match[1];
|
||||
// find in mongodb if there is already a record with the same tweet id and media index in selected
|
||||
const existing = await MediaUpload.findOne({ "tweetData.tweet.id": tweetId, mediaIndex: { $in: selected.map((s, i) => s ? i : -1).filter(i => i >= 0) } });
|
||||
const existing = await MediaUpload.findOne({ "tweet.id": tweetId, mediaIndex: { $in: selected.map((s, i) => s ? i : -1).filter(i => i >= 0) } });
|
||||
if (existing) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fetchTweetData(url: string) {
|
||||
|
|
|
|||
19
apps/backend/src/models/audit.ts
Normal file
19
apps/backend/src/models/audit.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as mongoose from "mongoose";
|
||||
|
||||
const auditLogSchema = new mongoose.Schema({
|
||||
actor: {
|
||||
userId: { type: String },
|
||||
discordId: { type: String },
|
||||
username: { type: String },
|
||||
role: { type: String },
|
||||
},
|
||||
action: { type: String, required: true },
|
||||
targetType: { type: String, required: true },
|
||||
targetId: { type: String },
|
||||
summary: { type: String },
|
||||
detail: { type: Object },
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export const AuditLog = mongoose.model("AuditLog", auditLogSchema);
|
||||
|
|
@ -13,6 +13,12 @@ const mediaUploadSchema = new mongoose.Schema({
|
|||
s3Key: { type: String, required: true },
|
||||
tags: { type: [String], default: [] },
|
||||
author: { type: String, required: true },
|
||||
uploadedBy: {
|
||||
userId: { type: String },
|
||||
discordId: { type: String },
|
||||
username: { type: String },
|
||||
role: { type: String, enum: ["admin", "writer", "reader"] },
|
||||
},
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
|
|
|||
17
apps/backend/src/models/user.ts
Normal file
17
apps/backend/src/models/user.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as mongoose from "mongoose";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const USER_ROLES = ["admin", "writer", "reader"] as const;
|
||||
export type UserRole = typeof USER_ROLES[number];
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
userId: { type: String, required: true, unique: true, default: () => ulid() },
|
||||
discordId: { type: String, required: true, unique: true },
|
||||
username: { type: String, required: true },
|
||||
avatar: { type: String },
|
||||
role: { type: String, enum: USER_ROLES, default: "reader" },
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export const User = mongoose.model("User", userSchema);
|
||||
381
apps/backend/src/routes/auth.ts
Normal file
381
apps/backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import config from "@/../config.toml";
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
import { AuditLog } from "@/models/audit";
|
||||
import { USER_ROLES, User } from "@/models/user";
|
||||
import { createAuditLog } from "@/lib/audit";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
const hardcodedOwners = new Set<string>(
|
||||
Array.isArray(config.auth.hardcoded_owner)
|
||||
? (config.auth.hardcoded_owner as Array<string | number>).map((id: string | number) => String(id))
|
||||
: [],
|
||||
);
|
||||
|
||||
export default new Elysia({ prefix: "/auth" })
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: config.auth.jwt_secret,
|
||||
})
|
||||
)
|
||||
.get("/me", async ({ jwt, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const user = await User.findOne({ userId: payload.id });
|
||||
if (!user) {
|
||||
return status(404, "User not found");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.userId,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
};
|
||||
})
|
||||
|
||||
.get("/discord/login", ({ redirect }) => redirect(`https://discord.com/oauth2/authorize?client_id=${config.auth.discord_client_id}&response_type=code&redirect_uri=${encodeURIComponent(config.auth.discord_redirect_uri)}&scope=guilds+identify`))
|
||||
|
||||
.get("/roles", () => USER_ROLES)
|
||||
|
||||
.post("/logout", ({ cookie: { mizuki } }) => {
|
||||
mizuki.set({
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
path: "/api",
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
})
|
||||
|
||||
.get("/discord/callback", async ({ jwt, query, status, cookie: { mizuki }, redirect }) => {
|
||||
const code = query.code;
|
||||
if (!code) {
|
||||
return status(400, "Missing code");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.auth.discord_client_id,
|
||||
client_secret: config.auth.discord_client_secret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: config.auth.discord_redirect_uri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error("Failed to exchange code for token", await tokenResponse.text());
|
||||
return status(500, "Failed to exchange code for token");
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
const userResponse = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
console.error("Failed to fetch user info", await userResponse.text());
|
||||
return status(500, "Failed to fetch user info");
|
||||
}
|
||||
|
||||
const userData = await userResponse.json();
|
||||
const isHardcodedOwner = hardcodedOwners.has(String(userData.id));
|
||||
const updateSet: { username: string; avatar: string; role?: "admin" } = {
|
||||
username: userData.username,
|
||||
avatar: userData.avatar,
|
||||
};
|
||||
|
||||
if (isHardcodedOwner) {
|
||||
updateSet.role = "admin";
|
||||
}
|
||||
|
||||
const user = await User.findOneAndUpdate(
|
||||
{ discordId: userData.id },
|
||||
{
|
||||
$set: updateSet,
|
||||
$setOnInsert: {
|
||||
userId: ulid(),
|
||||
discordId: userData.id,
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
|
||||
const ensuredUser = user && !user.userId
|
||||
? await User.findOneAndUpdate(
|
||||
{ discordId: userData.id },
|
||||
{ $set: { userId: ulid() } },
|
||||
{ new: true },
|
||||
)
|
||||
: user;
|
||||
|
||||
const token = await jwt.sign({
|
||||
id: ensuredUser?.userId,
|
||||
discordId: userData.id,
|
||||
username: userData.username,
|
||||
avatar: userData.avatar,
|
||||
role: ensuredUser?.role ?? "reader",
|
||||
});
|
||||
|
||||
mizuki.set({
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: "/api",
|
||||
});
|
||||
|
||||
return redirect("/");
|
||||
}, {
|
||||
query: t.Object({
|
||||
code: t.String(),
|
||||
})
|
||||
})
|
||||
|
||||
.post("/role", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const target = await User.findOne({ userId: body.userId });
|
||||
if (!target) {
|
||||
return status(404, "User not found");
|
||||
}
|
||||
|
||||
if (hardcodedOwners.has(target.discordId) && body.role !== "admin") {
|
||||
return status(400, "Hardcoded owner role must remain admin");
|
||||
}
|
||||
|
||||
const updated = await User.findOneAndUpdate(
|
||||
{ userId: body.userId },
|
||||
{ $set: { role: body.role } },
|
||||
{ new: true },
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
return status(404, "User not found");
|
||||
}
|
||||
|
||||
if (requester.userId === updated.userId) {
|
||||
const nextToken = await jwt.sign({
|
||||
id: updated.userId,
|
||||
discordId: updated.discordId,
|
||||
username: updated.username,
|
||||
avatar: updated.avatar,
|
||||
role: updated.role,
|
||||
});
|
||||
|
||||
mizuki.set({
|
||||
value: nextToken,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: "/api",
|
||||
});
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "auth.role.update",
|
||||
targetType: "user",
|
||||
targetId: updated.userId,
|
||||
summary: `${requester.username} changed role of ${updated.username} to ${updated.role}`,
|
||||
detail: {
|
||||
role: updated.role,
|
||||
username: updated.username,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.userId,
|
||||
discordId: updated.discordId,
|
||||
username: updated.username,
|
||||
avatar: updated.avatar,
|
||||
role: updated.role,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
userId: t.String(),
|
||||
role: t.Union([
|
||||
t.Literal("admin"),
|
||||
t.Literal("writer"),
|
||||
t.Literal("reader"),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/users", async ({ jwt, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const users = await User.find().sort({ createdAt: -1 });
|
||||
return users.map((user) => ({
|
||||
id: user.userId,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
}));
|
||||
})
|
||||
|
||||
.post("/user", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const role = hardcodedOwners.has(body.discordId) ? "admin" : body.role;
|
||||
|
||||
try {
|
||||
const created = await User.create({
|
||||
userId: ulid(),
|
||||
discordId: body.discordId,
|
||||
username: body.username,
|
||||
avatar: body.avatar,
|
||||
role,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "auth.user.create",
|
||||
targetType: "user",
|
||||
targetId: created.userId,
|
||||
summary: `${requester.username} created user ${created.username}`,
|
||||
detail: {
|
||||
username: created.username,
|
||||
discordId: created.discordId,
|
||||
role: created.role,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.userId,
|
||||
discordId: created.discordId,
|
||||
username: created.username,
|
||||
avatar: created.avatar,
|
||||
role: created.role,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create user";
|
||||
return status(400, message);
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
discordId: t.String({ minLength: 1 }),
|
||||
username: t.String({ minLength: 1 }),
|
||||
avatar: t.Optional(t.String()),
|
||||
role: t.Union([
|
||||
t.Literal("admin"),
|
||||
t.Literal("writer"),
|
||||
t.Literal("reader"),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const page = query.page;
|
||||
const size = query.size;
|
||||
const logs = await AuditLog.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * size)
|
||||
.limit(size);
|
||||
return logs;
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Number({ default: 1, minimum: 1 }),
|
||||
size: t.Number({ default: 30, minimum: 1, maximum: 200 }),
|
||||
}),
|
||||
})
|
||||
17
apps/backend/src/routes/pixiv.ts
Normal file
17
apps/backend/src/routes/pixiv.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { fetchPixivData } from "@/lib/pixiv";
|
||||
import { Elysia, t } from "elysia";
|
||||
|
||||
export default new Elysia({ prefix: "/pixiv" })
|
||||
.get("/fetch", async ({ query, status }) => {
|
||||
try {
|
||||
// Implement Pixiv data fetching logic here
|
||||
return await fetchPixivData(query.url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch Pixiv data");
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
url: t.String(),
|
||||
})
|
||||
})
|
||||
633
apps/backend/src/routes/post.ts
Normal file
633
apps/backend/src/routes/post.ts
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import config from "@/../config.toml";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { MediaUpload } from "@/models/media";
|
||||
import { Tag } from "@/models/tag";
|
||||
import { User } from "@/models/user";
|
||||
import { createAuditLog } from "@/lib/audit";
|
||||
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
|
||||
import { checkTweetData, fetchTweetData } from "@/lib/tweet";
|
||||
import { fetchPixivData } from "@/lib/pixiv";
|
||||
import { makeS3FileName, s3Client, uploadToS3 } from "@/lib/s3";
|
||||
|
||||
const inFlightUploads = new Set<string>();
|
||||
|
||||
function buildUploadKey(url: string, selected: boolean[]) {
|
||||
const match = url.match(/\/status\/(\d+)/);
|
||||
const tweetId = match?.[1] ?? url;
|
||||
const selectedIndices = selected
|
||||
.map((isSelected, index) => (isSelected ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
.join(",");
|
||||
|
||||
return `${tweetId}:${selectedIndices}`;
|
||||
}
|
||||
|
||||
async function saveTags(tags: string[]) {
|
||||
await Promise.all(
|
||||
tags.map((tag) =>
|
||||
Tag.updateOne(
|
||||
{ name: tag },
|
||||
{
|
||||
$inc: { usageCount: 1 },
|
||||
$set: { lastUsedAt: new Date() },
|
||||
$setOnInsert: { name: tag },
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function syncTagUsage(previousTags: string[], nextTags: string[]) {
|
||||
const previous = new Set(previousTags);
|
||||
const next = new Set(nextTags);
|
||||
|
||||
const removed = Array.from(previous).filter((tag) => !next.has(tag));
|
||||
const added = Array.from(next).filter((tag) => !previous.has(tag));
|
||||
|
||||
if (added.length > 0) {
|
||||
await saveTags(added);
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
await Promise.all(
|
||||
removed.map((tag) =>
|
||||
Tag.updateOne(
|
||||
{ name: tag },
|
||||
{
|
||||
$inc: { usageCount: -1 },
|
||||
$set: { lastUsedAt: new Date() },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function makePixivFileName(authorId: string, illustId: string, mediaUrl: string, index: number) {
|
||||
const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`;
|
||||
const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`;
|
||||
const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `pixiv/${authorId}/${illustId}/${safeName || `media_${Date.now()}_${index}`}`;
|
||||
}
|
||||
|
||||
async function uploadAndCreateWithRetry(options: {
|
||||
fileName: string;
|
||||
mediaUrl: string;
|
||||
mediaIndex: number;
|
||||
createDocument: () => Promise<void>;
|
||||
}) {
|
||||
const { fileName, mediaUrl, mediaIndex, createDocument } = options;
|
||||
|
||||
const existingBefore = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
|
||||
if (existingBefore) {
|
||||
return { ok: true as const, created: false };
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
if (!(await s3Client.exists(fileName))) {
|
||||
await uploadToS3(fileName, mediaUrl);
|
||||
}
|
||||
|
||||
const existing = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
|
||||
if (existing) {
|
||||
return { ok: true as const, created: false };
|
||||
}
|
||||
|
||||
await createDocument();
|
||||
return { ok: true as const, created: true };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const existingAfterError = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
|
||||
if (existingAfterError) {
|
||||
return { ok: true as const, created: false };
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false as const, error: lastError };
|
||||
}
|
||||
|
||||
export default new Elysia({ prefix: "/post" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
secret: config.auth.jwt_secret,
|
||||
}),
|
||||
)
|
||||
.get("/total", async ({ query }) => {
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const count = await MediaUpload.countDocuments(filter);
|
||||
return count;
|
||||
}, {
|
||||
query: t.Object({
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/list", async ({ query }) => {
|
||||
const page = query.page;
|
||||
const pageSize = query.size || 10;
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const uploads = await MediaUpload.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize);
|
||||
return uploads;
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Number({ default: 1, minimum: 1 }),
|
||||
size: t.Optional(t.Number({ default: 20, minimum: 1, maximum: 100 })),
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/tags", async () => {
|
||||
const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 });
|
||||
return tags;
|
||||
})
|
||||
|
||||
.get("/detail/:id", async ({ params, status }) => {
|
||||
const post = await MediaUpload.findById(params.id);
|
||||
if (!post) {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
_id: post._id,
|
||||
type: post.type,
|
||||
url: post.tweet?.url,
|
||||
author: post.author,
|
||||
tags: Array.isArray(post.tags) ? post.tags : [],
|
||||
mediaUrl: post.mediaUrl,
|
||||
mediaIndex: post.mediaIndex,
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
})
|
||||
})
|
||||
|
||||
.delete("/delete/:id", async ({ params, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const targetPost = await MediaUpload.findById(params.id);
|
||||
const result = await MediaUpload.deleteOne({ _id: params.id });
|
||||
if (result.deletedCount === 1) {
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.delete",
|
||||
targetType: "post",
|
||||
targetId: params.id,
|
||||
summary: `${requester.username} deleted a post`,
|
||||
detail: {
|
||||
id: params.id,
|
||||
mediaUrl: targetPost?.mediaUrl,
|
||||
},
|
||||
});
|
||||
return "삭제되었습니다.";
|
||||
} else {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
})
|
||||
})
|
||||
|
||||
.patch("/edit/:id", async ({ params, body, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const post = await MediaUpload.findById(params.id);
|
||||
if (!post) {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const nextTags = normalizeTags(body.tag ?? post.tags ?? ["미분류"]);
|
||||
const previousTags = Array.isArray(post.tags) ? post.tags : [];
|
||||
post.tags = nextTags;
|
||||
if (body.author !== undefined) {
|
||||
post.author = body.author.trim() || post.author;
|
||||
}
|
||||
|
||||
await post.save();
|
||||
await syncTagUsage(previousTags, nextTags);
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.edit",
|
||||
targetType: "post",
|
||||
targetId: params.id,
|
||||
summary: `${requester.username} edited a post`,
|
||||
detail: {
|
||||
id: params.id,
|
||||
author: post.author,
|
||||
tags: nextTags,
|
||||
},
|
||||
});
|
||||
|
||||
return "수정되었습니다.";
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
tag: t.Optional(t.Array(t.String())),
|
||||
author: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
.post("/upload", async ({ body, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
if (body.url.startsWith("https://www.pixiv.net/")) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, "이미 처리 중인 업로드입니다.");
|
||||
}
|
||||
|
||||
inFlightUploads.add(uploadKey);
|
||||
console.log(`[Pixiv upload started] requestId=${requestId} key=${uploadKey}`);
|
||||
|
||||
try {
|
||||
const pixivData = await fetchPixivData(body.url);
|
||||
const mediaUrls: string[] = Array.isArray(pixivData.image_proxy_urls)
|
||||
? pixivData.image_proxy_urls.filter((url: unknown): url is string => typeof url === "string")
|
||||
: [];
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
return status(400, "No media found in the Pixiv artwork.");
|
||||
}
|
||||
|
||||
// const normalizedTags = normalizeTags(
|
||||
// body.tag
|
||||
// ?? (Array.isArray(pixivData.tags)
|
||||
// ? pixivData.tags
|
||||
// .filter((tag: unknown): tag is string => typeof tag === "string")
|
||||
// .map((tag: string) => tag.replace(/^#/, ""))
|
||||
// : ["미분류"]),
|
||||
// );
|
||||
|
||||
const normalizedTags = normalizeTags(body.tag ?? ["미분류"]);
|
||||
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
const hasExplicitSelection = body.selected.length > 0;
|
||||
|
||||
for (const [index, mediaUrl] of mediaUrls.entries()) {
|
||||
const isSelected = hasExplicitSelection
|
||||
? body.selected[index] === true
|
||||
: true;
|
||||
|
||||
if (!isSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authorId = String(pixivData.author_id || "unknown");
|
||||
const illustId = String(pixivData.illust_id || `pixiv_${Date.now()}`);
|
||||
const fileName = makePixivFileName(authorId, illustId, mediaUrl, index);
|
||||
|
||||
const result = await uploadAndCreateWithRetry({
|
||||
fileName,
|
||||
mediaUrl,
|
||||
mediaIndex: index,
|
||||
createDocument: async () => {
|
||||
await MediaUpload.create({
|
||||
type: "pixiv",
|
||||
tweet: {
|
||||
id: illustId,
|
||||
url: pixivData.url,
|
||||
title: pixivData.title,
|
||||
authorId,
|
||||
authorName: pixivData.author_name,
|
||||
createDate: pixivData.create_date,
|
||||
},
|
||||
mediaIndex: index,
|
||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||
s3Key: fileName,
|
||||
tags: normalizedTags,
|
||||
author: body.author ? body.author : (pixivData.author_name || "unknown"),
|
||||
uploadedBy: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
failedCount += 1;
|
||||
console.error(`[Pixiv upload failed] index=${index} url=${mediaUrl} key=${fileName}`, result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.created) {
|
||||
await saveTags(normalizedTags);
|
||||
savedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === 0 && failedCount === 0) {
|
||||
console.warn("No Pixiv media uploaded: selected[] did not include any upload target.");
|
||||
}
|
||||
|
||||
console.log(`Saved ${savedCount} Pixiv media records to MongoDB. Failed: ${failedCount}`);
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.upload.pixiv",
|
||||
targetType: "post",
|
||||
summary: `${requester.username} uploaded Pixiv media`,
|
||||
detail: {
|
||||
url: body.url,
|
||||
savedCount,
|
||||
failedCount,
|
||||
},
|
||||
});
|
||||
console.log(`[Pixiv upload finished] requestId=${requestId} key=${uploadKey}`);
|
||||
if (savedCount === 0 && failedCount > 0) {
|
||||
return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
if (savedCount === 0) {
|
||||
return status(400, "선택된 이미지가 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
savedCount,
|
||||
failedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Pixiv upload aborted] requestId=${requestId} key=${uploadKey}`, error);
|
||||
return status(500, "Failed to fetch Pixiv data");
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else if (body.url.startsWith("https://x.com/") || body.url.startsWith("https://twitter.com/") || body.url.startsWith("https://fxtwitter.com/") || body.url.startsWith("https://fixupx.com/") || body.url.startsWith("https://vxwitter.com/")) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, "이미 처리 중인 업로드입니다.");
|
||||
}
|
||||
|
||||
inFlightUploads.add(uploadKey);
|
||||
console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`);
|
||||
|
||||
if (await checkTweetData(body.url, body.selected)) {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
console.log(`[Upload skipped-existing] requestId=${requestId} key=${uploadKey}`);
|
||||
return "이미 저장된 트윗입니다.";
|
||||
}
|
||||
try {
|
||||
const tweetData = await fetchTweetData(body.url);
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
if (tweetData.tweet) {
|
||||
const media = tweetData.tweet.media.photos || [];
|
||||
if (media.length > 0) {
|
||||
const mediaUrls = media.map((m: any) => m.url);
|
||||
// Upload to S3
|
||||
const hasExplicitSelection = body.selected.length > 0;
|
||||
for (const [index, url] of mediaUrls.entries()) {
|
||||
const isSelected = hasExplicitSelection
|
||||
? body.selected[index] === true
|
||||
: true;
|
||||
|
||||
if (!isSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, url, index);
|
||||
|
||||
const { media: _media, ...tweetWithoutMedia } = tweetData.tweet;
|
||||
const normalizedTags = normalizeTags(body.tag || ["미분류"]);
|
||||
const result = await uploadAndCreateWithRetry({
|
||||
fileName,
|
||||
mediaUrl: url,
|
||||
mediaIndex: index,
|
||||
createDocument: async () => {
|
||||
await MediaUpload.create({
|
||||
type: "twitter",
|
||||
tweet: tweetWithoutMedia,
|
||||
mediaIndex: index,
|
||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||
s3Key: fileName,
|
||||
tags: normalizedTags,
|
||||
author: body.author ? body.author : tweetData.tweet.author.name,
|
||||
uploadedBy: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
failedCount += 1;
|
||||
console.error(`[Upload failed] index=${index} url=${url} key=${fileName}`, result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.created) {
|
||||
await saveTags(normalizedTags);
|
||||
savedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === 0 && failedCount === 0) {
|
||||
console.warn("No media uploaded: selected[] did not include any upload target.");
|
||||
}
|
||||
|
||||
console.log(`Saved ${savedCount} media records to MongoDB. Failed: ${failedCount}`);
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.upload.twitter",
|
||||
targetType: "post",
|
||||
summary: `${requester.username} uploaded Twitter media`,
|
||||
detail: {
|
||||
url: body.url,
|
||||
savedCount,
|
||||
failedCount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return status(400, "트윗에서 이미지를 찾지 못했습니다.");
|
||||
}
|
||||
}
|
||||
console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`);
|
||||
if (savedCount === 0 && failedCount > 0) {
|
||||
return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
if (savedCount === 0) {
|
||||
return status(400, "선택된 이미지가 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
savedCount,
|
||||
failedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error);
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else {
|
||||
return status(400, "어...");
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String(),
|
||||
tag: t.Optional(t.Array(t.String({ default: "미분류" }))),
|
||||
author: t.Optional(t.String()),
|
||||
selected: t.Array(t.Boolean()),
|
||||
})
|
||||
})
|
||||
|
||||
.post("/bulk-delete", async ({ body, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.ids) || body.ids.length === 0) {
|
||||
return status(400, "삭제할 게시물이 없습니다.");
|
||||
}
|
||||
|
||||
const result = await MediaUpload.deleteMany({ _id: { $in: body.ids } });
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.bulkDelete",
|
||||
targetType: "post",
|
||||
summary: `${requester.username} deleted multiple posts`,
|
||||
detail: {
|
||||
ids: body.ids,
|
||||
deletedCount: result.deletedCount,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deletedCount: result.deletedCount,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }),
|
||||
}),
|
||||
})
|
||||
17
apps/backend/src/routes/tweet.ts
Normal file
17
apps/backend/src/routes/tweet.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { fetchTweetData } from "@/lib/tweet";
|
||||
import { Elysia, t } from "elysia";
|
||||
|
||||
export default new Elysia({ prefix: "/tweet" })
|
||||
.get("/fetch", async ({ query, status }) => {
|
||||
try {
|
||||
const tweetData = await fetchTweetData(query.url);
|
||||
return tweetData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
url: t.String(),
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue