From f2b021d67f74b57f09b8fc63604fbbedfaf7495c Mon Sep 17 00:00:00 2001 From: fakeminjun7321 Date: Thu, 16 Jul 2026 11:07:50 +0900 Subject: [PATCH 1/4] feat: add classbot files and name registration --- apps/classbot/db/migrations/002_files.sql | 42 +++ apps/classbot/db/schema.sql | 32 ++- apps/classbot/package-lock.json | 144 ++++++++++ apps/classbot/package.json | 1 + apps/classbot/server/app.js | 121 +++++++- apps/classbot/server/app.test.js | 45 +++ .../classbot/server/integration-mount.test.js | 8 + apps/classbot/server/services/commands.js | 263 ++++++++++++++++-- .../classbot/server/services/commands.test.js | 260 ++++++++++++++++- apps/classbot/server/services/file-tokens.js | 41 +++ .../server/services/file-tokens.test.js | 19 ++ apps/classbot/server/services/kakao.js | 58 +++- apps/classbot/server/services/kakao.test.js | 22 +- apps/classbot/server/store/memory-store.js | 89 ++++++ .../server/store/schema-security.test.js | 12 +- apps/classbot/server/store/supabase-store.js | 135 ++++++++- apps/classbot/src/App.jsx | 31 ++- apps/classbot/src/api/client.js | 43 ++- apps/classbot/src/components/AppShell.jsx | 5 +- apps/classbot/src/pages/FilesPage.jsx | 113 ++++++++ apps/classbot/src/styles.css | 45 ++- 21 files changed, 1481 insertions(+), 48 deletions(-) create mode 100644 apps/classbot/db/migrations/002_files.sql create mode 100644 apps/classbot/server/services/file-tokens.js create mode 100644 apps/classbot/server/services/file-tokens.test.js create mode 100644 apps/classbot/src/pages/FilesPage.jsx diff --git a/apps/classbot/db/migrations/002_files.sql b/apps/classbot/db/migrations/002_files.sql new file mode 100644 index 0000000..c7dfa46 --- /dev/null +++ b/apps/classbot/db/migrations/002_files.sql @@ -0,0 +1,42 @@ +begin; + +create table if not exists public.classbot_files ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid references public.classbot_members(id) on delete cascade, + alias text not null check (char_length(alias) between 1 and 60), + filename text not null check (char_length(filename) between 1 and 180), + description text not null default '' check (char_length(description) <= 1000), + mime_type text not null check (mime_type in ('application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif')), + size_bytes bigint not null check (size_bytes between 1 and 20971520), + bucket text not null, + object_path text not null unique, + status text not null default 'active' check (status in ('active', 'deleted')), + created_by text not null default 'admin', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists classbot_files_class_alias_idx + on public.classbot_files(class_id, lower(alias)) + where member_id is null and status = 'active'; + +create unique index if not exists classbot_files_member_alias_idx + on public.classbot_files(class_id, member_id, lower(alias)) + where member_id is not null and status = 'active'; + +create index if not exists classbot_files_list_idx + on public.classbot_files(class_id, status, created_at desc); + +drop trigger if exists set_updated_at on public.classbot_files; +create trigger set_updated_at +before update on public.classbot_files +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_files enable row level security; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 2, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; + +commit; diff --git a/apps/classbot/db/schema.sql b/apps/classbot/db/schema.sql index c3a0724..5e88014 100644 --- a/apps/classbot/db/schema.sql +++ b/apps/classbot/db/schema.sql @@ -7,7 +7,7 @@ create table if not exists public.classbot_schema_meta ( ); insert into public.classbot_schema_meta(id, version, applied_at) -values (1, 1, now()) +values (1, 2, now()) on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; create or replace function public.classbot_health_check() @@ -388,6 +388,34 @@ create unique index if not exists classbot_notices_request_key_idx on public.classbot_notices(class_id, request_key) where request_key is not null; +create table if not exists public.classbot_files ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid references public.classbot_members(id) on delete cascade, + alias text not null check (char_length(alias) between 1 and 60), + filename text not null check (char_length(filename) between 1 and 180), + description text not null default '' check (char_length(description) <= 1000), + mime_type text not null check (mime_type in ('application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif')), + size_bytes bigint not null check (size_bytes between 1 and 20971520), + bucket text not null, + object_path text not null unique, + status text not null default 'active' check (status in ('active', 'deleted')), + created_by text not null default 'admin', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists classbot_files_class_alias_idx + on public.classbot_files(class_id, lower(alias)) + where member_id is null and status = 'active'; + +create unique index if not exists classbot_files_member_alias_idx + on public.classbot_files(class_id, member_id, lower(alias)) + where member_id is not null and status = 'active'; + +create index if not exists classbot_files_list_idx + on public.classbot_files(class_id, status, created_at desc); + create table if not exists public.classbot_notifications ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classbot_classes(id) on delete cascade, @@ -447,6 +475,7 @@ begin 'classbot_timetable', 'classbot_events', 'classbot_notices', + 'classbot_files', 'classbot_notifications' ] loop @@ -466,6 +495,7 @@ alter table public.classbot_invites enable row level security; alter table public.classbot_timetable enable row level security; alter table public.classbot_events enable row level security; alter table public.classbot_notices enable row level security; +alter table public.classbot_files enable row level security; alter table public.classbot_notifications enable row level security; alter table public.classbot_audit_logs enable row level security; diff --git a/apps/classbot/package-lock.json b/apps/classbot/package-lock.json index e34934e..97a43cd 100644 --- a/apps/classbot/package-lock.json +++ b/apps/classbot/package-lock.json @@ -14,6 +14,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "lucide-react": "^0.468.0", + "multer": "^2.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1417,6 +1418,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1522,6 +1529,23 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1669,6 +1693,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", @@ -2875,6 +2914,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -3111,6 +3212,20 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3389,6 +3504,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3551,6 +3683,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3591,6 +3729,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/apps/classbot/package.json b/apps/classbot/package.json index 94089e9..6194d83 100644 --- a/apps/classbot/package.json +++ b/apps/classbot/package.json @@ -28,6 +28,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "lucide-react": "^0.468.0", + "multer": "^2.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/apps/classbot/server/app.js b/apps/classbot/server/app.js index d6de824..e0f0b7f 100644 --- a/apps/classbot/server/app.js +++ b/apps/classbot/server/app.js @@ -4,9 +4,11 @@ import { fileURLToPath } from "node:url"; import cookieSession from "cookie-session"; import express from "express"; import helmet from "helmet"; +import multer from "multer"; import { loadConfig } from "./config.js"; import { createInviteCode, createCronGuard, hashInviteCode, requireAdmin, safeEqual } from "./security.js"; import { createStore } from "./store/index.js"; +import { createFileToken, verifyFileToken } from "./services/file-tokens.js"; import { KakaoEventClient, simpleTextResponse } from "./services/kakao.js"; import { handleKakaoCommand } from "./services/commands.js"; import { NotificationService } from "./services/notifications.js"; @@ -18,6 +20,8 @@ const CATEGORIES = new Set(["assessment", "assignment", "class", "schedule_chang const EVENT_STATUSES = new Set(["scheduled", "completed", "cancelled"]); const NOTICE_STATUSES = new Set(["draft", "published", "archived"]); const MEMBER_STATUSES = new Set(["invited", "active", "disabled", "left"]); +const FILE_MIME_TYPES = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"]); +const MAX_FILE_BYTES = 20 * 1024 * 1024; class HttpError extends Error { constructor(status, message) { @@ -41,6 +45,42 @@ function optionalString(value, label, maxLength = 1000) { return text; } +function detectedFileMime(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4) return null; + if (buffer.subarray(0, 1024).includes(Buffer.from("%PDF-"))) return "application/pdf"; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png"; + if (["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif"; + if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp"; + return null; +} + +function publicFile(file) { + const { bucket: _bucket, object_path: _objectPath, created_by: _createdBy, ...safe } = file; + return { ...safe, visibility: safe.member_id ? "private" : "class" }; +} + +function safeFileName(value) { + return String(value || "file").replace(/[\r\n\0"]/g, "_").slice(0, 180) || "file"; +} + +function sendFileBody(res, file, body, { attachment = false } = {}) { + const filename = safeFileName(file.filename); + res.set({ + "Content-Type": file.mime_type, + "Content-Length": String(body.length), + "Content-Disposition": `${attachment ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(filename)}`, + "Cache-Control": "private, no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }); + return res.send(body); +} + +function fileLink(req, token) { + const basePath = String(req.baseUrl || "").replace(/\/$/, ""); + return new URL(`${basePath}/api/files/${encodeURIComponent(token)}`, `${req.protocol}://${req.get("host")}`).toString(); +} + function booleanValue(value, fallback) { if (value === undefined) return fallback; if (typeof value !== "boolean") throw new HttpError(400, "켜기/끄기 값은 boolean이어야 합니다."); @@ -208,6 +248,25 @@ export async function createApp(options = {}) { const embedded = options.embedded === true; const app = express(); const origins = allowedOrigins(config.allowedOrigin); + const fileForAdmin = (req, file) => { + const safe = publicFile(file); + if (file.member_id) return safe; + const issuedAt = now(); + return { + ...safe, + share_url: fileLink(req, createFileToken(file.id, config.sessionSecret, { now: issuedAt })), + share_expires_at: new Date(issuedAt.getTime() + 15 * 60_000).toISOString(), + }; + }; + const fileUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_BYTES, files: 1, fields: 8, fieldSize: 4 * 1024 }, + }).single("file"); + const receiveFile = (req, res, next) => fileUpload(req, res, (error) => { + if (!error) return next(); + if (error.code === "LIMIT_FILE_SIZE") return next(new HttpError(413, "파일은 20MB 이하여야 합니다.")); + return next(new HttpError(400, "파일 업로드 형식이 올바르지 않습니다.")); + }); if (config.production) app.set("trust proxy", 1); app.disable("x-powered-by"); @@ -275,6 +334,18 @@ export async function createApp(options = {}) { } })); + app.get("/api/files/:token", asyncRoute(async (req, res) => { + let verified; + try { + verified = verifyFileToken(req.params.token, config.sessionSecret, { now: now() }); + } catch (error) { + throw new HttpError(/만료/.test(error.message) ? 410 : 403, error.message); + } + const file = await store.getFile(verified.fileId); + if (!file || file.status !== "active") throw new HttpError(404, "파일을 찾을 수 없습니다."); + sendFileBody(res, file, await store.downloadFile(file.id)); + })); + const isAdminRequest = (req) => embedded ? req.classbotExternalAdmin === true : req.session?.isAdmin === true; @@ -299,6 +370,49 @@ export async function createApp(options = {}) { return requireAdmin(req, res, next); }); + app.get("/api/admin/files", asyncRoute(async (req, res) => { + res.json({ items: (await store.listFiles({ all: true })).map((file) => fileForAdmin(req, file)) }); + })); + app.post("/api/admin/files", receiveFile, asyncRoute(async (req, res) => { + if (!req.file?.buffer?.length) throw new HttpError(400, "올릴 PDF 또는 이미지 파일을 선택해 주세요."); + const mimeType = detectedFileMime(req.file.buffer); + if (!mimeType || !FILE_MIME_TYPES.has(mimeType)) { + throw new HttpError(400, "PDF, JPEG, PNG, WebP, GIF 파일만 올릴 수 있습니다."); + } + const visibility = String(req.body?.visibility || (req.body?.member_id ? "private" : "class")); + if (!new Set(["class", "private"]).has(visibility)) throw new HttpError(400, "자료 공개 범위가 올바르지 않습니다."); + let memberId = req.body?.member_id ? requireString(req.body.member_id, "개인 자료 구성원", 128) : null; + if (visibility === "private" && !memberId) throw new HttpError(400, "개인 자료를 받을 구성원을 선택해 주세요."); + if (visibility === "class" && memberId) throw new HttpError(400, "반 전체 자료에는 개인 구성원을 지정할 수 없습니다."); + if (memberId) { + const member = await store.getMember(memberId); + if (!member || member.status === "left") throw new HttpError(400, "개인 자료를 받을 구성원을 찾을 수 없습니다."); + } + const item = await store.createFile({ + member_id: memberId, + alias: requireString(req.body?.alias, "자료 별칭", 60), + filename: requireString(req.file.originalname, "파일 이름", 180), + description: optionalString(req.body?.description, "자료 설명", 1000), + mime_type: mimeType, + }, req.file.buffer, "admin"); + res.status(201).json({ item: fileForAdmin(req, item) }); + })); + app.patch("/api/admin/files/:id", asyncRoute(async (req, res) => { + const patch = {}; + if (req.body.alias !== undefined) patch.alias = requireString(req.body.alias, "자료 별칭", 60); + if (req.body.description !== undefined) patch.description = optionalString(req.body.description, "자료 설명", 1000); + if (!Object.keys(patch).length) throw new HttpError(400, "변경할 자료 정보를 입력해 주세요."); + res.json({ item: fileForAdmin(req, await store.updateFile(req.params.id, patch, "admin")) }); + })); + app.get("/api/admin/files/:id/download", asyncRoute(async (req, res) => { + const file = await store.getFile(req.params.id); + if (!file || file.status !== "active") throw new HttpError(404, "파일을 찾을 수 없습니다."); + sendFileBody(res, file, await store.downloadFile(file.id), { attachment: true }); + })); + app.delete("/api/admin/files/:id", asyncRoute(async (req, res) => { + res.json({ item: publicFile(await store.deleteFile(req.params.id, "admin")) }); + })); + app.get("/api/admin/overview", asyncRoute(async (_req, res) => { const [classroom, members, timetable, events, notices, notificationItems] = await Promise.all([ store.getClassroom(), @@ -482,7 +596,12 @@ export async function createApp(options = {}) { const supplied = req.get("x-classbot-skill-secret") || req.query.secret; if (!safeEqual(supplied, config.kakaoSkillSecret)) return res.status(401).json(simpleTextResponse("인증되지 않은 스킬 요청입니다.")); } - res.json(await handleKakaoCommand({ payload: req.body || {}, store, now: now() })); + res.json(await handleKakaoCommand({ + payload: req.body || {}, + store, + now: now(), + makeFileUrl: (file) => fileLink(req, createFileToken(file.id, config.sessionSecret, { now: now() })), + })); })); app.post("/api/cron/notifications", createCronGuard(config.cronSecret), asyncRoute(async (req, res) => { diff --git a/apps/classbot/server/app.test.js b/apps/classbot/server/app.test.js index 65756f0..774a783 100644 --- a/apps/classbot/server/app.test.js +++ b/apps/classbot/server/app.test.js @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import request from "supertest"; import { createApp } from "./app.js"; +import { createFileToken } from "./services/file-tokens.js"; import { MemoryStore } from "./store/memory-store.js"; const config = { @@ -50,6 +51,50 @@ test("관리자 세션이 보호되고 로그인 후 overview를 조회한다", assert.equal(Array.isArray(response.body.notices), true); }); +test("관리자는 실제 PDF·이미지만 자료실에 올리고 비공개 링크로 내려받는다", async () => { + const { app, agent, store } = await fixture(); + await login(agent); + const pdf = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF"); + const uploaded = await agent + .post("/api/admin/files") + .field("alias", "화학 실험 안내") + .field("description", "실험 전 읽을 자료") + .field("visibility", "class") + .attach("file", pdf, { filename: "화학안내.pdf", contentType: "application/pdf" }); + assert.equal(uploaded.status, 201); + assert.equal(uploaded.body.item.alias, "화학 실험 안내"); + assert.equal(uploaded.body.item.visibility, "class"); + assert.equal("bucket" in uploaded.body.item, false); + assert.equal("object_path" in uploaded.body.item, false); + + const listed = await agent.get("/api/admin/files"); + assert.equal(listed.body.items.length, 1); + const adminDownload = await agent.get(`/api/admin/files/${uploaded.body.item.id}/download`).buffer(true); + assert.equal(adminDownload.status, 200); + assert.match(adminDownload.headers["content-disposition"], /attachment/); + + const token = createFileToken(uploaded.body.item.id, config.sessionSecret, { + now: new Date("2026-07-15T03:00:00.000Z"), + ttlSeconds: 60, + }); + const publicDownload = await request(app).get(`/api/files/${encodeURIComponent(token)}`).buffer(true); + assert.equal(publicDownload.status, 200); + assert.match(publicDownload.headers["content-disposition"], /inline/); + assert.equal(publicDownload.headers["x-content-type-options"], "nosniff"); + + const rejected = await agent + .post("/api/admin/files") + .field("alias", "가짜 PDF") + .field("visibility", "class") + .attach("file", Buffer.from("not a pdf"), { filename: "fake.pdf", contentType: "application/pdf" }); + assert.equal(rejected.status, 400); + assert.equal((await store.listFiles({ all: true })).length, 1); + + const removed = await agent.delete(`/api/admin/files/${uploaded.body.item.id}`); + assert.equal(removed.status, 200); + assert.equal((await request(app).get(`/api/files/${encodeURIComponent(token)}`)).status, 404); +}); + test("health check는 저장소 연결 실패를 503으로 노출하되 내부 오류는 숨긴다", async () => { const store = new MemoryStore(config); store.healthCheck = async () => { throw new Error("database password should not leak"); }; diff --git a/apps/classbot/server/integration-mount.test.js b/apps/classbot/server/integration-mount.test.js index 08b742e..d291add 100644 --- a/apps/classbot/server/integration-mount.test.js +++ b/apps/classbot/server/integration-mount.test.js @@ -36,6 +36,14 @@ test("기존 Express 4 Quilo 서버의 /schedule namespace에서 API가 동작 const overview = await request(parent).get("/schedule/api/admin/overview"); assert.equal(overview.status, 200); + const uploaded = await request(parent) + .post("/schedule/api/admin/files") + .field("alias", "마운트 자료") + .field("visibility", "class") + .attach("file", Buffer.from("%PDF-1.4\n%%EOF"), { filename: "mount.pdf", contentType: "application/pdf" }); + assert.equal(uploaded.status, 201); + assert.match(new URL(uploaded.body.item.share_url).pathname, /^\/schedule\/api\/files\//); + const rootApi = await request(parent).get("/api/health"); assert.equal(rootApi.status, 404); diff --git a/apps/classbot/server/services/commands.js b/apps/classbot/server/services/commands.js index 483c957..a64b3a4 100644 --- a/apps/classbot/server/services/commands.js +++ b/apps/classbot/server/services/commands.js @@ -1,4 +1,11 @@ -import { extractKakaoUser, personalizedQuickReplies, simpleTextResponse } from "./kakao.js"; +import { + extractKakaoUser, + personalizedQuickReplies, + registeredQuickReplies, + simpleImageResponse, + simpleTextResponse, + textCardResponse, +} from "./kakao.js"; import { formatEventRows, formatTimetableRows, @@ -8,12 +15,14 @@ import { getWeekEvents, getWeekTimetable, } from "./schedule.js"; -import { dateForSeoulOffset, formatKoreanDate } from "../time.js"; +import { dateForSeoulOffset, formatKoreanDate, getSeoulParts } from "../time.js"; const TRAILING_REQUEST_WORDS = new Set([ "알려줘", "알려주세요", "보여줘", "보여주세요", "확인", "확인해줘", "확인해주세요", "조회", "조회해줘", "조회해주세요", "뭐야", "있어", "있나요", "부탁해", "부탁해요", "해줘", "해주세요", "일정", "시간표", "수업표", "수행평가", "시험", "과제", "숙제", "스케줄", + "전체", "요약", "통합요약", "남은일정", "이번달", "이달", "다음일정", + "자료", "목록", "자료목록", "파일", "pdf", "이미지", ]); function parameterValue(value) { @@ -31,7 +40,7 @@ function extractInviteCode(payload, command) { const params = payload.action?.params || {}; const explicit = parameterValue(params.inviteCode || params.invite_code || params.code).trim(); if (explicit) return explicit; - return command.match(/(?:가입|초대)\s*([A-Z0-9]{4}-?[A-Z0-9]{4})/i)?.[1] || ""; + return command.match(/(?:가입|초대|이름등록)\s*([A-Z0-9]{4}-?[A-Z0-9]{4})/i)?.[1] || ""; } function normalizedText(value) { @@ -45,17 +54,47 @@ function compactText(value) { return normalizedText(value).replace(/\s+/g, "").toLowerCase(); } +function looksLikeFileCommand(command) { + return /^(?:자료\s*(?:목록|리스트)|파일(?:\s|$)|pdf(?:\s|$)|이미지(?:\s|$))/iu.test(normalizedText(command)); +} + +function readFileCommand(command) { + const text = normalizedText(command); + if (/^자료\s*(?:목록|리스트)$/u.test(text)) return { kind: "list" }; + const match = text.match(/^(파일|pdf|이미지)\s+(.+)$/iu); + if (!match) return null; + const requestedType = match[1].toLowerCase() === "pdf" + ? "pdf" + : match[1] === "이미지" + ? "image" + : "file"; + return { kind: "open", requestedType, alias: normalizedText(match[2]) }; +} + function readIntent(command) { const compact = compactText(command); if (compact.includes("시간표") || compact.includes("수업표")) return "timetable"; - if (compact.includes("수행평가") || compact.includes("시험") || compact.includes("테스트")) return "assessment"; - if (compact.includes("과제") || compact.includes("숙제") || compact.includes("제출물")) return "assignment"; + const asksAssessment = compact.includes("수행평가") || compact.includes("시험") || compact.includes("테스트"); + const asksAssignment = compact.includes("과제") || compact.includes("숙제") || compact.includes("제출물"); + if ((asksAssessment && asksAssignment) || compact.includes("통합요약") || compact.includes("학업요약")) return "study-summary"; + if (asksAssessment) return "assessment"; + if (asksAssignment) return "assignment"; if (["일정", "스케줄", "할일", "할것", "뭐있", "해야할"].some((word) => compact.includes(word))) return "schedule"; return null; } function readPeriod(command, intent) { const compact = compactText(command); + if (intent === "timetable" && (compact.includes("전체") || compact.includes("월금"))) { + return { kind: "full-timetable", label: "전체" }; + } + if (compact.includes("다음일정") || compact.includes("가장가까운일정") || compact.includes("제일빠른일정")) { + return { kind: "next", label: "다음" }; + } + if (compact.includes("이번달") || compact.includes("이달")) return { kind: "month", label: "이번 달" }; + if ((compact.includes("이번주") || compact.includes("금주")) && compact.includes("남은")) { + return { kind: "remaining-week", label: "이번 주 남은" }; + } if (compact.includes("다음주") || compact.includes("차주")) return { kind: "week", offset: 1, label: "다음 주" }; if (compact.includes("이번주") || compact.includes("금주") || compact.includes("주간")) return { kind: "week", offset: 0, label: "이번 주" }; if (compact.includes("모레")) return { kind: "day", offset: 2 }; @@ -68,7 +107,7 @@ function appearsAsSeparateName(text, name) { return text === name || text.startsWith(`${name} `) || text.endsWith(` ${name}`) || text.includes(` ${name} `); } -function resolveTargetMember(command, members) { +function resolveTargetMember(command, members, { defaultMember } = {}) { const text = normalizedText(command); const registered = members .filter((member) => member.status !== "left" && String(member.display_name || "").trim()) @@ -80,7 +119,7 @@ function resolveTargetMember(command, members) { } if (matches.length === 1) { const member = matches[0]; - return { member, query: text.slice(0, -member.display_name.length).trim() }; + return { member, query: text.slice(0, -member.display_name.length).trim(), implicit: false }; } const misplaced = registered.find((member) => appearsAsSeparateName(text, member.display_name)); @@ -92,6 +131,7 @@ function resolveTargetMember(command, members) { const tailCompact = compactText(tail); const looksLikeQueryWord = TRAILING_REQUEST_WORDS.has(tailCompact) || ["오늘", "금일", "내일", "명일", "모레", "이번주", "다음주", "금주", "차주", "주간"].includes(tailCompact); + if (looksLikeQueryWord && defaultMember) return { member: defaultMember, query: text, implicit: true }; if (!looksLikeQueryWord && tail.length >= 2 && tail.length <= 40) { throw new Error(`등록된 구성원 '${tail}'을(를) 찾을 수 없습니다. 이름을 확인해 주세요.`); } @@ -108,12 +148,17 @@ function noticeText(notices) { function helpText() { return [ "Quilo에서 이렇게 물어보세요.", - "일정·시간표 조회는 등록된 구성원 이름을 항상 맨 뒤에 붙여 주세요.", - "• 오늘 일정 홍길동 / 내일 일정 홍길동 / 모레 일정 홍길동", - "• 이번 주 일정 홍길동 / 다음 주 시험 홍길동", - "• 오늘 시간표 홍길동 / 숙제 홍길동", + "최초 1회 관리자에게 받은 코드로 ‘이름등록 ABCD-EFGH’를 입력해 주세요.", + "등록 후 본인 일정·시간표·자료 조회에는 이름을 붙이지 않아도 됩니다.", + "• 오늘 일정 / 내일 일정 / 다음 일정", + "• 이번 주 남은 일정 / 이번 달 일정", + "• 수행평가 과제 통합 요약 / 다음 주 시험", + "• 오늘 시간표 / 시간표 전체", + "• 자료 목록 / PDF 가정통신문 / 이미지 좌석표", + "다른 구성원의 일정은 기존처럼 질문 맨 뒤에 등록 이름을 붙여 조회할 수 있어요.", + "자료는 카카오 가입이 완료된 본인만 열 수 있어요.", "• 공지", - "• 가입 ABCD-EFGH", + "• 이름등록 ABCD-EFGH (기존 ‘가입 ABCD-EFGH’도 가능)", "• 알림 설정 / 알림 켜기 / 알림 끄기", ].join("\n"); } @@ -121,14 +166,120 @@ function helpText() { function eventKind(intent) { if (intent === "assessment") return { label: "수행평가", category: "assessment", empty: "등록된 수행평가가 없습니다." }; if (intent === "assignment") return { label: "과제", category: "assignment", empty: "등록된 과제가 없습니다." }; + if (intent === "study-summary") { + return { + label: "수행평가·과제 통합 요약", + categories: new Set(["assessment", "assignment"]), + empty: "등록된 수행평가나 과제가 없습니다.", + }; + } return { label: "일정", category: null, empty: "등록된 일정이 없습니다." }; } -async function answerReadQuery({ command, intent, member, store, now }) { +function monthBounds(date) { + const { year, month } = getSeoulParts(date); + const nextYear = month === 12 ? year + 1 : year; + const nextMonth = month === 12 ? 1 : month + 1; + const start = new Date(`${year}-${String(month).padStart(2, "0")}-01T00:00:00+09:00`); + const next = new Date(`${nextYear}-${String(nextMonth).padStart(2, "0")}-01T00:00:00+09:00`); + return { start, end: new Date(next.getTime() - 1), label: `${year}년 ${month}월` }; +} + +function availableFiles(items, memberId) { + return (Array.isArray(items) ? items : []) + .filter((file) => file?.member_id == null || file.member_id === memberId) + .filter((file) => String(file.status || "").toLowerCase() === "active") + .sort((a, b) => String(a.alias || a.filename || "").localeCompare(String(b.alias || b.filename || ""), "ko")); +} + +function isImageFile(file) { + return String(file.mime_type || "").toLowerCase().startsWith("image/"); +} + +function isPdfFile(file) { + return String(file.mime_type || "").toLowerCase() === "application/pdf" + || String(file.filename || "").toLowerCase().endsWith(".pdf"); +} + +function fileSizeLabel(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes < 0) return ""; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +function fileListText(files, displayName) { + if (!files.length) return `${displayName}님이 조회할 수 있는 자료가 없습니다.`; + const rows = files.map((file) => { + const type = isImageFile(file) ? "이미지" : isPdfFile(file) ? "PDF" : "파일"; + const scope = file.member_id == null ? "반 전체" : "개인"; + const detail = [file.description, file.filename, fileSizeLabel(file.size_bytes)].filter(Boolean).join(" · "); + return `• [${scope} ${type}] ${file.alias || file.filename}${detail ? `\n ${detail}` : ""}`; + }); + return `${displayName}님의 자료 목록\n\n${rows.join("\n")}`; +} + +async function answerFileQuery({ command, requester, store, makeFileUrl, quickReplies }) { + const replies = quickReplies || registeredQuickReplies(); + if (typeof store.listFiles !== "function") { + return simpleTextResponse("현재 자료 조회 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + const spec = readFileCommand(command); + if (!spec) return simpleTextResponse("‘자료 목록 이름’ 또는 ‘파일 별칭 이름’처럼 입력해 주세요.", replies); + + const files = availableFiles(await store.listFiles({ targetMemberId: requester.id }), requester.id); + if (spec.kind === "list") return simpleTextResponse(fileListText(files, requester.display_name), replies); + + const alias = normalizedText(spec.alias).toLowerCase(); + const matches = files.filter((file) => normalizedText(file.alias || file.filename).toLowerCase() === alias); + const typedMatches = matches.filter((file) => spec.requestedType === "file" + || (spec.requestedType === "pdf" && isPdfFile(file)) + || (spec.requestedType === "image" && isImageFile(file))); + if (typedMatches.length !== 1) return simpleTextResponse("요청한 자료를 찾을 수 없습니다. ‘자료 목록’에서 별칭을 확인해 주세요.", replies); + if (typeof makeFileUrl !== "function") { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + + const file = typedMatches[0]; + let url; + try { + url = await makeFileUrl(file); + } catch { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + const title = file.alias || file.filename || "Quilo 자료"; + if (isImageFile(file) && file.member_id == null) { + try { + return simpleImageResponse({ imageUrl: url, altText: `${title} 이미지` }, replies); + } catch { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + } + try { + return textCardResponse({ + title, + description: [file.description, file.filename, fileSizeLabel(file.size_bytes)].filter(Boolean).join("\n"), + url, + buttonLabel: isPdfFile(file) ? "PDF 열기" : "파일 열기", + }, replies); + } catch { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } +} + +async function answerReadQuery({ command, intent, member, store, now, quickReplies }) { const period = readPeriod(command, intent); - const replies = personalizedQuickReplies(member.display_name); + const replies = quickReplies || personalizedQuickReplies(member.display_name); if (intent === "timetable") { + if (period.kind === "full-timetable") { + const bundle = await getWeekTimetable(store, now); + return simpleTextResponse( + `${member.display_name}님의 월~금 전체 시간표\n\n${formatWeekTimetable(bundle)}`, + replies, + ); + } if (period.kind === "week") { const bundle = await getWeekTimetable(store, now, { weekOffset: period.offset }); return simpleTextResponse( @@ -153,6 +304,27 @@ async function answerReadQuery({ command, intent, member, store, now }) { } else if (period.kind === "week") { events = await getWeekEvents(store, now, { weekOffset: period.offset, targetMemberId: member.id }); rangeLabel = period.label; + } else if (period.kind === "remaining-week") { + events = (await getWeekEvents(store, now, { targetMemberId: member.id })) + .filter((event) => new Date(event.due_at).getTime() >= now.getTime()); + rangeLabel = period.label; + } else if (period.kind === "month") { + const bounds = monthBounds(now); + events = await store.listEvents({ + from: bounds.start.toISOString(), + to: bounds.end.toISOString(), + status: "scheduled", + targetMemberId: member.id, + }); + rangeLabel = bounds.label; + } else if (period.kind === "next") { + events = await store.listEvents({ + from: now.toISOString(), + status: "scheduled", + targetMemberId: member.id, + }); + events = events.slice(0, 1); + rangeLabel = period.label; } else { events = await getUpcomingEvents(store, now, period.days, { targetMemberId: member.id }); rangeLabel = `앞으로 ${period.days}일`; @@ -160,48 +332,91 @@ async function answerReadQuery({ command, intent, member, store, now }) { const kind = eventKind(intent); if (kind.category) events = events.filter((event) => event.category === kind.category); + if (kind.categories) events = events.filter((event) => kind.categories.has(event.category)); return simpleTextResponse( `${member.display_name}님의 ${rangeLabel} ${kind.label}\n\n${formatEventRows(events, now, kind.empty)}`, replies, ); } -export async function handleKakaoCommand({ payload, store, now = new Date() }) { +export async function handleKakaoCommand({ payload, store, now = new Date(), makeFileUrl }) { const command = extractCommand(payload); const normalized = compactText(command); const user = extractKakaoUser(payload); let targetDisplayName = ""; + let targetQuickReplies; + let requesterLoaded = false; + let requesterMember = null; + const getRequester = async () => { + if (!requesterLoaded) { + requesterLoaded = true; + requesterMember = user ? await store.findMemberByUserKey(user.id) : null; + } + return requesterMember; + }; try { - if (/^(가입|초대)/.test(command)) { + if (/^(가입|초대|이름등록)/.test(command)) { const code = extractInviteCode(payload, command); if (!user) return simpleTextResponse("카카오 사용자 식별값을 확인할 수 없어 가입할 수 없습니다."); - if (!code) return simpleTextResponse("관리자에게 받은 초대 코드로 ‘가입 ABCD-EFGH’처럼 입력해 주세요."); + if (!code) return simpleTextResponse("관리자에게 받은 코드로 ‘이름등록 ABCD-EFGH’처럼 입력해 주세요. 기존 ‘가입’ 명령도 사용할 수 있습니다."); const member = await store.claimInvite({ code, userKey: user.id, userKeyType: user.type }); const isFriend = String(payload.userRequest?.user?.properties?.isFriend ?? "").toLowerCase(); const friendGuide = isFriend === "false" ? "\n알림을 받으려면 이 카카오톡 채널을 친구 추가해 주세요." : ""; return simpleTextResponse( - `${member.display_name}님, 2학년 4반 가입이 완료되었습니다.\n알림 설정에서 수신 여부를 바꿀 수 있어요.${friendGuide}`, - personalizedQuickReplies(member.display_name), + `${member.display_name}님, 이름 등록과 2학년 4반 가입이 완료되었습니다.\n이제 ‘오늘 일정’처럼 이름 없이 물어볼 수 있어요. 알림 설정에서 수신 여부도 바꿀 수 있습니다.${friendGuide}`, + registeredQuickReplies(), ); } + if (looksLikeFileCommand(command)) { + const requester = await getRequester(); + const text = normalizedText(command); + const requesterName = normalizedText(requester?.display_name); + if (!requester || requester.status !== "active" || !requesterName) { + return simpleTextResponse("자료를 조회할 권한이 없습니다. 먼저 관리자에게 받은 코드로 이름등록을 완료해 주세요."); + } + const registered = (await store.listMembers()).filter((member) => member.status !== "left"); + const trailingMatches = registered.filter((member) => { + const name = normalizedText(member.display_name); + return name && (text === name || text.endsWith(` ${name}`)); + }); + if (trailingMatches.some((member) => member.id !== requester.id)) { + return simpleTextResponse("자료를 조회할 권한이 없습니다. 자료는 가입된 본인만 조회할 수 있습니다."); + } + const explicitSelf = trailingMatches.some((member) => member.id === requester.id); + targetDisplayName = requesterName; + targetQuickReplies = explicitSelf ? personalizedQuickReplies(requesterName) : registeredQuickReplies(); + const query = explicitSelf ? text.slice(0, -requesterName.length).trim() : text; + return await answerFileQuery({ command: query, requester, store, makeFileUrl, quickReplies: targetQuickReplies }); + } + const intent = readIntent(command); if (intent) { - const target = resolveTargetMember(command, await store.listMembers()); + const requester = await getRequester(); + const defaultMember = requester?.status === "active" ? requester : null; + const target = resolveTargetMember(command, await store.listMembers(), { defaultMember }); targetDisplayName = target.member.display_name; const targetIntent = readIntent(target.query); if (!targetIntent) throw new Error("조회할 일정 종류가 필요합니다. 예: '오늘 일정 홍길동'"); - return await answerReadQuery({ command: target.query, intent: targetIntent, member: target.member, store, now }); + targetQuickReplies = target.implicit ? registeredQuickReplies() : personalizedQuickReplies(target.member.display_name); + return await answerReadQuery({ + command: target.query, + intent: targetIntent, + member: target.member, + store, + now, + quickReplies: targetQuickReplies, + }); } - const member = user ? await store.findMemberByUserKey(user.id) : null; + const member = await getRequester(); const requiresMembership = normalized.includes("알림") || normalized.includes("공지"); if (requiresMembership && !member) { return simpleTextResponse("2학년 4반 구성원만 조회할 수 있습니다. 먼저 ‘가입 초대코드’를 입력해 주세요."); } - const memberReplies = member ? personalizedQuickReplies(member.display_name) : undefined; + const memberReplies = member ? registeredQuickReplies() : undefined; if (normalized.includes("알림")) { if (normalized.includes("아침알림끄기")) { await store.updateMember(member.id, { daily_digest_enabled: false }, member.id); @@ -235,7 +450,7 @@ export async function handleKakaoCommand({ payload, store, now = new Date() }) { const friendly = /초대 코드|이미 다른 구성원|이미 가입|학급 정원|찾을 수 없습니다|필요합니다|구성원 이름|등록된 구성원|동명이인|맨 뒤/.test(error.message) ? error.message : "잠시 후 다시 시도해 주세요."; - const replies = targetDisplayName ? personalizedQuickReplies(targetDisplayName) : undefined; + const replies = targetQuickReplies || (targetDisplayName ? personalizedQuickReplies(targetDisplayName) : undefined); return simpleTextResponse(`요청을 처리하지 못했습니다. ${friendly}`, replies); } } diff --git a/apps/classbot/server/services/commands.test.js b/apps/classbot/server/services/commands.test.js index 0d954a4..708558a 100644 --- a/apps/classbot/server/services/commands.test.js +++ b/apps/classbot/server/services/commands.test.js @@ -14,11 +14,12 @@ function fixture() { return store; } -async function ask(store, utterance) { +async function ask(store, utterance, options = {}) { return handleKakaoCommand({ store, now, - payload: { userRequest: { utterance, user: { id: "group-chat-anonymous" } } }, + makeFileUrl: options.makeFileUrl, + payload: { userRequest: { utterance, user: { id: options.userId || "group-chat-anonymous" } } }, }); } @@ -40,14 +41,261 @@ test("익명 그룹챗 개인 조회는 반 전체와 대상 개인 일정만 const replies = response.template.quickReplies; assert.deepEqual(replies.map((item) => item.messageText), [ "오늘 일정 홍길동", - "내일 일정 홍길동", - "이번 주 일정 홍길동", - "오늘 시간표 홍길동", - "과제 홍길동", + "다음 일정 홍길동", + "수행평가 과제 통합 요약 홍길동", + "시간표 전체 홍길동", + "자료 목록 홍길동", ]); assert.equal(replies.every((item) => item.label.endsWith("홍길동")), true); }); +function fileFixture() { + const store = fixture(); + store.members[0].status = "active"; + store.members[0].kakao_user_key = "joined-hong"; + store.members[0].kakao_user_key_type = "botUserKey"; + store.members[1].status = "active"; + store.members[1].kakao_user_key = "joined-kim"; + store.members[1].kakao_user_key_type = "botUserKey"; + store.files = [ + { id: "class-image", alias: "좌석표", filename: "seats.png", description: "반 좌석 배치", mime_type: "image/png", size_bytes: 2048, member_id: null, status: "active" }, + { id: "class-pdf", alias: "가정통신문", filename: "notice.pdf", description: "7월 안내", mime_type: "application/pdf", size_bytes: 4096, member_id: null, status: "active" }, + { id: "hong-image", alias: "개인피드백", filename: "feedback.jpg", description: "개인 평가", mime_type: "image/jpeg", size_bytes: 8192, member_id: store.members[0].id, status: "active" }, + { id: "kim-pdf", alias: "김학생자료", filename: "kim.pdf", description: "타인 개인 자료", mime_type: "application/pdf", size_bytes: 1024, member_id: store.members[1].id, status: "active" }, + { id: "archived-pdf", alias: "지난자료", filename: "old.pdf", description: "보관됨", mime_type: "application/pdf", size_bytes: 1024, member_id: null, status: "archived" }, + ]; + store.listFiles = async ({ targetMemberId }) => store.files.filter((file) => file.member_id == null || file.member_id === targetMemberId); + return store; +} + +test("파일 목록은 가입된 본인이 자기 이름을 붙였을 때만 반 전체와 본인 개인자료를 보여준다", async () => { + const store = fileFixture(); + const response = await ask(store, "자료 목록 홍길동", { userId: "joined-hong" }); + assert.match(text(response), /좌석표/); + assert.match(text(response), /가정통신문/); + assert.match(text(response), /개인피드백/); + assert.doesNotMatch(text(response), /김학생자료|지난자료/); + assert.equal(response.template.quickReplies.length, 5); + assert.equal(response.template.quickReplies.at(-1).messageText, "자료 목록 홍길동"); +}); + +test("가입된 본인은 파일 명령에서 이름 suffix를 생략하고 이름 없는 Quick Reply를 받는다", async () => { + const store = fileFixture(); + const makeFileUrl = async (file) => `https://files.example.test/${file.id}`; + const list = await ask(store, "자료 목록", { userId: "joined-hong" }); + assert.match(text(list), /좌석표|개인피드백/); + assert.deepEqual(list.template.quickReplies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); + + const privateImage = await ask(store, "이미지 개인피드백", { userId: "joined-hong", makeFileUrl }); + assert.equal(privateImage.template.outputs[0].textCard.buttons[0].webLinkUrl, "https://files.example.test/hong-image"); + + const denied = await ask(store, "자료 목록 김학생", { userId: "joined-hong" }); + assert.match(text(denied), /권한이 없습니다/); + assert.doesNotMatch(text(denied), /김학생자료/); +}); + +test("미가입 사용자와 다른 이름을 쓴 가입자는 파일 존재 여부를 알 수 없다", async () => { + const store = fileFixture(); + let calls = 0; + const original = store.listFiles; + store.listFiles = async (options) => { calls += 1; return original(options); }; + + const anonymous = await ask(store, "파일 개인피드백 홍길동", { userId: "unknown-user" }); + const wrongTarget = await ask(store, "파일 개인피드백 홍길동", { userId: "joined-kim" }); + assert.match(text(anonymous), /권한이 없습니다/); + assert.match(text(wrongTarget), /권한이 없습니다/); + assert.doesNotMatch(text(wrongTarget), /개인피드백|찾을 수 없습니다/); + assert.equal(calls, 0); +}); + +test("반 전체 이미지는 인라인 이미지, 개인 이미지와 PDF는 열기 버튼 카드로 반환한다", async () => { + const store = fileFixture(); + const makeFileUrl = async (file) => `https://files.example.test/${file.id}`; + + const classImage = await ask(store, "이미지 좌석표 홍길동", { userId: "joined-hong", makeFileUrl }); + assert.equal(classImage.template.outputs.length, 1); + assert.equal(classImage.template.outputs[0].simpleImage.imageUrl, "https://files.example.test/class-image"); + + const privateImage = await ask(store, "이미지 개인피드백 홍길동", { userId: "joined-hong", makeFileUrl }); + assert.equal(privateImage.template.outputs[0].simpleImage, undefined); + assert.equal(privateImage.template.outputs[0].textCard.buttons[0].label, "파일 열기"); + + const pdf = await ask(store, "PDF 가정통신문 홍길동", { userId: "joined-hong", makeFileUrl }); + assert.equal(pdf.template.outputs[0].textCard.buttons[0].label, "PDF 열기"); + assert.equal(pdf.template.outputs[0].textCard.buttons[0].webLinkUrl, "https://files.example.test/class-pdf"); +}); + +test("파일 형식·별칭이 다르거나 URL 발급기가 없으면 안전한 안내만 반환한다", async () => { + const store = fileFixture(); + const wrongType = await ask(store, "PDF 좌석표 홍길동", { userId: "joined-hong", makeFileUrl: async () => "https://files.example.test/x" }); + assert.match(text(wrongType), /찾을 수 없습니다/); + + const unavailable = await ask(store, "파일 가정통신문 홍길동", { userId: "joined-hong" }); + assert.match(text(unavailable), /자료 열기 기능을 사용할 수 없습니다/); +}); + +test("다음 일정은 현재 이후 가장 빠른 반 전체 또는 개인 일정 한 건만 보여준다", async () => { + const store = fixture(); + await store.createEvent({ title: "이미 지난 반 일정", due_at: "2026-07-15T10:00:00+09:00" }); + await store.createEvent({ member_id: store.members[1].id, title: "다른 학생 다음 일정", due_at: "2026-07-15T13:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, title: "가장 가까운 개인 일정", due_at: "2026-07-15T13:30:00+09:00" }); + await store.createEvent({ title: "그다음 반 일정", due_at: "2026-07-15T14:00:00+09:00" }); + + const response = await ask(store, "다음 일정 홍길동"); + assert.match(text(response), /홍길동님의 다음 일정/); + assert.match(text(response), /가장 가까운 개인 일정/); + assert.doesNotMatch(text(response), /이미 지난 반 일정|다른 학생 다음 일정|그다음 반 일정/); +}); + +test("이번 달 일정은 서울 달력 경계 안의 반 전체와 본인 일정만 보여준다", async () => { + const store = fixture(); + await store.createEvent({ title: "7월 반 일정", due_at: "2026-07-01T00:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, title: "7월 개인 일정", due_at: "2026-07-31T23:59:00+09:00" }); + await store.createEvent({ member_id: store.members[1].id, title: "7월 타인 일정", due_at: "2026-07-20T12:00:00+09:00" }); + await store.createEvent({ title: "8월 일정", due_at: "2026-08-01T00:00:00+09:00" }); + + const response = await ask(store, "이번 달 일정 홍길동"); + assert.match(text(response), /홍길동님의 2026년 7월 일정/); + assert.match(text(response), /7월 반 일정/); + assert.match(text(response), /7월 개인 일정/); + assert.doesNotMatch(text(response), /7월 타인 일정|8월 일정/); +}); + +test("수행평가·과제 통합 요약은 두 유형만 함께 보여준다", async () => { + const store = fixture(); + await store.createEvent({ category: "assessment", title: "물리 수행평가", due_at: "2026-07-16T13:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "영어 과제", due_at: "2026-07-17T13:00:00+09:00" }); + await store.createEvent({ category: "class", title: "진로 수업", due_at: "2026-07-18T13:00:00+09:00" }); + + const response = await ask(store, "수행평가 과제 통합 요약 홍길동"); + assert.match(text(response), /수행평가·과제 통합 요약/); + assert.match(text(response), /물리 수행평가/); + assert.match(text(response), /영어 과제/); + assert.doesNotMatch(text(response), /진로 수업/); +}); + +test("시간표 전체는 월요일부터 금요일까지 등록 원본을 보여준다", async () => { + const store = fixture(); + const response = await ask(store, "시간표 전체 홍길동"); + assert.match(text(response), /홍길동님의 월~금 전체 시간표/); + assert.match(text(response), /7월 13일 월요일/); + assert.match(text(response), /7월 17일 금요일/); + assert.match(text(response), /1교시 물리학/); +}); + +test("이번 주 남은 일정은 현재 시각 이후 일정만 유지한다", async () => { + const store = fixture(); + await store.createEvent({ title: "오늘 지난 일정", due_at: "2026-07-15T10:00:00+09:00" }); + await store.createEvent({ title: "오늘 남은 일정", due_at: "2026-07-15T13:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, title: "금요일 개인 일정", due_at: "2026-07-17T13:00:00+09:00" }); + await store.createEvent({ title: "다음 주 일정", due_at: "2026-07-20T13:00:00+09:00" }); + + const response = await ask(store, "이번 주 남은 일정 홍길동"); + assert.match(text(response), /홍길동님의 이번 주 남은 일정/); + assert.match(text(response), /오늘 남은 일정/); + assert.match(text(response), /금요일 개인 일정/); + assert.doesNotMatch(text(response), /오늘 지난 일정|다음 주 일정/); +}); + +test("도움말은 최초 이름등록, 이름 없는 본인 조회와 명시적 타인 일정 조회를 안내한다", async () => { + const store = fixture(); + const help = await ask(store, "도움말"); + assert.match(text(help), /이름등록 ABCD-EFGH/); + assert.match(text(help), /본인 일정.*이름을 붙이지 않아도/); + assert.match(text(help), /다음 일정/); + assert.match(text(help), /이번 달 일정/); + assert.match(text(help), /수행평가 과제 통합 요약/); + assert.match(text(help), /시간표 전체/); + assert.match(text(help), /자료 목록/); + assert.match(text(help), /다른 구성원의 일정.*등록 이름/); + + const response = await ask(store, "이번 달 일정 홍길동"); + assert.equal(response.template.quickReplies.length, 5); + assert.equal(response.template.quickReplies.every((item) => item.messageText.endsWith("홍길동")), true); +}); + +test("이름등록은 기존 가입과 같은 초대코드 매핑을 사용하고 이후 이름 없이 본인 일정을 조회한다", async () => { + const store = fixture(); + const member = store.members[0]; + let claimInput; + store.claimInvite = async (input) => { + claimInput = input; + member.status = "active"; + member.kakao_user_key = input.userKey; + member.kakao_user_key_type = input.userKeyType; + return { ...member }; + }; + await store.createEvent({ title: "반 전체 일정", due_at: "2026-07-15T16:00:00" }); + await store.createEvent({ member_id: member.id, title: "홍길동 개인 일정", due_at: "2026-07-15T17:00:00" }); + + const registration = await ask(store, "이름등록 ABCD-EFGH", { userId: "new-hong-user" }); + assert.match(text(registration), /이름 등록.*가입이 완료/); + assert.equal(claimInput.code, "ABCD-EFGH"); + assert.equal(claimInput.userKey, "new-hong-user"); + assert.equal(registration.template.quickReplies[0].messageText, "오늘 일정"); + + const response = await ask(store, "오늘 일정", { userId: "new-hong-user" }); + assert.match(text(response), /홍길동님의.*오늘|홍길동님의 7월 15일/); + assert.match(text(response), /반 전체 일정/); + assert.match(text(response), /홍길동 개인 일정/); + assert.equal(response.template.quickReplies.every((item) => !item.messageText.includes("홍길동")), true); +}); + +test("active 요청자는 모든 본인 조회 명령을 이름 없이 사용하고 명시한 다른 이름 일정도 조회한다", async () => { + const store = fileFixture(); + await store.createEvent({ title: "오늘 반 일정", due_at: "2026-07-15T16:00:00" }); + await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "홍길동 수행평가", due_at: "2026-07-16T16:00:00" }); + await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "홍길동 과제", due_at: "2026-07-17T16:00:00" }); + await store.createEvent({ member_id: store.members[1].id, title: "김학생 개인 일정", due_at: "2026-07-15T18:00:00" }); + + for (const utterance of [ + "오늘 일정", + "내일 일정", + "다음 일정", + "이번 달 일정", + "이번 주 남은 일정", + "수행평가 과제 통합 요약", + "오늘 시간표", + "시간표 전체", + ]) { + const response = await ask(store, utterance, { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의/); + assert.deepEqual(response.template.quickReplies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); + } + + const other = await ask(store, "오늘 일정 김학생", { userId: "joined-hong" }); + assert.match(text(other), /김학생님의/); + assert.match(text(other), /김학생 개인 일정/); + assert.equal(other.template.quickReplies[0].messageText, "오늘 일정 김학생"); +}); + +test("등록된 요청자의 알림·공지 응답 Quick Reply에도 이름 suffix를 다시 붙이지 않는다", async () => { + const store = fileFixture(); + const notifications = await ask(store, "알림 설정", { userId: "joined-hong" }); + assert.deepEqual(notifications.template.quickReplies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); + + const notices = await ask(store, "공지", { userId: "joined-hong" }); + assert.equal(notices.template.quickReplies.every((item) => !item.messageText.includes("홍길동")), true); +}); + test("오늘·내일·모레·이번 주·다음 주와 시험·숙제 변형을 대상 범위로 해석한다", async () => { const store = fixture(); await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "오늘 시험", due_at: "2026-07-15T16:00:00" }); diff --git a/apps/classbot/server/services/file-tokens.js b/apps/classbot/server/services/file-tokens.js new file mode 100644 index 0000000..8cac1b8 --- /dev/null +++ b/apps/classbot/server/services/file-tokens.js @@ -0,0 +1,41 @@ +import crypto from "node:crypto"; + +const DEFAULT_TTL_SECONDS = 15 * 60; + +function signature(payload, secret) { + return crypto.createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function createFileToken(fileId, secret, options = {}) { + const id = String(fileId || "").trim(); + if (!id) throw new Error("파일 식별값이 필요합니다."); + const ttlSeconds = Number(options.ttlSeconds ?? DEFAULT_TTL_SECONDS); + if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 3600) { + throw new Error("파일 링크 유효시간이 올바르지 않습니다."); + } + const now = options.now instanceof Date ? options.now : new Date(); + const payload = Buffer.from(JSON.stringify({ id, exp: Math.floor(now.getTime() / 1000) + ttlSeconds })).toString("base64url"); + return `${payload}.${signature(payload, secret)}`; +} + +export function verifyFileToken(token, secret, options = {}) { + const [payload, supplied, extra] = String(token || "").split("."); + if (!payload || !supplied || extra) throw new Error("올바르지 않은 파일 링크입니다."); + const expected = signature(payload, secret); + const suppliedBuffer = Buffer.from(supplied); + const expectedBuffer = Buffer.from(expected); + if (suppliedBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(suppliedBuffer, expectedBuffer)) { + throw new Error("올바르지 않은 파일 링크입니다."); + } + let decoded; + try { + decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw new Error("올바르지 않은 파일 링크입니다."); + } + const now = options.now instanceof Date ? options.now : new Date(); + if (!decoded?.id || !Number.isInteger(decoded.exp) || decoded.exp <= Math.floor(now.getTime() / 1000)) { + throw new Error("파일 링크가 만료되었습니다. 챗봇에서 다시 요청해 주세요."); + } + return { fileId: String(decoded.id), expiresAt: new Date(decoded.exp * 1000) }; +} diff --git a/apps/classbot/server/services/file-tokens.test.js b/apps/classbot/server/services/file-tokens.test.js new file mode 100644 index 0000000..30ad9e7 --- /dev/null +++ b/apps/classbot/server/services/file-tokens.test.js @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createFileToken, verifyFileToken } from "./file-tokens.js"; + +const secret = "test-secret-long-enough-for-file-token"; +const now = new Date("2026-07-16T00:00:00.000Z"); + +test("파일 토큰은 파일 ID를 숨기고 만료 전 검증된다", () => { + const token = createFileToken("private-file-id", secret, { now, ttlSeconds: 60 }); + assert.equal(token.includes("private-file-id"), false); + const result = verifyFileToken(token, secret, { now: new Date(now.getTime() + 59_000) }); + assert.equal(result.fileId, "private-file-id"); +}); + +test("변조되거나 만료된 파일 토큰은 거부한다", () => { + const token = createFileToken("file-id", secret, { now, ttlSeconds: 60 }); + assert.throws(() => verifyFileToken(`${token}x`, secret, { now }), /올바르지 않은/); + assert.throws(() => verifyFileToken(token, secret, { now: new Date(now.getTime() + 61_000) }), /만료/); +}); diff --git a/apps/classbot/server/services/kakao.js b/apps/classbot/server/services/kakao.js index 2c6f002..f6a88f2 100644 --- a/apps/classbot/server/services/kakao.js +++ b/apps/classbot/server/services/kakao.js @@ -31,25 +31,71 @@ function normalizedEventData(data) { return Object.fromEntries(entries); } -export function simpleTextResponse(text, quickReplies = QUICK_REPLIES) { +export function templateResponse(outputs, quickReplies = QUICK_REPLIES) { + if (!Array.isArray(outputs) || !outputs.length) throw new Error("Kakao 응답에는 출력이 하나 이상 필요합니다."); return { version: "2.0", template: { - outputs: [{ simpleText: { text: String(text).slice(0, 1000) } }], + outputs: outputs.slice(0, 3), quickReplies, }, }; } +export function simpleTextResponse(text, quickReplies = QUICK_REPLIES) { + return templateResponse([{ simpleText: { text: String(text).slice(0, 1000) } }], quickReplies); +} + +function safeWebUrl(value) { + const url = new URL(String(value || "")); + if (!new Set(["https:", "http:"]).has(url.protocol) || url.username || url.password) { + throw new Error("자료 URL은 인증정보가 없는 HTTP 또는 HTTPS 주소여야 합니다."); + } + return url.href; +} + +export function simpleImageResponse({ imageUrl, altText }, quickReplies = QUICK_REPLIES) { + return templateResponse([{ + simpleImage: { + imageUrl: safeWebUrl(imageUrl), + altText: String(altText || "Quilo 자료 이미지").slice(0, 1000), + }, + }], quickReplies); +} + +export function textCardResponse({ title, description, url, buttonLabel = "열기" }, quickReplies = QUICK_REPLIES) { + return templateResponse([{ + textCard: { + title: String(title || "Quilo 자료").slice(0, 50), + description: String(description || "").slice(0, 230), + buttons: [{ + action: "webLink", + label: String(buttonLabel || "열기").slice(0, 14), + webLinkUrl: safeWebUrl(url), + }], + }, + }], quickReplies); +} + export function personalizedQuickReplies(displayName) { const name = String(displayName || "").trim(); if (!name) return QUICK_REPLIES; return [ `오늘 일정 ${name}`, - `내일 일정 ${name}`, - `이번 주 일정 ${name}`, - `오늘 시간표 ${name}`, - `과제 ${name}`, + `다음 일정 ${name}`, + `수행평가 과제 통합 요약 ${name}`, + `시간표 전체 ${name}`, + `자료 목록 ${name}`, + ].map((messageText) => ({ label: messageText, action: "message", messageText })); +} + +export function registeredQuickReplies() { + return [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", ].map((messageText) => ({ label: messageText, action: "message", messageText })); } diff --git a/apps/classbot/server/services/kakao.test.js b/apps/classbot/server/services/kakao.test.js index 1664e19..2eea099 100644 --- a/apps/classbot/server/services/kakao.test.js +++ b/apps/classbot/server/services/kakao.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { KakaoEventClient } from "./kakao.js"; +import { KakaoEventClient, personalizedQuickReplies, registeredQuickReplies, templateResponse } from "./kakao.js"; const config = { enabled: true, @@ -51,3 +51,23 @@ test("Event API 사용자 타입, 인원수, data 타입을 발송 전에 검증 /모두 문자열/, ); }); + +test("스킬 응답 output은 최대 3개로 제한하고 개인화 Quick Reply는 자료 목록을 포함해 5개다", () => { + const outputs = Array.from({ length: 5 }, (_, index) => ({ simpleText: { text: String(index) } })); + const response = templateResponse(outputs, personalizedQuickReplies("홍길동")); + assert.equal(response.template.outputs.length, 3); + assert.equal(response.template.quickReplies.length, 5); + assert.equal(response.template.quickReplies.at(-1).messageText, "자료 목록 홍길동"); + assert.equal(response.template.quickReplies.every((item) => item.messageText.endsWith("홍길동")), true); +}); + +test("이름등록이 끝난 요청자용 Quick Reply는 이름 suffix 없이 5개다", () => { + const replies = registeredQuickReplies(); + assert.deepEqual(replies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); +}); diff --git a/apps/classbot/server/store/memory-store.js b/apps/classbot/server/store/memory-store.js index 4d5e7b6..6ea1600 100644 --- a/apps/classbot/server/store/memory-store.js +++ b/apps/classbot/server/store/memory-store.js @@ -23,6 +23,11 @@ function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; } +function fileMetadata(file) { + const { body: _body, ...metadata } = file; + return clone(metadata); +} + function seedTimetable(classId) { const subjects = { 1: [ @@ -195,6 +200,8 @@ export class MemoryStore { ]; this.notifications = []; this.auditLogs = []; + this.files = []; + this.fileBodies = new Map(); } async healthCheck() { @@ -502,6 +509,88 @@ export class MemoryStore { return { notice: await this.updateNotice(noticeId, { status: "published" }, actor), newlyPublished: true }; } + async listFiles({ targetMemberId, all = false, status = "active" } = {}) { + return this.files + .filter((file) => (!status || file.status === status)) + .filter((file) => all || file.member_id == null || (targetMemberId && file.member_id === targetMemberId)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .map(fileMetadata); + } + + async getFile(fileId) { + const file = this.files.find((item) => item.id === fileId); + return file ? fileMetadata(file) : null; + } + + async createFile(input, body, actor = "admin") { + if (!Buffer.isBuffer(body) || body.length < 1 || body.length > 20 * 1024 * 1024) { + throw new Error("파일은 20MB 이하여야 합니다."); + } + if (this.files.filter((file) => file.status === "active").length >= 100) { + throw new Error("자료실에는 최대 100개의 파일만 보관할 수 있습니다."); + } + const storedBytes = [...this.fileBodies.values()].reduce((sum, value) => sum + value.length, 0); + if (storedBytes + body.length > 100 * 1024 * 1024) { + throw new Error("개발용 자료실의 총 저장 용량 100MB를 초과할 수 없습니다."); + } + const alias = String(input.alias || "").trim(); + const duplicate = this.files.find((file) => ( + file.status === "active" + && file.member_id === (input.member_id || null) + && file.alias.toLocaleLowerCase("ko") === alias.toLocaleLowerCase("ko") + )); + if (duplicate) throw new Error("같은 대상에 동일한 자료 별칭을 두 번 등록할 수 없습니다."); + const now = nowIso(); + const file = { + id: id("file"), + class_id: this.classroom.id, + member_id: input.member_id || null, + alias, + filename: String(input.filename || "").trim(), + description: String(input.description || "").trim(), + mime_type: input.mime_type, + size_bytes: body.length, + bucket: "memory", + object_path: id("object"), + status: "active", + created_by: actor, + created_at: now, + updated_at: now, + }; + this.files.push(file); + this.fileBodies.set(file.id, Buffer.from(body)); + await this.appendAudit({ actor, action: "file.create", entityType: "file", entityId: file.id, after: file }); + return fileMetadata(file); + } + + async updateFile(fileId, patch, actor = "admin") { + const file = this.files.find((item) => item.id === fileId); + if (!file) throw new Error("파일을 찾을 수 없습니다."); + const before = fileMetadata(file); + for (const key of ["alias", "description", "member_id", "status"]) { + if (patch[key] !== undefined) file[key] = patch[key]; + } + file.updated_at = nowIso(); + await this.appendAudit({ actor, action: "file.update", entityType: "file", entityId: file.id, before, after: file }); + return fileMetadata(file); + } + + async deleteFile(fileId, actor = "admin") { + const index = this.files.findIndex((item) => item.id === fileId); + if (index < 0) throw new Error("파일을 찾을 수 없습니다."); + const [file] = this.files.splice(index, 1); + this.fileBodies.delete(file.id); + await this.appendAudit({ actor, action: "file.delete", entityType: "file", entityId: file.id, before: file }); + return fileMetadata(file); + } + + async downloadFile(fileId) { + const file = this.files.find((item) => item.id === fileId && item.status === "active"); + const body = this.fileBodies.get(fileId); + if (!file || !body) throw new Error("파일을 찾을 수 없습니다."); + return Buffer.from(body); + } + async listNotifications({ limit = 50, status } = {}) { return clone(this.notifications) .filter((item) => !status || item.status === status) diff --git a/apps/classbot/server/store/schema-security.test.js b/apps/classbot/server/store/schema-security.test.js index 2eaa644..e4437ff 100644 --- a/apps/classbot/server/store/schema-security.test.js +++ b/apps/classbot/server/store/schema-security.test.js @@ -34,13 +34,23 @@ test("스키마 버전 health RPC와 전체 RLS가 운영 준비 상태를 검 assert.match(schema, /create table if not exists public\.classbot_schema_meta/); assert.match(schema, /create or replace function public\.classbot_health_check\(\)/); assert.match(schema, /grant execute on function public\.classbot_health_check\(\) to service_role/); - for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "events", "notices", "notifications", "audit_logs"]) { + for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "events", "notices", "files", "notifications", "audit_logs"]) { assert.match(schema, new RegExp(`alter table public\\.classbot_${table} enable row level security`)); } assert.match(storeSource, /\.rpc\("classbot_health_check"\)/); assert.match(storeSource, /result\.error\.code === "23505"/); }); +test("자료실은 비공개 서버 경계와 대상별 별칭 중복 방지를 스키마에 둔다", () => { + assert.match(schema, /create table if not exists public\.classbot_files/); + assert.match(schema, /mime_type in \('application\/pdf', 'image\/jpeg', 'image\/png', 'image\/webp', 'image\/gif'\)/); + assert.match(schema, /size_bytes between 1 and 20971520/); + assert.match(schema, /classbot_files_class_alias_idx/); + assert.match(schema, /classbot_files_member_alias_idx/); + assert.match(storeSource, /storage\.createBucket\(FILE_BUCKET/); + assert.match(storeSource, /public: false/); +}); + test("개인 일정은 nullable member FK와 대상별 반 전체 포함 조회를 사용한다", () => { assert.match(schema, /classbot_events[\s\S]*member_id uuid references public\.classbot_members\(id\) on delete cascade/); assert.match(schema, /classbot_events_member_due_idx/); diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index fdfd8ad..e69932f 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { createClient } from "@supabase/supabase-js"; import { hashInviteCode } from "../security.js"; import { parseKoreaDateTime } from "../time.js"; @@ -27,6 +28,16 @@ function fetchWithTimeout(url, options = {}) { return fetch(url, { ...options, signal }); } +const FILE_BUCKET = "classbot-files"; +const FILE_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"]; +const FILE_EXTENSIONS = new Map([ + ["application/pdf", "pdf"], + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], + ["image/gif", "gif"], +]); + export class SupabaseStore { constructor(config) { this.config = config; @@ -64,7 +75,7 @@ export class SupabaseStore { async healthCheck() { await this.ensureClassroom(); const version = unwrap(await this.client.rpc("classbot_health_check"), "학급 저장소 상태 확인 실패"); - if (Number(version) !== 1) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); + if (Number(version) !== 2) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); return { ok: true, storage: "supabase" }; } @@ -458,6 +469,128 @@ export class SupabaseStore { return { notice: current, newlyPublished: false }; } + async ensureFileBucket() { + const existing = await this.client.storage.getBucket(FILE_BUCKET); + if (existing.data) return; + const created = await this.client.storage.createBucket(FILE_BUCKET, { + public: false, + fileSizeLimit: 20 * 1024 * 1024, + allowedMimeTypes: FILE_MIME_TYPES, + }); + if (created.error && !/already exists|duplicate/i.test(created.error.message || "")) { + throw new Error(`자료실 저장소 준비 실패: ${created.error.message}`); + } + } + + async listFiles({ targetMemberId, all = false, status = "active" } = {}) { + const classroom = await this.ensureClassroom(); + let query = this.client + .from("classbot_files") + .select("*") + .eq("class_id", classroom.id) + .order("created_at", { ascending: false }); + if (status) query = query.eq("status", status); + if (!all) { + query = targetMemberId + ? query.or(`member_id.is.null,member_id.eq.${targetMemberId}`) + : query.is("member_id", null); + } + return unwrap(await query, "자료실 조회 실패"); + } + + async getFile(fileId) { + const classroom = await this.ensureClassroom(); + return unwrap( + await this.client.from("classbot_files").select("*").eq("class_id", classroom.id).eq("id", fileId).maybeSingle(), + "파일 조회 실패", + ); + } + + async createFile(input, body, actor = "admin") { + const classroom = await this.ensureClassroom(); + if (!Buffer.isBuffer(body) || body.length < 1 || body.length > 20 * 1024 * 1024) { + throw new Error("파일은 20MB 이하여야 합니다."); + } + const countResult = await this.client + .from("classbot_files") + .select("id", { count: "exact", head: true }) + .eq("class_id", classroom.id) + .eq("status", "active"); + if (countResult.error) throw new Error(`자료실 용량 확인 실패: ${countResult.error.message}`); + if ((countResult.count || 0) >= 100) throw new Error("자료실에는 최대 100개의 파일만 보관할 수 있습니다."); + + await this.ensureFileBucket(); + const extension = FILE_EXTENSIONS.get(input.mime_type); + if (!extension) throw new Error("PDF 또는 지원되는 이미지 파일만 올릴 수 있습니다."); + const fileId = crypto.randomUUID(); + const objectPath = `${classroom.id}/${fileId}/${crypto.randomUUID()}.${extension}`; + const upload = await this.client.storage.from(FILE_BUCKET).upload(objectPath, body, { + contentType: input.mime_type, + cacheControl: "0", + upsert: false, + }); + if (upload.error) throw new Error(`파일 업로드 실패: ${upload.error.message}`); + + const result = await this.client.from("classbot_files").insert({ + id: fileId, + class_id: classroom.id, + member_id: input.member_id || null, + alias: input.alias, + filename: input.filename, + description: input.description || "", + mime_type: input.mime_type, + size_bytes: body.length, + bucket: FILE_BUCKET, + object_path: objectPath, + status: "active", + created_by: actor, + }).select("*").single(); + if (result.error) { + await this.client.storage.from(FILE_BUCKET).remove([objectPath]); + if (result.error.code === "23505") throw new Error("같은 대상에 동일한 자료 별칭을 두 번 등록할 수 없습니다."); + throw new Error(`파일 정보 저장 실패: ${result.error.message}`); + } + await this.appendAudit({ actor, action: "file.create", entityType: "file", entityId: result.data.id, after: result.data }); + return result.data; + } + + async updateFile(fileId, patch, actor = "admin") { + const classroom = await this.ensureClassroom(); + const before = await this.getFile(fileId); + if (!before) throw new Error("파일을 찾을 수 없습니다."); + const allowed = Object.fromEntries( + Object.entries(patch).filter(([key]) => ["alias", "description", "member_id", "status"].includes(key)), + ); + const updated = unwrap( + await this.client.from("classbot_files").update(allowed).eq("class_id", classroom.id).eq("id", fileId).select("*").single(), + "파일 정보 저장 실패", + ); + await this.appendAudit({ actor, action: "file.update", entityType: "file", entityId: fileId, before, after: updated }); + return updated; + } + + async deleteFile(fileId, actor = "admin") { + const classroom = await this.ensureClassroom(); + const file = await this.getFile(fileId); + if (!file) throw new Error("파일을 찾을 수 없습니다."); + const removedObject = await this.client.storage.from(file.bucket).remove([file.object_path]); + if (removedObject.error) throw new Error(`파일 삭제 실패: ${removedObject.error.message}`); + unwrap( + await this.client.from("classbot_files").delete().eq("class_id", classroom.id).eq("id", fileId), + "파일 정보 삭제 실패", + ); + await this.appendAudit({ actor, action: "file.delete", entityType: "file", entityId: fileId, before: file }); + return file; + } + + async downloadFile(fileId) { + const file = await this.getFile(fileId); + if (!file || file.status !== "active") throw new Error("파일을 찾을 수 없습니다."); + const downloaded = await this.client.storage.from(file.bucket).download(file.object_path); + if (downloaded.error) throw new Error(`파일 다운로드 실패: ${downloaded.error.message}`); + return Buffer.from(await downloaded.data.arrayBuffer()); + } + async listNotifications({ limit = 50, status } = {}) { const classroom = await this.ensureClassroom(); let query = this.client diff --git a/apps/classbot/src/App.jsx b/apps/classbot/src/App.jsx index 2d33d2f..ce3c83b 100644 --- a/apps/classbot/src/App.jsx +++ b/apps/classbot/src/App.jsx @@ -7,6 +7,7 @@ import TodayPage from "./pages/TodayPage.jsx"; import EventsPage from "./pages/EventsPage.jsx"; import TimetablePage from "./pages/TimetablePage.jsx"; import NoticesPage from "./pages/NoticesPage.jsx"; +import FilesPage from "./pages/FilesPage.jsx"; import MembersPage from "./pages/MembersPage.jsx"; import NotificationsPage from "./pages/NotificationsPage.jsx"; import SettingsPage from "./pages/SettingsPage.jsx"; @@ -14,6 +15,18 @@ import SettingsPage from "./pages/SettingsPage.jsx"; const actions = { today: "일정 추가", events: "일정 추가", notices: "공지 추가" }; const embeddedInQuilo = !import.meta.env.DEV && import.meta.env.BASE_URL.startsWith("/schedule/"); +async function copyText(value) { + if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(value); + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + function Login({ onLogin, busy, error }) { const [password, setPassword] = useState(""); if (embeddedInQuilo) { @@ -33,6 +46,7 @@ export default function App() { const [overview, setOverview] = useState(null); const [active, setActive] = useState("today"); const [drawer, setDrawer] = useState({ type: null, item: null, open: false }); + const [fileLibrary, setFileLibrary] = useState({ items: [], loading: false, loaded: false, error: "" }); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [toast, setToast] = useState(""); @@ -59,6 +73,15 @@ export default function App() { const patchCollection = (collection, item) => setOverview((current) => ({ ...current, [collection]: current[collection].map((existing) => existing.id === item.id ? item : existing) })); const addCollection = (collection, item) => setOverview((current) => ({ ...current, [collection]: [item, ...current[collection]] })); + const loadFiles = async () => { + setFileLibrary((current) => ({ ...current, loading: true, error: "" })); + try { + const result = await api.files(); + setFileLibrary({ items: result.items || result.files || [], loading: false, loaded: true, error: "" }); + } catch (err) { + setFileLibrary((current) => ({ ...current, loading: false, loaded: false, error: err.message || "자료 목록 요청에 실패했습니다." })); + } + }; const login = async (password) => { setBusy(true); setError(""); @@ -81,7 +104,10 @@ export default function App() { const deleteEvent = async () => { if (busy || !drawer.item || !window.confirm("이 일정을 삭제할까요?")) return; setBusy(true); try { await api.deleteEvent(drawer.item.id); setOverview((current) => ({ ...current, events: current.events.filter((item) => item.id !== drawer.item.id) })); closeDrawer(); flash("일정을 삭제했습니다."); } finally { setBusy(false); } }; const saveNotice = async (input) => { if (busy) return; setBusy(true); try { const result = drawer.item ? await api.updateNotice(drawer.item.id, input) : await api.createNotice(input); drawer.item ? patchCollection("notices", result.item) : addCollection("notices", result.item); closeDrawer(); flash("공지를 저장했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }; const deleteNotice = async () => { if (busy || !drawer.item || !window.confirm("이 공지를 삭제할까요?")) return; setBusy(true); try { await api.deleteNotice(drawer.item.id); setOverview((current) => ({ ...current, notices: current.notices.filter((item) => item.id !== drawer.item.id) })); closeDrawer(); flash("공지를 삭제했습니다."); } finally { setBusy(false); } }; - const navigate = (id) => { setActive(id); if (drawer.open) closeDrawer(); }; + const uploadFile = async (input) => { if (busy) return false; setBusy(true); try { const result = await api.uploadFile(input); setFileLibrary((current) => ({ ...current, items: [result.item, ...current.items], loaded: true, error: "" })); flash("자료를 업로드했습니다."); return true; } catch (err) { flash(err.message); return false; } finally { setBusy(false); } }; + const deleteFile = async (item) => { if (busy || !window.confirm(`'${item.alias || item.original_name || "자료"}'을 삭제할까요?`)) return; setBusy(true); try { await api.deleteFile(item.id); setFileLibrary((current) => ({ ...current, items: current.items.filter((file) => file.id !== item.id) })); flash("자료를 삭제했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }; + const copyFileLink = async (item) => { try { await copyText(api.fileShareUrl(item)); flash("자료 링크를 복사했습니다."); } catch { flash("링크를 복사하지 못했습니다."); } }; + const navigate = (id) => { setActive(id); if (id === "files" && !fileLibrary.loaded && !fileLibrary.loading) loadFiles(); if (drawer.open) closeDrawer(); }; const screen = useMemo(() => { if (!overview) return null; @@ -89,10 +115,11 @@ export default function App() { if (active === "events") return setDrawer({ type: "event", item, open: true })} />; if (active === "timetable") return { if (busy) return; setBusy(true); try { const result = await api.saveTimetable(weekday, items); setOverview((current) => ({ ...current, timetable: [...current.timetable.filter((item) => item.weekday !== weekday), ...result.items] })); flash(`${["", "월", "화", "수", "목", "금"][weekday]}요일 시간표를 저장했습니다.`); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; if (active === "notices") return setDrawer({ type: "notice", item, open: true })} onSend={async (id) => { if (busy) return; setBusy(true); try { const result = await api.sendNotice(id); patchCollection("notices", result.item); flash("게시 요청을 접수했습니다. 전송 결과는 알림 기록에서 확인하세요."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; + if (active === "files") return ; if (active === "members") return { if (busy) return false; setBusy(true); try { const result = await api.createMember(input); addCollection("members", result.item); flash("구성원을 추가했습니다."); return true; } catch (err) { flash(err.message); return false; } finally { setBusy(false); } }} onUpdate={async (id, patch) => { if (busy) return; setBusy(true); try { const result = await api.updateMember(id, patch); patchCollection("members", result.item); } catch (err) { flash(err.message); } finally { setBusy(false); } }} onInvite={async (id) => { if (busy) return; setBusy(true); try { const result = await api.inviteMember(id); await navigator.clipboard?.writeText(result.invite_url || result.code || ""); flash("초대 링크를 복사했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; if (active === "notifications") return { if (busy) return; setBusy(true); try { const result = await api.notifications(); setOverview((current) => ({ ...current, notifications: result.items })); flash("알림 상태를 새로고침했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} onTest={async () => { if (busy) return; setBusy(true); try { const result = await api.testNotification(); if (result.item) addCollection("notifications", result.item); flash("테스트 알림 요청을 접수했습니다. 아직 전송 완료 상태는 아닙니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; return { if (busy) return; setBusy(true); try { const result = await api.updateSettings(patch); setOverview((current) => ({ ...current, classroom: result.item })); flash("설정을 저장했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} onLogout={async () => { await api.logout(); setSession({ authenticated: false }); setOverview(null); }} />; - }, [active, overview, busy, drawer.item]); + }, [active, overview, busy, drawer.item, fileLibrary]); if (loading) return
학급 정보를 불러오는 중
; if (loadError && !overview) return ; diff --git a/apps/classbot/src/api/client.js b/apps/classbot/src/api/client.js index 7325d9e..23e4eb6 100644 --- a/apps/classbot/src/api/client.js +++ b/apps/classbot/src/api/client.js @@ -26,6 +26,7 @@ function readLocal() { } let localState = readLocal(); +if (!Array.isArray(localState.files)) localState.files = []; function persistLocal() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(localState)); } catch { /* private mode */ } @@ -37,11 +38,12 @@ function nextId(prefix) { async function request(path, options = {}) { const { body, headers, root = false, ...rest } = options; + const isFormData = typeof FormData !== "undefined" && body instanceof FormData; const response = await fetch(root ? path : resolveApiPath(path), { credentials: "same-origin", ...rest, - headers: body ? { "Content-Type": "application/json", ...headers } : headers, - body: body && typeof body !== "string" ? JSON.stringify(body) : body, + headers: body && !isFormData ? { "Content-Type": "application/json", ...headers } : headers, + body: body && typeof body !== "string" && !isFormData ? JSON.stringify(body) : body, }); const data = await response.json().catch(() => ({})); if (!response.ok) { @@ -131,6 +133,43 @@ export const api = { updateNotice(id, patch) { return remoteOrLocal(() => request(`/api/admin/notices/${id}`, { method: "PATCH", body: patch }), () => updateItem("notices", id, patch)); }, deleteNotice(id) { return remoteOrLocal(() => request(`/api/admin/notices/${id}`, { method: "DELETE" }), () => { localState.notices = localState.notices.filter((item) => item.id !== id); persistLocal(); return { ok: true }; }); }, sendNotice(id, options = {}) { return remoteOrLocal(() => request(`/api/admin/notices/${id}/send`, { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("notice-send") } }), () => updateItem("notices", id, { status: "published", published_at: new Date().toISOString() })); }, + files() { return remoteOrLocal(() => request("/api/admin/files"), () => ({ items: structuredClone(localState.files) })); }, + uploadFile(input, options) { + const body = new FormData(); + body.append("file", input.file); + body.append("alias", input.alias); + body.append("description", input.description || ""); + body.append("member_id", input.member_id || ""); + body.append("visibility", input.member_id ? "private" : "class"); + return remoteOrLocal( + () => request("/api/admin/files", { method: "POST", headers: idempotencyHeaders("file", input, options), body }), + () => { + const now = new Date().toISOString(); + const item = { + id: nextId("file"), alias: input.alias, description: input.description || "", member_id: input.member_id || null, + filename: input.file.name, original_name: input.file.name, mime_type: input.file.type || "application/octet-stream", + size_bytes: input.file.size, visibility: input.member_id ? "private" : "class", + download_url: URL.createObjectURL(input.file), created_at: now, updated_at: now, + }; + localState.files.unshift(item); + persistLocal(); + return { item: structuredClone(item) }; + }, + ); + }, + deleteFile(id) { return remoteOrLocal(() => request(`/api/admin/files/${id}`, { method: "DELETE" }), () => { localState.files = localState.files.filter((item) => item.id !== id); persistLocal(); return { ok: true }; }); }, + fileDownloadUrl(item) { + const value = item?.admin_download_url || item?.download_url || item?.file_url; + if (value) return new URL(value, location.href).href; + return new URL(resolveApiPath(`/api/admin/files/${item.id}/download`), location.href).href; + }, + fileShareUrl(item) { + const value = item?.share_url || item?.public_url || item?.url; + if (value) return new URL(value, location.href).href; + const token = item?.share_token || item?.public_token || item?.token; + if (token) return new URL(resolveApiPath(`/api/files/${encodeURIComponent(token)}`), location.href).href; + return this.fileDownloadUrl(item); + }, notifications() { return remoteOrLocal(() => request("/api/admin/notifications"), () => ({ items: structuredClone(localState.notifications) })); }, testNotification(options = {}) { return remoteOrLocal(() => request("/api/admin/notifications/test", { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("notification-test") } }), () => createItem("notifications", "notification", { kind: "test", status: "reserved", scheduled_for: new Date().toISOString(), payload: { title: "테스트 알림" } })); }, }; diff --git a/apps/classbot/src/components/AppShell.jsx b/apps/classbot/src/components/AppShell.jsx index 4adc96b..9d859fe 100644 --- a/apps/classbot/src/components/AppShell.jsx +++ b/apps/classbot/src/components/AppShell.jsx @@ -1,6 +1,6 @@ import { Bell, CalendarDays, ChevronDown, ClipboardList, Clock3, - LayoutDashboard, Megaphone, Plus, Settings, Users, X, + FolderOpen, LayoutDashboard, Megaphone, Plus, Settings, Users, X, } from "lucide-react"; const navItems = [ @@ -8,13 +8,14 @@ const navItems = [ ["events", "일정", CalendarDays], ["timetable", "시간표", ClipboardList], ["notices", "반 공지", Megaphone], + ["files", "자료실", FolderOpen], ["members", "구성원", Users], ["notifications", "알림 기록", Bell], ["settings", "설정", Settings], ]; export function Brand() { - return
Quilo
; + return
Quilo
; } export function Sidebar({ active, onNavigate, classroom, memberCount }) { diff --git a/apps/classbot/src/pages/FilesPage.jsx b/apps/classbot/src/pages/FilesPage.jsx new file mode 100644 index 0000000..5c286c9 --- /dev/null +++ b/apps/classbot/src/pages/FilesPage.jsx @@ -0,0 +1,113 @@ +import { useMemo, useRef, useState } from "react"; +import { + Copy, ExternalLink, FileImage, FileText, FolderOpen, LoaderCircle, + LockKeyhole, Trash2, Upload, Users, X, +} from "lucide-react"; +import { dateLabel } from "../lib/format.js"; + +const ACCEPTED_TYPES = "application/pdf,image/jpeg,image/png,image/webp,image/gif"; +const ACCEPTED_EXTENSIONS = /\.(pdf|png|jpe?g|webp|gif)$/i; + +function isAcceptedFile(file) { + return ["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"].includes(file?.type) || ACCEPTED_EXTENSIONS.test(file?.name || ""); +} + +function originalName(item) { + return item.original_name || item.original_filename || item.file_name || item.filename || "파일"; +} + +function fileMime(item) { + return item.mime_type || item.content_type || ""; +} + +function fileSize(item) { + const value = Number(item.size_bytes ?? item.size ?? item.file_size ?? item.byte_size); + if (!Number.isFinite(value) || value < 0) return "용량 정보 없음"; + if (value < 1024) return `${value} B`; + if (value < 1024 ** 2) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`; + return `${(value / 1024 ** 2).toFixed(value < 10 * 1024 ** 2 ? 1 : 0)} MB`; +} + +export default function FilesPage({ files, members, busy, loading, error, onUpload, onDelete, onCopy, onRefresh, downloadUrl }) { + const inputRef = useRef(null); + const [file, setFile] = useState(null); + const [alias, setAlias] = useState(""); + const [description, setDescription] = useState(""); + const [memberId, setMemberId] = useState(""); + const [fileError, setFileError] = useState(""); + const targetOptions = useMemo(() => members.filter((member) => member.status !== "left"), [members]); + const memberNames = useMemo(() => new Map(members.map((member) => [member.id, member.display_name])), [members]); + const ordered = useMemo(() => [...files].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)), [files]); + + const chooseFile = (nextFile) => { + if (!nextFile) return; + if (!isAcceptedFile(nextFile)) { + setFileError("PDF, JPEG, PNG, WebP 또는 GIF 파일만 업로드할 수 있습니다."); + return; + } + setFile(nextFile); + setFileError(""); + if (!alias.trim()) setAlias(nextFile.name.replace(/\.[^.]+$/, "")); + }; + + const reset = () => { + setFile(null); setAlias(""); setDescription(""); setMemberId(""); setFileError(""); + if (inputRef.current) inputRef.current.value = ""; + }; + + const submit = async (event) => { + event.preventDefault(); + if (!file) { setFileError("업로드할 파일을 선택해 주세요."); return; } + if (!alias.trim() || busy) return; + const saved = await onUpload({ file, alias: alias.trim(), description: description.trim(), member_id: memberId || null }); + if (saved !== false) reset(); + }; + + return ( +
+

자료실

PDF와 이미지를 반 전체 또는 선택한 구성원에게 안전하게 공유합니다.

+ +
+

자료 업로드

PDF · 이미지
+
+
+ chooseFile(event.target.files?.[0])} disabled={busy} /> + + {file &&
{file.name}{fileSize(file)}
} + {fileError &&

{fileError}

} +
+ +
+ +