From 5207f5d431692a56f47412db1a0ab2d1eadb1416 Mon Sep 17 00:00:00 2001 From: imnyang Date: Thu, 16 Apr 2026 00:07:00 +0900 Subject: [PATCH] wow --- README.md | 9 +- apps/backend/package.json | 4 +- apps/backend/src/index.ts | 193 +------ apps/backend/src/lib/audit.ts | 34 ++ apps/backend/src/lib/pixiv.ts | 33 ++ apps/backend/src/lib/s3.ts | 112 ++-- apps/backend/src/lib/tweet.ts | 4 +- apps/backend/src/models/audit.ts | 19 + apps/backend/src/models/media.ts | 6 + apps/backend/src/models/user.ts | 17 + apps/backend/src/routes/auth.ts | 381 ++++++++++++++ apps/backend/src/routes/pixiv.ts | 17 + apps/backend/src/routes/post.ts | 633 +++++++++++++++++++++++ apps/backend/src/routes/tweet.ts | 17 + apps/frontend/next.config.ts | 2 +- apps/frontend/src/app/add/page.tsx | 438 ++++++++++++++++ apps/frontend/src/app/dashboard/page.tsx | 525 +++++++++++++++++++ apps/frontend/src/app/edit/[id]/page.tsx | 283 ++++++++++ apps/frontend/src/app/favicon.ico | Bin 25931 -> 19089 bytes apps/frontend/src/app/favicon.svg | 1 - apps/frontend/src/app/globals.css | 2 +- apps/frontend/src/app/layout.tsx | 5 +- apps/frontend/src/app/page.tsx | 376 ++++++++++++-- apps/frontend/src/components/header.tsx | 147 +++++- bun.lock | 8 + 25 files changed, 2933 insertions(+), 333 deletions(-) create mode 100644 apps/backend/src/lib/audit.ts create mode 100644 apps/backend/src/lib/pixiv.ts create mode 100644 apps/backend/src/models/audit.ts create mode 100644 apps/backend/src/models/user.ts create mode 100644 apps/backend/src/routes/auth.ts create mode 100644 apps/backend/src/routes/pixiv.ts create mode 100644 apps/backend/src/routes/post.ts create mode 100644 apps/backend/src/routes/tweet.ts create mode 100644 apps/frontend/src/app/add/page.tsx create mode 100644 apps/frontend/src/app/dashboard/page.tsx create mode 100644 apps/frontend/src/app/edit/[id]/page.tsx delete mode 100644 apps/frontend/src/app/favicon.svg diff --git a/README.md b/README.md index a2de472..10e8fcd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -- [ ] Login +- [x] Login - [ ] Upload UI -- [ ] Hover시에 아래에 약간 책갈피처럼 작가 정보 나오게 하기 -- [ ] Pixiv Import +- [x] Hover시에 아래에 약간 책갈피처럼 작가 정보 나오게 하기 +- [x] Pixiv Import - [ ] Tag Search -- [ ] Edit (우클릭하면 나오게 하면 될듯) \ No newline at end of file +- [ ] Edit (우클릭하면 나오게 하면 될듯) +- [ ] Tag를 좀 더 이쁜 방식으로 보여주면 좋을듯 + 작가 필터 \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index d2e5d1e..35337fd 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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" diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index c1238bf..a8748ed 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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(); - -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) ; diff --git a/apps/backend/src/lib/audit.ts b/apps/backend/src/lib/audit.ts new file mode 100644 index 0000000..b7101ce --- /dev/null +++ b/apps/backend/src/lib/audit.ts @@ -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; +}; + +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 }; diff --git a/apps/backend/src/lib/pixiv.ts b/apps/backend/src/lib/pixiv.ts new file mode 100644 index 0000000..745d69e --- /dev/null +++ b/apps/backend/src/lib/pixiv.ts @@ -0,0 +1,33 @@ +function fetchPixivData(url: string): Promise { + // 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 }; \ No newline at end of file diff --git a/apps/backend/src/lib/s3.ts b/apps/backend/src/lib/s3.ts index 6fbc088..e4cadba 100644 --- a/apps/backend/src/lib/s3.ts +++ b/apps/backend/src/lib/s3.ts @@ -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; } diff --git a/apps/backend/src/lib/tweet.ts b/apps/backend/src/lib/tweet.ts index baee3ab..4e9ab76 100644 --- a/apps/backend/src/lib/tweet.ts +++ b/apps/backend/src/lib/tweet.ts @@ -8,11 +8,11 @@ async function checkTweetData(url: string, selected: Array) { } 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) { diff --git a/apps/backend/src/models/audit.ts b/apps/backend/src/models/audit.ts new file mode 100644 index 0000000..aa14b05 --- /dev/null +++ b/apps/backend/src/models/audit.ts @@ -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); diff --git a/apps/backend/src/models/media.ts b/apps/backend/src/models/media.ts index cf735cf..3fb00b1 100644 --- a/apps/backend/src/models/media.ts +++ b/apps/backend/src/models/media.ts @@ -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, }); diff --git a/apps/backend/src/models/user.ts b/apps/backend/src/models/user.ts new file mode 100644 index 0000000..dccf070 --- /dev/null +++ b/apps/backend/src/models/user.ts @@ -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); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts new file mode 100644 index 0000000..e7a44dc --- /dev/null +++ b/apps/backend/src/routes/auth.ts @@ -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( + Array.isArray(config.auth.hardcoded_owner) + ? (config.auth.hardcoded_owner as Array).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 }), + }), + }) diff --git a/apps/backend/src/routes/pixiv.ts b/apps/backend/src/routes/pixiv.ts new file mode 100644 index 0000000..d8c0a60 --- /dev/null +++ b/apps/backend/src/routes/pixiv.ts @@ -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(), + }) + }) diff --git a/apps/backend/src/routes/post.ts b/apps/backend/src/routes/post.ts new file mode 100644 index 0000000..475f76d --- /dev/null +++ b/apps/backend/src/routes/post.ts @@ -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(); + +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; +}) { + 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 }), + }), + }) diff --git a/apps/backend/src/routes/tweet.ts b/apps/backend/src/routes/tweet.ts new file mode 100644 index 0000000..43c08cd --- /dev/null +++ b/apps/backend/src/routes/tweet.ts @@ -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(), + }) + }) diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 7000c5a..a91f081 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -7,7 +7,7 @@ const nextConfig: NextConfig = { return [ { source: '/api/:path*', - destination: 'http://localhost:1108/:path*', + destination: `${process.env.API_BASE_URL}/:path*`, }, ]; }, diff --git a/apps/frontend/src/app/add/page.tsx b/apps/frontend/src/app/add/page.tsx new file mode 100644 index 0000000..7522203 --- /dev/null +++ b/apps/frontend/src/app/add/page.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import Header from "../../components/header"; + +type SourceType = "twitter" | "pixiv"; + +type PreviewItem = { + url: string; +}; + +type TweetApiResponse = { + tweet?: { + author?: { name?: string }; + media?: { photos?: Array<{ url?: string }> }; + }; +}; + +type PixivApiResponse = { + image_proxy_urls?: string[]; + author_name?: string; + tags?: string[]; +}; + +type Me = { + role: "admin" | "writer" | "reader"; +}; + +function detectSource(url: string): SourceType | null { + if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) { + return "twitter"; + } + + if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) { + return "pixiv"; + } + + return null; +} + +function splitTags(text: string) { + return text + .split(/[\n,]/) + .map((tag) => tag.trim().replace(/^#/, "")) + .filter((tag) => tag.length > 0); +} + +export default function AddPage() { + const [url, setUrl] = useState(""); + const [author, setAuthor] = useState(""); + const [tagsText, setTagsText] = useState(""); + const [previewItems, setPreviewItems] = useState([]); + const [selected, setSelected] = useState([]); + const [sourceType, setSourceType] = useState(null); + const [loadingPreview, setLoadingPreview] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [lastFetchedUrl, setLastFetchedUrl] = useState(""); + const [viewerRole, setViewerRole] = useState("guest"); + const [loadingRole, setLoadingRole] = useState(true); + + const selectedCount = useMemo( + () => selected.filter(Boolean).length, + [selected], + ); + const canPreview = url.trim().length > 0 && !loadingPreview; + const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0; + const tags = useMemo(() => splitTags(tagsText), [tagsText]); + const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-"; + const canManagePost = viewerRole === "admin" || viewerRole === "writer"; + + useEffect(() => { + let active = true; + + async function loadMe() { + try { + const response = await fetch("/api/auth/me", { cache: "no-store" }); + if (!response.ok) { + if (active) setViewerRole("guest"); + return; + } + + const me = (await response.json()) as Me; + if (active) { + setViewerRole(me.role); + } + } catch { + if (active) { + setViewerRole("guest"); + } + } finally { + if (active) { + setLoadingRole(false); + } + } + } + + void loadMe(); + + return () => { + active = false; + }; + }, []); + + function resetPreview() { + setPreviewItems([]); + setSelected([]); + setSourceType(null); + setLastFetchedUrl(""); + setError(null); + setSuccess(null); + } + + async function fetchPreview(targetUrl?: string) { + const trimmedUrl = (targetUrl ?? url).trim(); + const source = detectSource(trimmedUrl); + setError(null); + setSuccess(null); + + if (!source) { + setError("지원하지 않는 URL 형식입니다. Twitter(X) 또는 Pixiv URL을 입력해 주세요."); + return; + } + + setLoadingPreview(true); + setSourceType(source); + + try { + if (source === "twitter") { + const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, { + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`트위터 미리보기 요청 실패: ${response.status}`); + } + + const data = (await response.json()) as TweetApiResponse; + const items = (data.tweet?.media?.photos ?? []) + .map((photo) => photo.url) + .filter((photoUrl): photoUrl is string => typeof photoUrl === "string" && photoUrl.length > 0) + .map((photoUrl) => ({ url: photoUrl })); + + if (items.length === 0) { + throw new Error("이미지를 찾지 못했습니다."); + } + + setPreviewItems(items); + setSelected(items.map(() => true)); + if (!author.trim()) { + setAuthor(data.tweet?.author?.name ?? ""); + } + setLastFetchedUrl(trimmedUrl); + return; + } + + const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, { + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`); + } + + const data = (await response.json()) as PixivApiResponse; + const items = (data.image_proxy_urls ?? []) + .filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0) + .map((imageUrl) => ({ url: imageUrl })); + + if (items.length === 0) { + throw new Error("이미지를 찾지 못했습니다."); + } + + setPreviewItems(items); + setSelected(items.map(() => true)); + if (!author.trim()) { + setAuthor(data.author_name ?? ""); + } + if (!tagsText.trim()) { + const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", "); + setTagsText(pixivTags); + } + + setLastFetchedUrl(trimmedUrl); + } catch (fetchError) { + setPreviewItems([]); + setSelected([]); + setLastFetchedUrl(""); + setError(fetchError instanceof Error ? fetchError.message : "미리보기를 불러오지 못했습니다."); + } finally { + setLoadingPreview(false); + } + } + + useEffect(() => { + if (loadingPreview) { + return; + } + + const trimmed = url.trim(); + if (!trimmed || trimmed === lastFetchedUrl) { + return; + } + + if (!detectSource(trimmed)) { + return; + } + + const timer = window.setTimeout(() => { + void fetchPreview(trimmed); + }, 450); + + return () => window.clearTimeout(timer); + }, [lastFetchedUrl, loadingPreview, url]); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(null); + + if (!canManagePost) { + setError("writer 또는 admin 권한이 필요합니다."); + return; + } + + if (!sourceType) { + setError("먼저 미리보기를 불러와 주세요."); + return; + } + + if (previewItems.length === 0) { + setError("업로드할 이미지가 없습니다."); + return; + } + + if (selectedCount === 0) { + setError("최소 한 장 이상 선택해 주세요."); + return; + } + + setSubmitting(true); + try { + const tags = splitTags(tagsText); + const response = await fetch("/api/post/upload", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: url.trim(), + author: author.trim() || undefined, + tag: tags.length > 0 ? tags : undefined, + selected, + }), + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error(responseText || `업로드 실패: ${response.status}`); + } + + setSuccess(`${selectedCount}개 이미지 업로드를 요청했습니다.`); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ +
+
+
+
+

Add Post

+

아래와 같은 유형의 URL을 지원해요!

+
    +
  • - https://x.com, https://twitter.com (기타 FxEmbed URL)
  • +
  • - https://pixiv.net (#R-18 태그가 들어갈 시 거부)
  • +
+
+
+

Source: {sourceLabel}

+

Selected: {selectedCount}/{previewItems.length}

+
+
+ +
+ {!loadingRole && !canManagePost ? ( +
+ 업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다. +
+ ) : null} + +
+ +
+ { + setUrl(event.target.value); + + if (previewItems.length > 0 || sourceType) { + resetPreview(); + } + }} + placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40" + disabled={loadingPreview} + required + /> +
+

URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.

+
+ +
+
+ + setAuthor(event.target.value)} + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" + /> +

* 자동으로 작가를 가져옵니다. 만약 잘못된 이름이라면 수동으로 수정할 수 있습니다.

+
+
+ + setTagsText(event.target.value)} + placeholder="" + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" + /> +

* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.

+
+
+ + {error ?

{error}

: null} + {success ?

{success}

: null} + + {loadingPreview ? ( +
+ 미리보기를 불러오는 중입니다. 이미지 개수에 따라 몇 초 정도 소요될 수 있습니다. +
+ ) : null} + +
+ source: {sourceLabel} + selected: {selectedCount}/{previewItems.length} + tags: {tags.length} +
+ + {previewItems.length > 0 ? ( +
+
+

Preview

+
+ {selectedCount}장 선택됨 + + +
+
+ +
+ {previewItems.map((item, index) => ( + + ))} +
+
+ ) : null} + +
+ +
+
+
+ + +
+
+ ); +} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..3ce16a6 --- /dev/null +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,525 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import Header from "../../components/header"; + +type Role = "admin" | "writer" | "reader"; + +type Me = { + id: string; + username: string; + role: Role; +}; + +type UserItem = { + id: string; + discordId: string; + username: string; + avatar?: string; + role: Role; + createdAt?: string; +}; + +type UploadItem = { + _id: string; + mediaUrl: string; + author?: string; + tags?: string[]; + uploadedBy?: { + username?: string; + role?: Role; + }; + createdAt?: string; +}; + +type AuditLog = { + _id: string; + action: string; + summary?: string; + targetType?: string; + targetId?: string; + actor?: { + username?: string; + role?: string; + }; + createdAt?: string; +}; + +export default function DashboardPage() { + const [me, setMe] = useState(null); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [pendingRole, setPendingRole] = useState>({}); + + const [posts, setPosts] = useState([]); + const [selectedPostIds, setSelectedPostIds] = useState([]); + const [deletingPosts, setDeletingPosts] = useState(false); + + const [auditLogs, setAuditLogs] = useState([]); + + const [loading, setLoading] = useState(true); + const [savingUserId, setSavingUserId] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [newDiscordId, setNewDiscordId] = useState(""); + const [newUsername, setNewUsername] = useState(""); + const [newAvatar, setNewAvatar] = useState(""); + const [newRole, setNewRole] = useState("reader"); + + const isAdmin = me?.role === "admin"; + + const sortedUsers = useMemo( + () => [...users].sort((a, b) => a.username.localeCompare(b.username)), + [users], + ); + + function buildPostQuery(page: number, size: number) { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("size", String(size)); + return params.toString(); + } + + async function loadAll() { + setLoading(true); + setError(null); + + try { + const meResponse = await fetch("/api/auth/me", { cache: "no-store" }); + if (!meResponse.ok) { + throw new Error("로그인이 필요합니다."); + } + + const profile = (await meResponse.json()) as Me; + setMe(profile); + + if (profile.role !== "admin") { + setUsers([]); + setRoles([]); + setPosts([]); + setAuditLogs([]); + return; + } + + const [usersResponse, rolesResponse, postsResponse, logsResponse] = await Promise.all([ + fetch("/api/auth/users", { cache: "no-store" }), + fetch("/api/auth/roles", { cache: "no-store" }), + fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { cache: "no-store" }), + fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }), + ]); + + if (!usersResponse.ok) { + throw new Error(`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`); + } + + if (!rolesResponse.ok) { + throw new Error(`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`); + } + + if (!postsResponse.ok) { + throw new Error(`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`); + } + + if (!logsResponse.ok) { + throw new Error(`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`); + } + + const usersData = (await usersResponse.json()) as UserItem[]; + const roleData = (await rolesResponse.json()) as Role[]; + const postData = (await postsResponse.json()) as UploadItem[]; + const logData = (await logsResponse.json()) as AuditLog[]; + + setUsers(usersData); + setRoles(roleData); + setPosts(postData); + setAuditLogs(logData); + setPendingRole( + Object.fromEntries(usersData.map((user) => [user.id, user.role])) as Record, + ); + setSelectedPostIds([]); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "대시보드를 불러오지 못했습니다."); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadAll(); + }, []); + + async function updateRole(user: UserItem) { + const nextRole = pendingRole[user.id] ?? user.role; + if (nextRole === user.role) { + return; + } + + setSavingUserId(user.id); + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/auth/role", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: user.id, + role: nextRole, + }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `역할 변경 실패: ${response.status}`); + } + + setUsers((current) => current.map((item) => (item.id === user.id ? { ...item, role: nextRole } : item))); + setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`); + void refreshLogs(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "역할 변경에 실패했습니다."); + } finally { + setSavingUserId(null); + } + } + + async function createUser(event: FormEvent) { + event.preventDefault(); + setCreating(true); + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/auth/user", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + discordId: newDiscordId.trim(), + username: newUsername.trim(), + avatar: newAvatar.trim() || undefined, + role: newRole, + }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `유저 생성 실패: ${response.status}`); + } + + const created = (await response.json()) as UserItem; + setUsers((current) => [created, ...current]); + setPendingRole((current) => ({ ...current, [created.id]: created.role })); + setNewDiscordId(""); + setNewUsername(""); + setNewAvatar(""); + setNewRole("reader"); + setSuccess(`${created.username} 유저를 생성했습니다.`); + void refreshLogs(); + } catch (createError) { + setError(createError instanceof Error ? createError.message : "유저 생성에 실패했습니다."); + } finally { + setCreating(false); + } + } + + async function refreshLogs() { + try { + const response = await fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }); + if (!response.ok) { + return; + } + const data = (await response.json()) as AuditLog[]; + setAuditLogs(data); + } catch { + // ignore refresh error + } + } + + async function bulkDeletePosts() { + if (selectedPostIds.length === 0) { + return; + } + + const confirmed = window.confirm(`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`); + if (!confirmed) { + return; + } + + setDeletingPosts(true); + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/post/bulk-delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ids: selectedPostIds, + }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `일괄 삭제 실패: ${response.status}`); + } + + const result = (await response.json()) as { deletedCount?: number }; + setPosts((current) => current.filter((post) => !selectedPostIds.includes(post._id))); + setSuccess(`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`); + setSelectedPostIds([]); + void refreshLogs(); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "게시물 일괄 삭제에 실패했습니다."); + } finally { + setDeletingPosts(false); + } + } + + function togglePost(id: string) { + setSelectedPostIds((current) => { + if (current.includes(id)) { + return current.filter((value) => value !== id); + } + return [...current, id]; + }); + } + + function toggleSelectAllPosts() { + if (selectedPostIds.length === posts.length) { + setSelectedPostIds([]); + return; + } + setSelectedPostIds(posts.map((post) => post._id)); + } + + return ( +
+
+ +
+
+
+
+

Dashboard

+

유저/게시물 관리 및 감사 로그

+
+
+ + {loading ?

불러오는 중...

: null} + {error ?

{error}

: null} + {success ?

{success}

: null} + + {!loading && me && !isAdmin ? ( +
+ 관리자만 접근할 수 있습니다. +
+ ) : null} +
+ + {!loading && isAdmin ? ( +
+

User Management

+
+
+ + setNewDiscordId(event.target.value)} + className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none" + required + /> +
+
+ + setNewUsername(event.target.value)} + className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none" + required + /> +
+
+ + setNewAvatar(event.target.value)} + className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none" + /> +
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + {sortedUsers.map((user) => ( + + + + + + + ))} + +
UsernameDiscord IDRoleAction
{user.username}{user.discordId} + + + +
+
+
+ ) : null} + {!loading && isAdmin ? ( +
+

Audit Log

+
+ + + + + + + + + + + {auditLogs.map((log) => ( + + + + + + + ))} + +
TimeActorActionSummary
{log.createdAt ? new Date(log.createdAt).toLocaleString() : "-"}{log.actor?.username || "system"}{log.action}{log.summary || `${log.targetType || "target"}:${log.targetId || "-"}`}
+
+
+ ) : null} + + {!loading && isAdmin ? ( +
+
+

Post Management

+
+ + +
+
+ +
+ {posts.map((post) => { + const checked = selectedPostIds.includes(post._id); + return ( + + ); + })} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/frontend/src/app/edit/[id]/page.tsx b/apps/frontend/src/app/edit/[id]/page.tsx new file mode 100644 index 0000000..920dd5c --- /dev/null +++ b/apps/frontend/src/app/edit/[id]/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import Header from "../../../components/header"; + +type SourceType = "twitter" | "pixiv"; + +type PostDetailResponse = { + _id: string; + type: SourceType; + url?: string; + author?: string; + tags?: string[]; + mediaUrl?: string; +}; + +type Me = { + role: "admin" | "writer" | "reader"; +}; + +function splitTags(text: string) { + return text + .split(/[\n,]/) + .map((tag) => tag.trim().replace(/^#/, "")) + .filter((tag) => tag.length > 0); +} + +export default function EditPage() { + const params = useParams<{ id: string }>(); + const id = params?.id; + + const [sourceType, setSourceType] = useState(null); + const [url, setUrl] = useState(""); + const [author, setAuthor] = useState(""); + const [tagsText, setTagsText] = useState(""); + const [currentImage, setCurrentImage] = useState(null); + const [loadingPost, setLoadingPost] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [viewerRole, setViewerRole] = useState("guest"); + const [loadingRole, setLoadingRole] = useState(true); + + const tags = useMemo(() => splitTags(tagsText), [tagsText]); + const canManagePost = viewerRole === "admin" || viewerRole === "writer"; + const canSubmit = !loadingPost && !submitting && canManagePost && !loadingRole; + const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-"; + + useEffect(() => { + let active = true; + + async function loadMe() { + try { + const response = await fetch("/api/auth/me", { cache: "no-store" }); + if (!response.ok) { + if (active) setViewerRole("guest"); + return; + } + + const me = (await response.json()) as Me; + if (active) { + setViewerRole(me.role); + } + } catch { + if (active) { + setViewerRole("guest"); + } + } finally { + if (active) { + setLoadingRole(false); + } + } + } + + void loadMe(); + + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!id) { + return; + } + + let active = true; + + async function loadPost() { + setLoadingPost(true); + setError(null); + + try { + const response = await fetch(`/api/post/detail/${id}`, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`수정 데이터를 불러오지 못했습니다: ${response.status}`); + } + + const post = (await response.json()) as PostDetailResponse; + if (!active) { + return; + } + + setSourceType(post.type); + setUrl(post.url?.trim() ?? ""); + setAuthor(post.author ?? ""); + setTagsText((post.tags ?? []).join(", ")); + setCurrentImage(post.mediaUrl ?? null); + } catch (loadError) { + if (active) { + setError(loadError instanceof Error ? loadError.message : "수정 데이터를 불러오지 못했습니다."); + } + } finally { + if (active) { + setLoadingPost(false); + } + } + } + + void loadPost(); + + return () => { + active = false; + }; + }, [id]); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(null); + + if (!id) { + setError("포스트 ID를 확인할 수 없습니다."); + return; + } + + if (!canManagePost) { + setError("writer 또는 admin 권한이 필요합니다."); + return; + } + + setSubmitting(true); + try { + const normalizedTags = splitTags(tagsText); + const response = await fetch(`/api/post/edit/${id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + author: author.trim() || undefined, + tag: normalizedTags.length > 0 ? normalizedTags : undefined, + }), + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error(responseText || `수정 실패: ${response.status}`); + } + + setSuccess("게시물 정보를 수정했습니다. 프리뷰 이미지는 변경되지 않습니다."); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "수정에 실패했습니다."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ +
+
+
+
+

Edit Post

+

프리뷰 이미지는 고정이며, 작가/태그만 수정할 수 있습니다.

+
+
+

Source: {sourceLabel}

+

Preview: locked

+
+
+ +
+ {!loadingRole && !canManagePost ? ( +
+ 수정 권한이 없습니다. writer 또는 admin 권한이 필요합니다. +
+ ) : null} + +
+
+

현재 이미지

+ {currentImage ? ( + current media + ) : ( +
+ )} +
+
+ + setUrl(event.target.value)} + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm text-foreground/60 outline-none" + disabled + readOnly + /> +

프리뷰 변경 방지를 위해 URL 수정은 비활성화되어 있습니다.

+
+
+ +
+
+ + setAuthor(event.target.value)} + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" + /> +
+
+ + setTagsText(event.target.value)} + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" + /> +

쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.

+
+
+ + {error ?

{error}

: null} + {success ?

{success}

: null} + + {loadingPost ? ( +
+ 데이터를 불러오는 중입니다. +
+ ) : null} + +
+ source: {sourceLabel} + preview: locked + tags: {tags.length} +
+ +
+ + 취소 + + +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/app/favicon.ico b/apps/frontend/src/app/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..86656d01e7bfe059c8927c9aaa95418daa9372bc 100644 GIT binary patch literal 19089 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 diff --git a/apps/frontend/src/app/favicon.svg b/apps/frontend/src/app/favicon.svg deleted file mode 100644 index 6ed24f9..0000000 --- a/apps/frontend/src/app/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 0206d7f..ca3a235 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -68,7 +68,7 @@ } .image-scale:hover { - z-index: 10; + z-index: 80; box-shadow: 0 15px 45px #0006; } diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 596138e..1ee5ad6 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -5,10 +5,7 @@ import "react-photo-album/masonry.css"; export const metadata: Metadata = { title: "Akiyama Mizuki", - description: "Gallery", - icons: { - icon: "/favicon.svg", - }, + description: "Gallery" }; export default function RootLayout({ diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index d465740..ba7bdcf 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -10,6 +10,7 @@ type Upload = { mediaIndex: number; mediaUrl: string; s3Key: string; + author?: string; tweet: { url: string; }; @@ -28,9 +29,38 @@ type GalleryPhoto = { key: string; href: string; alt: string; + author: string; }; -const PAGE_SIZE = 20; +type Me = { + role: "admin" | "writer" | "reader"; +}; + +type ContextMenuState = { + x: number; + y: number; + photo: GalleryPhoto; +}; + +const DEFAULT_LIST_SIZE = 8; +const EXTRA_PREFETCH_ROWS = 2; + +function getColumnsForWidth(containerWidth: number) { + if (containerWidth < 520) return 2; + if (containerWidth < 900) return 3; + if (containerWidth < 1280) return 4; + return 5; +} + +function calculateListSize(viewportWidth: number, viewportHeight: number) { + const columns = getColumnsForWidth(viewportWidth); + const columnWidth = viewportWidth / columns; + // grid item uses aspect-4/5, so height is width * (5 / 4) + const itemHeight = columnWidth * (5 / 4); + const rowsInViewport = Math.ceil(viewportHeight / itemHeight); + const size = columns * (rowsInViewport + EXTRA_PREFETCH_ROWS); + return Math.max(DEFAULT_LIST_SIZE, size); +} export default function App() { const [uploads, setUploads] = useState([]); @@ -43,12 +73,27 @@ export default function App() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [photos, setPhotos] = useState([]); + const [viewerRole, setViewerRole] = useState("guest"); + const [contextMenu, setContextMenu] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [listSize, setListSize] = useState(() => { + if (typeof window === "undefined") { + return DEFAULT_LIST_SIZE; + } + return calculateListSize(window.innerWidth, window.innerHeight); + }); const sentinelRef = useRef(null); const isFetchingMoreRef = useRef(false); + const loadMoreRef = useRef<() => Promise>(async () => { }); + const wasIntersectingRef = useRef(false); - function buildQuery(page: number, queryTags: string[]) { + function buildQuery(page: number, queryTags: string[], size?: number) { const params = new URLSearchParams(); params.set("page", String(page)); + if (typeof size === "number") { + params.set("size", String(size)); + } for (const tag of queryTags) { params.append("tags", tag); } @@ -69,7 +114,7 @@ export default function App() { async function loadTags() { try { - const tagsResponse = await fetch("/api/tags", { cache: "no-store" }); + const tagsResponse = await fetch("/api/post/tags", { cache: "no-store" }); if (!tagsResponse.ok) { throw new Error(`Failed to load tags: ${tagsResponse.status}`); } @@ -92,6 +137,119 @@ export default function App() { }; }, []); + useEffect(() => { + let active = true; + + async function loadMe() { + try { + const response = await fetch("/api/auth/me", { cache: "no-store" }); + if (!response.ok) { + if (active) setViewerRole("guest"); + return; + } + + const me = (await response.json()) as Me; + if (active) { + setViewerRole(me.role); + } + } catch { + if (active) { + setViewerRole("guest"); + } + } + } + + void loadMe(); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + function closeContextMenu() { + setContextMenu(null); + } + + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setContextMenu(null); + if (!isDeleting) { + setDeleteTarget(null); + } + } + } + + document.addEventListener("click", closeContextMenu); + document.addEventListener("scroll", closeContextMenu, true); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("click", closeContextMenu); + document.removeEventListener("scroll", closeContextMenu, true); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [isDeleting]); + + const canManagePost = viewerRole === "admin" || viewerRole === "writer"; + + async function deletePhoto(photo: GalleryPhoto) { + try { + setIsDeleting(true); + const response = await fetch(`/api/post/delete/${photo.key}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error(`Failed to delete post: ${response.status}`); + } + + setUploads((current) => current.filter((upload) => upload._id !== photo.key)); + setTotal((current) => Math.max(0, current - 1)); + setDeleteTarget(null); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete post"); + } finally { + setIsDeleting(false); + } + } + + async function copyImage(photo: GalleryPhoto) { + try { + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { + const response = await fetch(photo.src, { cache: "no-store" }); + const blob = await response.blob(); + await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); + } else if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(photo.src); + } + } catch { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(photo.src); + } + } + } + + function saveImage(photo: GalleryPhoto) { + const link = document.createElement("a"); + link.href = photo.src; + link.download = `${photo.key}.jpg`; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + document.body.appendChild(link); + link.click(); + link.remove(); + } + + useEffect(() => { + function updateListSize() { + const nextSize = calculateListSize(window.innerWidth, window.innerHeight); + setListSize((current) => (current === nextSize ? current : nextSize)); + } + + updateListSize(); + window.addEventListener("resize", updateListSize); + return () => window.removeEventListener("resize", updateListSize); + }, []); + const loadMore = useCallback(async () => { if (isFetchingMoreRef.current || loading || loadingMore || !hasMore) { return; @@ -102,8 +260,8 @@ export default function App() { try { const nextPage = page + 1; - const query = buildQuery(nextPage, selectedTags); - const response = await fetch(`/api/list?${query}`, { cache: "no-store" }); + const query = buildQuery(nextPage, selectedTags, listSize); + const response = await fetch(`/api/post/list?${query}`, { cache: "no-store" }); if (!response.ok) { throw new Error(`Failed to load more gallery: ${response.status}`); @@ -120,7 +278,7 @@ export default function App() { const existingIds = new Set(current.map((item) => item._id)); const appended = data.filter((item) => !existingIds.has(item._id)); const merged = [...current, ...appended]; - setHasMore(merged.length < total && data.length >= PAGE_SIZE); + setHasMore(merged.length < total && data.length >= listSize); return merged; }); @@ -132,7 +290,11 @@ export default function App() { isFetchingMoreRef.current = false; setLoadingMore(false); } - }, [hasMore, loading, loadingMore, page, selectedTags, total]); + }, [hasMore, listSize, loading, loadingMore, page, selectedTags, total]); + + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); useEffect(() => { if (!hasMore || loading) { @@ -146,16 +308,30 @@ export default function App() { const observer = new IntersectionObserver( (entries) => { - if (entries[0]?.isIntersecting) { - void loadMore(); + const entry = entries[0]; + if (!entry) { + return; + } + + if (entry.isIntersecting && !wasIntersectingRef.current) { + wasIntersectingRef.current = true; + void loadMoreRef.current(); + return; + } + + if (!entry.isIntersecting) { + wasIntersectingRef.current = false; } }, - { rootMargin: "800px 0px" }, + { rootMargin: "200px 0px" }, ); observer.observe(target); - return () => observer.disconnect(); - }, [hasMore, loadMore, loading]); + return () => { + observer.disconnect(); + wasIntersectingRef.current = false; + }; + }, [hasMore, loading]); useEffect(() => { let active = true; @@ -168,10 +344,10 @@ export default function App() { setHasMore(true); isFetchingMoreRef.current = false; setError(null); - const query = buildQuery(1, selectedTags); + const query = buildQuery(1, selectedTags, listSize); const [listResponse, totalResponse] = await Promise.all([ - fetch(`/api/list?${query}`, { cache: "no-store" }), - fetch(`/api/total?${query}`, { cache: "no-store" }), + fetch(`/api/post/list?${query}`, { cache: "no-store" }), + fetch(`/api/post/total?${buildQuery(1, selectedTags)}`, { cache: "no-store" }), ]); if (!listResponse.ok) { @@ -212,7 +388,7 @@ export default function App() { return () => { active = false; }; - }, [selectedTags]); + }, [listSize, selectedTags]); const items = useMemo(() => uploads.filter((upload) => Boolean(upload.mediaUrl)), [uploads]); @@ -239,6 +415,7 @@ export default function App() { key: upload._id, href: upload.tweet.url, alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`, + author: upload.author?.trim() || "unknown", }); }; image.onerror = () => { @@ -249,6 +426,7 @@ export default function App() { key: upload._id, href: upload.mediaUrl, alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`, + author: upload.author?.trim() || "unknown", }); }; }), @@ -312,41 +490,155 @@ export default function App() { ) : (
- photos={photos} spacing={0} - columns={(containerWidth) => { - if (containerWidth < 520) return 2; - if (containerWidth < 900) return 3; - if (containerWidth < 1280) return 4; - return 5; + columns={(containerWidth) => getColumnsForWidth(containerWidth)} + render={{ + photo: ({ onClick }, { photo }) => ( + { + event.preventDefault(); + event.stopPropagation(); + const safeX = Math.min(event.clientX, window.innerWidth - 200); + const safeY = Math.min(event.clientY, window.innerHeight - 220); + setContextMenu({ x: Math.max(12, safeX), y: Math.max(12, safeY), photo }); + }} + target="_blank" + rel="noopener noreferrer" + className="group relative block overflow-hidden image-scale" + title={`작가 ${photo.author}`} + > + {photo.alt} +
+
+ © {photo.author} +
+
+
+ ), }} componentsProps={{ container: { className: "!w-full" }, - image: { - loading: "lazy", - decoding: "async", - className: "block w-full", - }, - link: { - className: "block overflow-hidden image-scale", - }, }} />
- )} - - {!loading && hasMore ?
: null} - {loadingMore ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
- ))} + ) + } + + {contextMenu ? ( +
event.stopPropagation()} + > + {canManagePost ? ( + <> + + +
+ + ) : null} + +
) : null} -
+ {!loading && hasMore ?
: null} + { + loadingMore ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : null + } + {deleteTarget ? ( +
{ + if (!isDeleting) { + setDeleteTarget(null); + } + }} + > +
event.stopPropagation()} + > +

게시물을 삭제할까요?

+

삭제 후에는 되돌릴 수 없습니다.

+
+ + +
+
+
+ ) : null} +
); } diff --git a/apps/frontend/src/components/header.tsx b/apps/frontend/src/components/header.tsx index 5efcb04..20f60a6 100644 --- a/apps/frontend/src/components/header.tsx +++ b/apps/frontend/src/components/header.tsx @@ -1,11 +1,148 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type Me = { + id: string; + discordId: string; + username: string; + avatar?: string; + role: "admin" | "writer" | "reader"; +}; + +function getAvatarUrl(me: Me) { + if (!me.avatar) { + return `https://cdn.discordapp.com/embed/avatars/${Number(me.discordId) % 5}.png`; + } + + if (me.avatar.startsWith("http://") || me.avatar.startsWith("https://")) { + return me.avatar; + } + + const ext = me.avatar.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/avatars/${me.discordId}/${me.avatar}.${ext}?size=128`; +} + export default function Header() { + const [me, setMe] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + let active = true; + + async function loadMe() { + try { + const response = await fetch("/api/auth/me", { cache: "no-store" }); + if (!response.ok) { + if (response.status === 401 || response.status === 404) { + if (active) setMe(null); + return; + } + throw new Error(`Failed to load profile: ${response.status}`); + } + + const profile = await response.json() as Me; + if (active) { + setMe(profile); + } + } catch { + if (active) { + setMe(null); + } + } + } + + void loadMe(); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + function handleOutsideClick(event: MouseEvent) { + if (!menuRef.current) { + return; + } + + if (!menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + } + + document.addEventListener("mousedown", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, []); + + async function logout() { + try { + await fetch("/api/auth/logout", { + method: "POST", + cache: "no-store", + }); + } finally { + setMenuOpen(false); + setMe(null); + window.location.href = "/"; + } + } + return ( -
+
🎀 - + {me ? ( + + ) : ( + [ Login ] + )}
); } \ No newline at end of file diff --git a/bun.lock b/bun.lock index 48e776e..4500140 100644 --- a/bun.lock +++ b/bun.lock @@ -10,9 +10,11 @@ "version": "1.0.50", "dependencies": { "@aws-sdk/client-s3": "^3.1030.0", + "@elysiajs/jwt": "^1.4.1", "@elysiajs/openapi": "^1.4.14", "elysia": "latest", "mongoose": "^9.4.1", + "ulid": "^3.0.2", }, "devDependencies": { "bun-types": "latest", @@ -151,6 +153,8 @@ "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@elysiajs/jwt": ["@elysiajs/jwt@1.4.1", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.27" } }, "sha512-Qx28XE7hUf2XK/+HZB+hOBGSq6dPs4u1lk80wNge9jijAfzCwftxjOiAWO57RKYSEMdJTOG1ItEL5fbXjJyWsA=="], + "@elysiajs/openapi": ["@elysiajs/openapi@1.4.14", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-kWmJWdvP8/LwHwAJXSpz6xFfYUoyUyEPRimEYABuDU1rOnS27Da1u9T2jyU7frOopxKWV/wDfDxMP8z2xdCPJw=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -427,6 +431,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "kareem": ["kareem@3.2.0", "", {}, "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -523,6 +529,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],