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 718d6fe..86656d0 100644 Binary files a/apps/frontend/src/app/favicon.ico and b/apps/frontend/src/app/favicon.ico differ 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=="],