From fd2887f4c8292ea64e4f5ed4ddc30072c5eddc6b Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Sat, 6 Jun 2026 22:17:21 +0900 Subject: [PATCH 1/4] feat: add popup feature --- cmd/server/main.go | 4 +- docs/docs/admin.md | 170 ++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/PopupCarousel.tsx | 209 ++++++++++++ frontend/src/components/UserAvatar.tsx | 13 +- frontend/src/lib/api.ts | 33 ++ frontend/src/lib/media.ts | 9 + frontend/src/lib/types.ts | 33 ++ frontend/src/locales/en.json | 29 ++ frontend/src/locales/ja.json | 29 ++ frontend/src/locales/ko.json | 29 ++ frontend/src/routes/Admin.tsx | 8 +- frontend/src/routes/admin/Popups.tsx | 269 +++++++++++++++ internal/db/db.go | 2 + internal/http/handlers/errors.go | 3 + internal/http/handlers/handler.go | 158 ++++++++- internal/http/handlers/handler_test.go | 284 +++++++++++++++- internal/http/handlers/testenv_test.go | 13 +- internal/http/handlers/types.go | 65 ++++ internal/http/integration/community_test.go | 2 +- internal/http/integration/popups_test.go | 161 +++++++++ internal/http/integration/testenv_test.go | 31 +- internal/http/router.go | 12 +- internal/models/popup.go | 21 ++ internal/repo/community_repo_test.go | 2 +- internal/repo/popup_repo.go | 79 +++++ internal/repo/popup_repo_test.go | 85 +++++ internal/repo/testenv_test.go | 2 +- internal/service/community_service_test.go | 2 +- internal/service/errors.go | 1 + internal/service/popup_service.go | 345 ++++++++++++++++++++ internal/service/popup_service_test.go | 210 ++++++++++++ internal/service/testenv_test.go | 11 +- internal/service/vm_service_test.go | 6 +- internal/storage/memory.go | 12 +- internal/storage/s3_media_test.go | 4 +- migrations/2026-06-06/001_add_popups.sql | 18 + migrations/2026-06-06/999_rollback.sql | 5 + 38 files changed, 2319 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/PopupCarousel.tsx create mode 100644 frontend/src/lib/media.ts create mode 100644 frontend/src/routes/admin/Popups.tsx create mode 100644 internal/http/integration/popups_test.go create mode 100644 internal/models/popup.go create mode 100644 internal/repo/popup_repo.go create mode 100644 internal/repo/popup_repo_test.go create mode 100644 internal/service/popup_service.go create mode 100644 internal/service/popup_service_test.go create mode 100644 migrations/2026-06-06/001_add_popups.sql create mode 100644 migrations/2026-06-06/999_rollback.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 4258977..7e86cca 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -73,6 +73,7 @@ func main() { challengeCommentRepo := repo.NewChallengeCommentRepo(database) communityRepo := repo.NewCommunityRepo(database) challengeSeriesRepo := repo.NewChallengeSeriesRepo(database) + popupRepo := repo.NewPopupRepo(database) scoreRepo := repo.NewScoreboardRepo(database) stackRepo := repo.NewStackRepo(database) vmRepo := repo.NewVMRepo(database) @@ -99,6 +100,7 @@ func main() { authSvc := service.NewAuthService(cfg, userRepo, redisClient) userSvc := service.NewUserService(userRepo, affiliationRepo, profileImageStore) + popupSvc := service.NewPopupService(popupRepo, profileImageStore) affiliationSvc := service.NewAffiliationService(affiliationRepo) scoreSvc := service.NewScoreboardService(scoreRepo) wargameSvc := service.NewWargameService(cfg, challengeRepo, submissionRepo, voteRepo, writeupRepo, challengeCommentRepo, communityRepo, redisClient, fileStore, challengeSeriesRepo) @@ -134,7 +136,7 @@ func main() { leaderboardBus := realtime.NewScoreboardBus(redisClient, cfg, scoreSvc, logger) leaderboardBus.Start(ctx) - router := httpserver.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, redisClient, logger) + router := httpserver.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, popupSvc, redisClient, logger) srv := &nethttp.Server{ Addr: cfg.HTTPAddr, Handler: router, diff --git a/docs/docs/admin.md b/docs/docs/admin.md index 8a88baa..e74a303 100644 --- a/docs/docs/admin.md +++ b/docs/docs/admin.md @@ -51,6 +51,176 @@ Validation notes: --- +## List Active Popups + +`GET /api/popups/active` + +Returns active popups that have an uploaded image. Results are ordered newest first. + +Response 200 + +```json +{ + "popups": [ + { + "id": 12, + "title": "Event Notice", + "image_key": "popups/8f2c....png", + "image_name": "notice.png", + "link_url": "https://example.com/event", + "is_active": true, + "created_at": "2026-06-06T12:00:00Z", + "updated_at": "2026-06-06T12:00:00Z" + } + ] +} +``` + +Frontend clients build the image URL with `VITE_S3_MEDIA_CDN_BASE_URL + "/" + image_key`. If `link_url` is present, the popup image opens that URL when clicked. + +--- + +## Manage Popups + +`GET /api/admin/popups` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +```json +{ + "popups": [] +} +``` + +`POST /api/admin/popups` + +Request + +```json +{ + "title": "Event Notice", + "link_url": "https://example.com/event", + "is_active": false +} +``` + +Response 201 + +```json +{ + "id": 12, + "title": "Event Notice", + "image_key": null, + "image_name": null, + "link_url": "https://example.com/event", + "is_active": false, + "created_by_user_id": 1, + "created_at": "2026-06-06T12:00:00Z", + "updated_at": "2026-06-06T12:00:00Z" +} +``` + +`PUT /api/admin/popups/{id}` + +Request + +```json +{ + "title": "Updated Notice", + "link_url": "https://example.com/updated", + "is_active": false +} +``` + +`DELETE /api/admin/popups/{id}` + +Response 200 + +```json +{ + "status": "ok" +} +``` + +Errors: + +- 400 `invalid input` +- 401 `invalid token` or `missing access_token cookie` +- 403 `forbidden` +- 404 `popup not found` + +Validation notes: + +- A popup cannot be created or updated with `is_active: true` until an image has been finalized. +- Deleting a popup image also deactivates that popup. +- `link_url` is optional, but when present it must be an `http://` or `https://` URL. + +--- + +## Popup Image Upload + +`POST /api/admin/popups/{id}/image/upload` + +Request + +```json +{ + "filename": "notice.png" +} +``` + +Response 200 + +```json +{ + "popup": { + "id": 12, + "title": "Event Notice", + "image_key": null, + "image_name": null, + "link_url": "https://example.com/event", + "is_active": false, + "created_at": "2026-06-06T12:00:00Z", + "updated_at": "2026-06-06T12:00:00Z" + }, + "upload": { + "url": "https://...", + "method": "POST", + "fields": { + "key": "popups/8f2c....png", + "Content-Type": "image/png" + }, + "expires_at": "2026-06-06T12:15:00Z" + } +} +``` + +After uploading the file to the presigned POST URL, finalize it: + +`PUT /api/admin/popups/{id}/image` + +```json +{ + "key": "popups/8f2c....png", + "filename": "notice.png" +} +``` + +`DELETE /api/admin/popups/{id}/image` + +Validation notes: + +- Popup images must use `.png`, `.jpg`, `.jpeg`, or `.webp`. +- Uploads use the S3 Media store and are limited to 10 MB. + +--- + ## Unblock User `POST /api/admin/users/{id}/unblock` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c18b502..83450bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { useLocale, useT } from './lib/i18n' import { SITE_CONFIG } from './lib/siteConfig' import './index.css' import DismissibleNotice from './components/DismissibleNotice' +import PopupCarousel from './components/PopupCarousel' interface RouteProps { routeParams?: Record @@ -199,6 +200,7 @@ const App = () => {

{t('footer.copyright')}

+ ) } diff --git a/frontend/src/components/PopupCarousel.tsx b/frontend/src/components/PopupCarousel.tsx new file mode 100644 index 0000000..b80b6e9 --- /dev/null +++ b/frontend/src/components/PopupCarousel.tsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import { useApi } from '../lib/useApi' +import { useT } from '../lib/i18n' +import { formatApiError } from '../lib/utils' +import { mediaURL } from '../lib/media' +import type { Popup } from '../lib/types' + +const HIDDEN_UNTIL_KEY = 'popups_hidden_until' +const DAY_MS = 24 * 60 * 60 * 1000 +const AUTO_ADVANCE_MS = 10000 +const POPUP_ENTER_DELAY_MS = 250 +const POPUP_ENTER_TRANSITION_MS = 260 + +const isHiddenToday = () => { + const hiddenUntil = Number(window.localStorage.getItem(HIDDEN_UNTIL_KEY) ?? '0') + return Number.isFinite(hiddenUntil) && hiddenUntil > Date.now() +} + +const PopupCarousel = () => { + const api = useApi() + const t = useT() + const [popups, setPopups] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(0) + const [visible, setVisible] = useState(false) + const [entered, setEntered] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [autoAdvanceNonce, setAutoAdvanceNonce] = useState(0) + const [emblaRef, emblaApi] = useEmblaCarousel({ align: 'start', containScroll: 'trimSnaps', dragFree: false, slidesToScroll: 1 }) + const [canScrollPrev, setCanScrollPrev] = useState(false) + const [canScrollNext, setCanScrollNext] = useState(false) + + useEffect(() => { + let mounted = true + let showTimer: number | null = null + + const load = async () => { + if (isHiddenToday()) return + + try { + const data = await api.activePopups() + if (!mounted) return + + const rows = data.popups.filter((popup) => mediaURL(popup.image_key)) + setPopups(rows) + setSelectedIndex(0) + if (rows.length > 0) { + showTimer = window.setTimeout(() => { + if (!mounted) return + setVisible(true) + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (mounted) setEntered(true) + }) + }) + }, POPUP_ENTER_DELAY_MS) + } + } catch (error) { + if (mounted) setErrorMessage(formatApiError(error, t).message) + } + } + + void load() + + return () => { + mounted = false + if (showTimer !== null) window.clearTimeout(showTimer) + } + }, [api, t]) + + const updateEmblaButtons = useCallback(() => { + if (!emblaApi) return + setCanScrollPrev(emblaApi.canScrollPrev()) + setCanScrollNext(emblaApi.canScrollNext()) + setSelectedIndex(emblaApi.selectedScrollSnap()) + }, [emblaApi]) + + useEffect(() => { + if (!emblaApi || !visible) return + updateEmblaButtons() + emblaApi.on('select', updateEmblaButtons) + emblaApi.on('reInit', updateEmblaButtons) + return () => { + emblaApi.off('select', updateEmblaButtons) + emblaApi.off('reInit', updateEmblaButtons) + } + }, [emblaApi, updateEmblaButtons, visible]) + + useEffect(() => { + if (!visible || !emblaApi || popups.length <= 1) return + + const timer = window.setInterval(() => { + if (emblaApi.canScrollNext()) { + emblaApi.scrollNext() + return + } + emblaApi.scrollTo(0) + }, AUTO_ADVANCE_MS) + + return () => window.clearInterval(timer) + }, [autoAdvanceNonce, emblaApi, popups.length, visible]) + + const current = popups[selectedIndex] + const imageURL = useMemo(() => mediaURL(current?.image_key), [current?.image_key]) + + if (!visible || !current || !imageURL) return null + + const resetAutoAdvance = () => setAutoAdvanceNonce((prev) => prev + 1) + const previous = () => { + resetAutoAdvance() + emblaApi?.scrollPrev() + } + const next = () => { + resetAutoAdvance() + emblaApi?.scrollNext() + } + const goTo = (popupIndex: number) => { + resetAutoAdvance() + emblaApi?.scrollTo(popupIndex) + } + const close = () => { + setEntered(false) + window.setTimeout(() => setVisible(false), POPUP_ENTER_TRANSITION_MS) + } + const hideForDay = () => { + window.localStorage.setItem(HIDDEN_UNTIL_KEY, String(Date.now() + DAY_MS)) + close() + } + + return ( +
+
+ + +
+ {popups.length > 1 ? ( + + ) : null} + +
+
+ {popups.map((popup) => ( +
+ {popup.link_url ? ( + + {popup.title} + + ) : ( + {popup.title} + )} +
+ ))} +
+
+ + {popups.length > 1 ? ( + + ) : null} +
+ +
+ + + {popups.length > 1 ? ( +
+ {popups.map((popup, popupIndex) => ( +
+ ) : ( +
+ )} + + {errorMessage} +
+
+
+ ) +} + +export default PopupCarousel diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx index 6e14314..33dfd70 100644 --- a/frontend/src/components/UserAvatar.tsx +++ b/frontend/src/components/UserAvatar.tsx @@ -1,4 +1,5 @@ import { generateColorFromUsername } from '../lib/utils' +import { mediaURL } from '../lib/media' interface UserAvatarProps { username: string @@ -6,20 +7,10 @@ interface UserAvatarProps { profileImage?: string | null } -const PROFILE_IMAGE_CDN_BASE = String(import.meta.env.VITE_S3_MEDIA_CDN_BASE_URL ?? '') - .trim() - .replace(/\/+$/, '') - -const joinCDNURL = (base: string, key: string) => { - const normalizedKey = key.replace(/^\/+/, '') - if (!base || !normalizedKey) return '' - return `${base}/${normalizedKey}` -} - const UserAvatar = ({ username, size = 'md', profileImage }: UserAvatarProps) => { const firstLetter = username.charAt(0).toUpperCase() const backgroundColor = generateColorFromUsername(username) - const imageURL = profileImage ? joinCDNURL(PROFILE_IMAGE_CDN_BASE, profileImage) : '' + const imageURL = mediaURL(profileImage) const sizeClasses = { sm: 'h-8 w-8 text-xs', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1dd8ede..858d6f4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -9,6 +9,11 @@ import type { ChallengeUpdatePayload, ChallengeFileUploadResponse, ProfileImageUploadResponse, + Popup, + PopupCreatePayload, + PopupImageUploadResponse, + PopupsResponse, + PopupUpdatePayload, ChallengeMyVoteResponse, ChallengeVotesResponse, ChallengeSeriesListResponse, @@ -607,6 +612,34 @@ export const createApi = ({ setAuthUser, clearAuth, translate }: ApiDeps) => { submissions: Array.isArray(data?.submissions) ? data.submissions : [], } as TimelineResponse }, + activePopups: async () => { + const data = await request>(`/api/popups/active`, { noCache: true }) + return { + popups: Array.isArray(data?.popups) ? data.popups : [], + } as PopupsResponse + }, + adminPopups: async () => { + const data = await request>(`/api/admin/popups`, { auth: true, noCache: true }) + return { + popups: Array.isArray(data?.popups) ? data.popups : [], + } as PopupsResponse + }, + createPopup: (payload: PopupCreatePayload) => request(`/api/admin/popups`, { method: 'POST', body: payload, auth: true }), + updatePopup: (id: number, payload: PopupUpdatePayload) => request(`/api/admin/popups/${id}`, { method: 'PUT', body: payload, auth: true }), + deletePopup: (id: number) => request<{ status: string }>(`/api/admin/popups/${id}`, { method: 'DELETE', auth: true }), + requestPopupImageUpload: (id: number, filename: string) => + request(`/api/admin/popups/${id}/image/upload`, { + method: 'POST', + body: { filename }, + auth: true, + }), + finalizePopupImageUpload: (id: number, key: string, filename: string) => + request(`/api/admin/popups/${id}/image`, { + method: 'PUT', + body: { key, filename }, + auth: true, + }), + deletePopupImage: (id: number) => request(`/api/admin/popups/${id}/image`, { method: 'DELETE', auth: true }), createChallenge: (payload: ChallengeCreatePayload) => request(`/api/admin/challenges`, { method: 'POST', body: payload, auth: true }), adminChallenge: (id: number) => request(`/api/admin/challenges/${id}`, { auth: true }), updateChallenge: (id: number, payload: ChallengeUpdatePayload) => request(`/api/admin/challenges/${id}`, { method: 'PUT', body: payload, auth: true }), diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts new file mode 100644 index 0000000..f5e7729 --- /dev/null +++ b/frontend/src/lib/media.ts @@ -0,0 +1,9 @@ +const S3_MEDIA_CDN_BASE = String(import.meta.env.VITE_S3_MEDIA_CDN_BASE_URL ?? '') + .trim() + .replace(/\/+$/, '') + +export const mediaURL = (key?: string | null) => { + const normalizedKey = String(key ?? '').replace(/^\/+/, '') + if (!S3_MEDIA_CDN_BASE || !normalizedKey) return '' + return `${S3_MEDIA_CDN_BASE}/${normalizedKey}` +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 75750b2..27c303b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -243,6 +243,39 @@ export interface ProfileImageUploadResponse { upload: PresignedUpload } +export interface Popup { + id: number + title: string + image_key: string | null + image_name: string | null + link_url: string | null + is_active: boolean + created_by_user_id?: number | null + created_at: string + updated_at: string +} + +export interface PopupsResponse { + popups: Popup[] +} + +export interface PopupCreatePayload { + title: string + link_url?: string | null + is_active: boolean +} + +export interface PopupUpdatePayload { + title?: string + link_url?: string | null + is_active?: boolean +} + +export interface PopupImageUploadResponse { + popup: Popup + upload: PresignedUpload +} + export interface FlagSubmissionPayload { flag: string } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c4f4015..f93dd5a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -298,12 +298,41 @@ "admin.tab.stacks": "Stacks", "admin.tab.vms": "VMs", "admin.tab.affiliations": "Affiliations", + "admin.tab.popups": "Popups", "admin.affiliations.title": "Affiliations", "admin.affiliations.namePlaceholder": "Affiliation name", "admin.affiliations.create": "Create", "admin.affiliations.creating": "Creating...", "admin.affiliations.created": "Created affiliation \"{name}\"", "admin.affiliations.empty": "No affiliations found.", + "admin.popups.title": "Popup Management", + "admin.popups.titlePlaceholder": "Popup title", + "admin.popups.linkPlaceholder": "External link (optional)", + "admin.popups.create": "Create Popup", + "admin.popups.selectImage": "Select Image", + "admin.popups.created": "Popup created.", + "admin.popups.updated": "Popup updated.", + "admin.popups.deleted": "Popup deleted.", + "admin.popups.saving": "Saving...", + "admin.popups.empty": "No popups found.", + "admin.popups.active": "Active", + "admin.popups.inactive": "Inactive", + "admin.popups.preview": "Preview", + "admin.popups.titleColumn": "Title", + "admin.popups.status": "Status", + "admin.popups.actions": "Actions", + "admin.popups.uploadImage": "Upload Image", + "admin.popups.uploading": "Uploading...", + "admin.popups.deleteImage": "Delete Image", + "admin.popups.imageUploaded": "Image uploaded.", + "admin.popups.imageDeleted": "Image deleted.", + "admin.popups.imageTypeError": "Only image files can be uploaded.", + "admin.popups.activeRequiresImage": "Upload an image before activating this popup.", + "admin.popups.deleteConfirm": "Delete this popup?", + "popup.hideForDay": "Do not show for a day", + "popup.previous": "Previous popup", + "popup.next": "Next popup", + "popup.goTo": "Show popup {index}", "admin.users.searchPlaceholder": "Search by username or ID...", "admin.users.loading": "Loading users...", "admin.users.noUsers": "No users found.", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 6bfac0d..1924a48 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -298,12 +298,41 @@ "admin.tab.stacks": "スタック管理", "admin.tab.vms": "VM管理", "admin.tab.affiliations": "所属管理", + "admin.tab.popups": "ポップアップ管理", "admin.affiliations.title": "所属管理", "admin.affiliations.namePlaceholder": "所属名", "admin.affiliations.create": "所属作成", "admin.affiliations.creating": "作成中...", "admin.affiliations.created": "所属 \"{name}\" を作成しました", "admin.affiliations.empty": "所属がありません。", + "admin.popups.title": "ポップアップ管理", + "admin.popups.titlePlaceholder": "ポップアップタイトル", + "admin.popups.linkPlaceholder": "外部リンク(任意)", + "admin.popups.create": "作成", + "admin.popups.selectImage": "画像選択", + "admin.popups.created": "ポップアップを作成しました。", + "admin.popups.updated": "ポップアップを更新しました。", + "admin.popups.deleted": "ポップアップを削除しました。", + "admin.popups.saving": "保存中...", + "admin.popups.empty": "ポップアップがありません。", + "admin.popups.active": "有効", + "admin.popups.inactive": "無効", + "admin.popups.preview": "プレビュー", + "admin.popups.titleColumn": "タイトル", + "admin.popups.status": "状態", + "admin.popups.actions": "操作", + "admin.popups.uploadImage": "画像アップロード", + "admin.popups.uploading": "アップロード中...", + "admin.popups.deleteImage": "画像削除", + "admin.popups.imageUploaded": "画像をアップロードしました。", + "admin.popups.imageDeleted": "画像を削除しました。", + "admin.popups.imageTypeError": "画像ファイルのみアップロードできます。", + "admin.popups.activeRequiresImage": "画像を登録してから有効化してください。", + "admin.popups.deleteConfirm": "このポップアップを削除しますか?", + "popup.hideForDay": "1日表示しない", + "popup.previous": "前のポップアップ", + "popup.next": "次のポップアップ", + "popup.goTo": "{index}番目のポップアップを表示", "admin.users.searchPlaceholder": "ユーザー名またはIDで検索...", "admin.users.loading": "ユーザーを読み込み中...", "admin.users.noUsers": "ユーザーがいません。", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 939accd..0e8cb28 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -286,12 +286,41 @@ "admin.tab.stacks": "스택 관리", "admin.tab.vms": "VM 관리", "admin.tab.affiliations": "소속 관리", + "admin.tab.popups": "팝업 관리", "admin.affiliations.title": "소속 관리", "admin.affiliations.namePlaceholder": "소속 이름", "admin.affiliations.create": "소속 생성", "admin.affiliations.creating": "생성 중...", "admin.affiliations.created": "\"{name}\" 소속을 생성했습니다", "admin.affiliations.empty": "생성된 소속이 없습니다.", + "admin.popups.title": "팝업 관리", + "admin.popups.titlePlaceholder": "팝업 제목", + "admin.popups.linkPlaceholder": "외부 링크 (선택)", + "admin.popups.create": "팝업 생성", + "admin.popups.selectImage": "이미지 선택", + "admin.popups.created": "팝업을 생성했습니다.", + "admin.popups.updated": "팝업을 수정했습니다.", + "admin.popups.deleted": "팝업을 삭제했습니다.", + "admin.popups.saving": "저장 중...", + "admin.popups.empty": "등록된 팝업이 없습니다.", + "admin.popups.active": "활성", + "admin.popups.inactive": "비활성", + "admin.popups.preview": "미리보기", + "admin.popups.titleColumn": "제목", + "admin.popups.status": "상태", + "admin.popups.actions": "작업", + "admin.popups.uploadImage": "이미지 업로드", + "admin.popups.uploading": "업로드 중...", + "admin.popups.deleteImage": "이미지 삭제", + "admin.popups.imageUploaded": "이미지를 업로드했습니다.", + "admin.popups.imageDeleted": "이미지를 삭제했습니다.", + "admin.popups.imageTypeError": "이미지 파일만 업로드할 수 있습니다.", + "admin.popups.activeRequiresImage": "이미지를 먼저 등록해야 활성화할 수 있습니다.", + "admin.popups.deleteConfirm": "팝업을 삭제할까요?", + "popup.hideForDay": "하루 동안 보지 않기", + "popup.previous": "이전 팝업", + "popup.next": "다음 팝업", + "popup.goTo": "{index}번째 팝업 보기", "admin.users.searchPlaceholder": "아이디 또는 번호로 검색...", "admin.users.loading": "유저 불러오는 중...", "admin.users.noUsers": "유저가 없습니다.", diff --git a/frontend/src/routes/Admin.tsx b/frontend/src/routes/Admin.tsx index a3a4c37..36fd171 100644 --- a/frontend/src/routes/Admin.tsx +++ b/frontend/src/routes/Admin.tsx @@ -5,6 +5,7 @@ import Users from './admin/Users' import Stacks from './admin/Stacks' import Affiliations from './admin/Affiliations' import ChallengeSeriesManagement from './admin/ChallengeSeriesManagement' +import Popups from './admin/Popups' import { useT } from '../lib/i18n' import { useAuth } from '../lib/auth' import DismissibleNotice from '../components/DismissibleNotice' @@ -13,10 +14,10 @@ interface RouteProps { routeParams?: Record } -type AdminTabId = 'challenge_create' | 'challenge_management' | 'challenge_series' | 'users' | 'vms' | 'affiliations' +type AdminTabId = 'challenge_create' | 'challenge_management' | 'challenge_series' | 'popups' | 'users' | 'vms' | 'affiliations' const TAB_PARAM = 'tab' const ADMIN_NOTICE_DISMISSED_KEY = 'admin_notice_dismissed' -const ADMIN_TAB_IDS: AdminTabId[] = ['challenge_create', 'challenge_management', 'challenge_series', 'users', 'vms', 'affiliations'] +const ADMIN_TAB_IDS: AdminTabId[] = ['challenge_create', 'challenge_management', 'challenge_series', 'popups', 'users', 'vms', 'affiliations'] const getTabFromUrl = (): AdminTabId | null => { const params = new URLSearchParams(window.location.search) @@ -36,6 +37,7 @@ const Admin = ({ routeParams = {} }: RouteProps) => { { id: 'users', label: t('admin.tab.users') }, { id: 'vms', label: t('admin.tab.vms') }, { id: 'affiliations', label: t('admin.tab.affiliations') }, + { id: 'popups', label: t('admin.tab.popups') }, ], [t], ) @@ -104,6 +106,8 @@ const Admin = ({ routeParams = {} }: RouteProps) => { ) : activeTab === 'challenge_series' ? ( + ) : activeTab === 'popups' ? ( + ) : activeTab === 'vms' ? ( ) : activeTab === 'users' ? ( diff --git a/frontend/src/routes/admin/Popups.tsx b/frontend/src/routes/admin/Popups.tsx new file mode 100644 index 0000000..5c3baf2 --- /dev/null +++ b/frontend/src/routes/admin/Popups.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useState } from 'react' +import { uploadPresignedPost } from '../../lib/api' +import { useApi } from '../../lib/useApi' +import { useT } from '../../lib/i18n' +import { formatApiError } from '../../lib/utils' +import { mediaURL } from '../../lib/media' +import type { Popup } from '../../lib/types' +import FormMessage from '../../components/FormMessage' + +const AdminPopups = () => { + const api = useApi() + const t = useT() + const [rows, setRows] = useState([]) + const [title, setTitle] = useState('') + const [linkURL, setLinkURL] = useState('') + const [active, setActive] = useState(false) + const [createImage, setCreateImage] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [uploadingID, setUploadingID] = useState(null) + const [errorMessage, setErrorMessage] = useState('') + const [successMessage, setSuccessMessage] = useState('') + + const loadRows = useCallback(async () => { + setLoading(true) + setErrorMessage('') + try { + const data = await api.adminPopups() + setRows(data.popups) + } catch (error) { + setRows([]) + setErrorMessage(formatApiError(error, t).message) + } finally { + setLoading(false) + } + }, [api, t]) + + useEffect(() => { + void loadRows() + }, [loadRows]) + + const replaceRow = (popup: Popup) => { + setRows((prev) => prev.map((row) => (row.id === popup.id ? popup : row))) + } + + const uploadPopupImage = async (popup: Popup, file: File) => { + if (!file.type.startsWith('image/')) { + throw new Error(t('admin.popups.imageTypeError')) + } + + setUploadingID(popup.id) + const upload = await api.requestPopupImageUpload(popup.id, file.name) + await uploadPresignedPost(upload.upload, file) + return api.finalizePopupImageUpload(popup.id, upload.upload.fields?.key ?? '', file.name) + } + + const createPopup = async () => { + const trimmed = title.trim() + if (!trimmed) { + setErrorMessage(t('errors.required')) + return + } + if (active && !createImage) { + setErrorMessage(t('admin.popups.activeRequiresImage')) + return + } + + setSaving(true) + setErrorMessage('') + setSuccessMessage('') + try { + const trimmedLink = linkURL.trim() + let created = await api.createPopup({ title: trimmed, link_url: trimmedLink || null, is_active: false }) + setRows((prev) => [created, ...prev]) + if (createImage) { + created = await uploadPopupImage(created, createImage) + if (active) created = await api.updatePopup(created.id, { is_active: true }) + setRows((prev) => prev.map((row) => (row.id === created.id ? created : row))) + } + setTitle('') + setLinkURL('') + setActive(false) + setCreateImage(null) + setSuccessMessage(t('admin.popups.created')) + } catch (error) { + setErrorMessage(formatApiError(error, t).message) + } finally { + setSaving(false) + setUploadingID(null) + } + } + + const updatePopup = async (popup: Popup, patch: { title?: string; link_url?: string | null; is_active?: boolean }) => { + setSaving(true) + setErrorMessage('') + setSuccessMessage('') + try { + const updated = await api.updatePopup(popup.id, patch) + replaceRow(updated) + setSuccessMessage(t('admin.popups.updated')) + } catch (error) { + setErrorMessage(formatApiError(error, t).message) + } finally { + setSaving(false) + } + } + + const deletePopup = async (popup: Popup) => { + if (!window.confirm(t('admin.popups.deleteConfirm'))) return + + setSaving(true) + setErrorMessage('') + setSuccessMessage('') + try { + await api.deletePopup(popup.id) + setRows((prev) => prev.filter((row) => row.id !== popup.id)) + setSuccessMessage(t('admin.popups.deleted')) + } catch (error) { + setErrorMessage(formatApiError(error, t).message) + } finally { + setSaving(false) + } + } + + const uploadImage = async (popup: Popup, file: File | null) => { + if (!file) return + if (!file.type.startsWith('image/')) { + setErrorMessage(t('admin.popups.imageTypeError')) + return + } + + setUploadingID(popup.id) + setErrorMessage('') + setSuccessMessage('') + try { + const updated = await uploadPopupImage(popup, file) + replaceRow(updated) + setSuccessMessage(t('admin.popups.imageUploaded')) + } catch (error) { + setErrorMessage(formatApiError(error, t).message) + } finally { + setUploadingID(null) + } + } + + const deleteImage = async (popup: Popup) => { + setUploadingID(popup.id) + setErrorMessage('') + setSuccessMessage('') + try { + const updated = await api.deletePopupImage(popup.id) + replaceRow(updated) + setSuccessMessage(t('admin.popups.imageDeleted')) + } catch (error) { + setErrorMessage(formatApiError(error, t).message) + } finally { + setUploadingID(null) + } + } + + return ( +
+
+

{t('admin.popups.title')}

+
+ setTitle(event.target.value)} placeholder={t('admin.popups.titlePlaceholder')} disabled={saving} /> + setLinkURL(event.target.value)} placeholder={t('admin.popups.linkPlaceholder')} disabled={saving} /> + + + +
+
+ + {errorMessage ? : null} + {successMessage ? : null} + +
+
+ {t('common.id')} + {t('admin.popups.preview')} + {t('admin.popups.titleColumn')} + {t('admin.popups.status')} + {t('admin.popups.actions')} +
+ + {loading ? ( +

{t('common.loading')}

+ ) : rows.length === 0 ? ( +

{t('admin.popups.empty')}

+ ) : ( +
+ {rows.map((popup) => { + const imageURL = mediaURL(popup.image_key) + const busy = saving || uploadingID === popup.id + + return ( +
+ {popup.id} +
+
{imageURL ? {popup.title} : null}
+
+
+ { + const nextTitle = event.target.value.trim() + if (nextTitle && nextTitle !== popup.title) void updatePopup(popup, { title: nextTitle }) + }} /> + { + const nextLink = event.target.value.trim() + const currentLink = popup.link_url ?? '' + if (nextLink !== currentLink) void updatePopup(popup, { link_url: nextLink || null }) + }} + /> +
+ + + +
+ {popup.image_name ?

{popup.image_name}

: null} +
+ +
+ +
+
+ ) + })} +
+ )} +
+
+ ) +} + +export default AdminPopups diff --git a/internal/db/db.go b/internal/db/db.go index b1309a1..6a9ff68 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -39,6 +39,7 @@ func AutoMigrate(ctx context.Context, db *bun.DB) error { modelsToCreate := []any{ (*models.User)(nil), (*models.Affiliation)(nil), + (*models.Popup)(nil), (*models.Challenge)(nil), (*models.ChallengeSeries)(nil), (*models.ChallengeSeriesChallenge)(nil), @@ -76,6 +77,7 @@ func createIndexes(ctx context.Context, db *bun.DB) error { query string }{ {name: "idx_challenges_category", query: "CREATE INDEX IF NOT EXISTS idx_challenges_category ON challenges (category)"}, + {name: "idx_popups_active_created", query: "CREATE INDEX IF NOT EXISTS idx_popups_active_created ON popups (is_active, created_at DESC, id DESC)"}, {name: "idx_challenges_active_category", query: "CREATE INDEX IF NOT EXISTS idx_challenges_active_category ON challenges (is_active, category)"}, {name: "idx_challenge_series_created", query: "CREATE INDEX IF NOT EXISTS idx_challenge_series_created ON challenge_series (created_at DESC, id DESC)"}, {name: "idx_challenge_series_challenges_series_position", query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_challenge_series_challenges_series_position ON challenge_series_challenges (series_id, position)"}, diff --git a/internal/http/handlers/errors.go b/internal/http/handlers/errors.go index a100879..e8d21e4 100644 --- a/internal/http/handlers/errors.go +++ b/internal/http/handlers/errors.go @@ -101,6 +101,9 @@ func mapError(err error) (int, errorResponse, map[string]string) { case errors.Is(err, service.ErrChallengeNotFound): status = http.StatusNotFound resp.Error = service.ErrChallengeNotFound.Error() + case errors.Is(err, service.ErrPopupNotFound): + status = http.StatusNotFound + resp.Error = service.ErrPopupNotFound.Error() case errors.Is(err, service.ErrChallengeSeriesNotFound): status = http.StatusNotFound resp.Error = service.ErrChallengeSeriesNotFound.Error() diff --git a/internal/http/handlers/handler.go b/internal/http/handlers/handler.go index 6c88fe0..369a62b 100644 --- a/internal/http/handlers/handler.go +++ b/internal/http/handlers/handler.go @@ -31,16 +31,12 @@ type Handler struct { score *service.ScoreboardService stacks *service.StackService vms *service.VMService + popups *service.PopupService redis *redis.Client } -func New(cfg config.Config, auth *service.AuthService, wargame *service.WargameService, users *service.UserService, affiliations *service.AffiliationService, score *service.ScoreboardService, stacks *service.StackService, redis *redis.Client, vmServices ...*service.VMService) *Handler { - var vms *service.VMService - if len(vmServices) > 0 { - vms = vmServices[0] - } - - return &Handler{cfg: cfg, auth: auth, wargame: wargame, users: users, affiliations: affiliations, score: score, stacks: stacks, vms: vms, redis: redis} +func New(cfg config.Config, auth *service.AuthService, wargame *service.WargameService, users *service.UserService, affiliations *service.AffiliationService, score *service.ScoreboardService, stacks *service.StackService, redis *redis.Client, vmSvc *service.VMService, popupSvc *service.PopupService) *Handler { + return &Handler{cfg: cfg, auth: auth, wargame: wargame, users: users, affiliations: affiliations, score: score, stacks: stacks, vms: vmSvc, popups: popupSvc, redis: redis} } func (h *Handler) respondFromCache(ctx *gin.Context, cacheKey string) bool { @@ -1922,3 +1918,151 @@ func (h *Handler) AdminCreateAffiliation(ctx *gin.Context) { ctx.JSON(http.StatusCreated, affiliationResponse{ID: affiliation.ID, Name: affiliation.Name}) } + +func (h *Handler) ListActivePopups(ctx *gin.Context) { + rows, err := h.popups.ListActive(ctx.Request.Context()) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, newPopupsResponse(rows)) +} + +func (h *Handler) AdminListPopups(ctx *gin.Context) { + rows, err := h.popups.List(ctx.Request.Context()) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, newPopupsResponse(rows)) +} + +func (h *Handler) AdminCreatePopup(ctx *gin.Context) { + var req createPopupRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + writeBindError(ctx, err) + return + } + + active := false + if req.IsActive != nil { + active = *req.IsActive + } + + popup, err := h.popups.Create(ctx.Request.Context(), req.Title, req.LinkURL, active, middleware.UserID(ctx)) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, newPopupResponse(*popup)) +} + +func (h *Handler) AdminUpdatePopup(ctx *gin.Context) { + popupID, ok := parseIDParamOrError(ctx, "id") + if !ok { + return + } + + var req updatePopupRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + writeBindError(ctx, err) + return + } + + popup, err := h.popups.Update(ctx.Request.Context(), popupID, service.PopupUpdate{ + Title: req.Title.Value, + TitleSet: req.Title.Set, + LinkURL: req.LinkURL.Value, + LinkSet: req.LinkURL.Set, + IsActive: req.IsActive, + }) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, newPopupResponse(*popup)) +} + +func (h *Handler) AdminDeletePopup(ctx *gin.Context) { + popupID, ok := parseIDParamOrError(ctx, "id") + if !ok { + return + } + + if err := h.popups.Delete(ctx.Request.Context(), popupID); err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *Handler) AdminRequestPopupImageUpload(ctx *gin.Context) { + popupID, ok := parseIDParamOrError(ctx, "id") + if !ok { + return + } + + var req popupImageUploadRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + writeBindError(ctx, err) + return + } + + popup, upload, err := h.popups.RequestImageUpload(ctx.Request.Context(), popupID, req.Filename) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, popupImageUploadResponse{ + Popup: newPopupResponse(*popup), + Upload: presignedUploadResponse{ + URL: upload.URL, + Method: upload.Method, + Fields: upload.Fields, + Headers: upload.Headers, + ExpiresAt: upload.ExpiresAt, + }, + }) +} + +func (h *Handler) AdminFinalizePopupImageUpload(ctx *gin.Context) { + popupID, ok := parseIDParamOrError(ctx, "id") + if !ok { + return + } + + var req popupImageFinalizeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + writeBindError(ctx, err) + return + } + + popup, err := h.popups.FinalizeImageUpload(ctx.Request.Context(), popupID, req.Key, req.Filename) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, newPopupResponse(*popup)) +} + +func (h *Handler) AdminDeletePopupImage(ctx *gin.Context) { + popupID, ok := parseIDParamOrError(ctx, "id") + if !ok { + return + } + + popup, err := h.popups.DeleteImage(ctx.Request.Context(), popupID) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, newPopupResponse(*popup)) +} diff --git a/internal/http/handlers/handler_test.go b/internal/http/handlers/handler_test.go index 75fe9c6..ea136d3 100644 --- a/internal/http/handlers/handler_test.go +++ b/internal/http/handlers/handler_test.go @@ -772,6 +772,288 @@ func TestHandlerAffiliationsAndRankings(t *testing.T) { } }) } + +func TestHandlerPopupEndpoints(t *testing.T) { + env := setupHandlerTest(t) + admin := createHandlerUser(t, env, "admin@example.com", "admin", "pass", models.AdminRole) + + createCtx, createRec := newJSONContext(t, http.MethodPost, "/api/admin/popups", []byte(`{"title":"Launch Notice","link_url":"https://example.com/launch","is_active":false}`)) + createCtx.Set("userID", admin.ID) + env.handler.AdminCreatePopup(createCtx) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status %d: %s", createRec.Code, createRec.Body.String()) + } + + var created popupResponse + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create popup: %v", err) + } + + if created.Title != "Launch Notice" || created.LinkURL == nil || *created.LinkURL != "https://example.com/launch" || created.IsActive || created.CreatedByUserID == nil || *created.CreatedByUserID != admin.ID { + t.Fatalf("unexpected created popup: %+v", created) + } + + listCtx, listRec := newJSONContext(t, http.MethodGet, "/api/admin/popups", nil) + env.handler.AdminListPopups(listCtx) + if listRec.Code != http.StatusOK { + t.Fatalf("list status %d: %s", listRec.Code, listRec.Body.String()) + } + + var listResp popupsResponse + if err := json.Unmarshal(listRec.Body.Bytes(), &listResp); err != nil { + t.Fatalf("decode list popups: %v", err) + } + + if len(listResp.Popups) != 1 || listResp.Popups[0].ID != created.ID { + t.Fatalf("unexpected list popups: %+v", listResp) + } + + activeCtx, activeRec := newJSONContext(t, http.MethodGet, "/api/popups/active", nil) + env.handler.ListActivePopups(activeCtx) + if activeRec.Code != http.StatusOK { + t.Fatalf("active status before image %d: %s", activeRec.Code, activeRec.Body.String()) + } + + var emptyActive popupsResponse + if err := json.Unmarshal(activeRec.Body.Bytes(), &emptyActive); err != nil { + t.Fatalf("decode empty active popups: %v", err) + } + + if len(emptyActive.Popups) != 0 { + t.Fatalf("expected active popup without image to be hidden, got %+v", emptyActive) + } + + uploadCtx, uploadRec := newJSONContext(t, http.MethodPost, "/api/admin/popups/"+toStringID(created.ID)+"/image/upload", []byte(`{"filename":"notice.png"}`)) + uploadCtx.Params = append(uploadCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminRequestPopupImageUpload(uploadCtx) + if uploadRec.Code != http.StatusOK { + t.Fatalf("upload status %d: %s", uploadRec.Code, uploadRec.Body.String()) + } + + var uploadResp popupImageUploadResponse + if err := json.Unmarshal(uploadRec.Body.Bytes(), &uploadResp); err != nil { + t.Fatalf("decode upload popup: %v", err) + } + + key := uploadResp.Upload.Fields["key"] + if key == "" { + t.Fatalf("expected upload key in fields: %+v", uploadResp.Upload.Fields) + } + + finalizeCtx, finalizeRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID)+"/image", []byte(`{"key":"`+key+`","filename":"notice.png"}`)) + finalizeCtx.Params = append(finalizeCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminFinalizePopupImageUpload(finalizeCtx) + if finalizeRec.Code != http.StatusOK { + t.Fatalf("finalize status %d: %s", finalizeRec.Code, finalizeRec.Body.String()) + } + + var finalized popupResponse + if err := json.Unmarshal(finalizeRec.Body.Bytes(), &finalized); err != nil { + t.Fatalf("decode finalized popup: %v", err) + } + + if finalized.ImageKey == nil || *finalized.ImageKey != key { + t.Fatalf("unexpected finalized popup: %+v", finalized) + } + + activateCtx, activateRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID), []byte(`{"is_active":true}`)) + activateCtx.Params = append(activateCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminUpdatePopup(activateCtx) + if activateRec.Code != http.StatusOK { + t.Fatalf("activate status %d: %s", activateRec.Code, activateRec.Body.String()) + } + + activeCtx, activeRec = newJSONContext(t, http.MethodGet, "/api/popups/active", nil) + env.handler.ListActivePopups(activeCtx) + if activeRec.Code != http.StatusOK { + t.Fatalf("active status %d: %s", activeRec.Code, activeRec.Body.String()) + } + + var activeResp popupsResponse + if err := json.Unmarshal(activeRec.Body.Bytes(), &activeResp); err != nil { + t.Fatalf("decode active popups: %v", err) + } + + if len(activeResp.Popups) != 1 || activeResp.Popups[0].ID != created.ID { + t.Fatalf("unexpected active popups: %+v", activeResp) + } + + updateCtx, updateRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID), []byte(`{"title":"Updated Notice","link_url":"https://example.com/updated","is_active":false}`)) + updateCtx.Params = append(updateCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminUpdatePopup(updateCtx) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status %d: %s", updateRec.Code, updateRec.Body.String()) + } + + var updated popupResponse + if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil { + t.Fatalf("decode updated popup: %v", err) + } + + if updated.Title != "Updated Notice" || updated.LinkURL == nil || *updated.LinkURL != "https://example.com/updated" || updated.IsActive { + t.Fatalf("unexpected updated popup: %+v", updated) + } + + deleteImageCtx, deleteImageRec := newJSONContext(t, http.MethodDelete, "/api/admin/popups/"+toStringID(created.ID)+"/image", nil) + deleteImageCtx.Params = append(deleteImageCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminDeletePopupImage(deleteImageCtx) + if deleteImageRec.Code != http.StatusOK { + t.Fatalf("delete image status %d: %s", deleteImageRec.Code, deleteImageRec.Body.String()) + } + + deleteCtx, deleteRec := newJSONContext(t, http.MethodDelete, "/api/admin/popups/"+toStringID(created.ID), nil) + deleteCtx.Params = append(deleteCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminDeletePopup(deleteCtx) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status %d: %s", deleteRec.Code, deleteRec.Body.String()) + } +} + +func TestHandlerPopupErrors(t *testing.T) { + env := setupHandlerTest(t) + admin := createHandlerUser(t, env, "admin@example.com", "admin", "pass", models.AdminRole) + + blankCtx, blankRec := newJSONContext(t, http.MethodPost, "/api/admin/popups", []byte(`{"title":" "}`)) + blankCtx.Set("userID", admin.ID) + env.handler.AdminCreatePopup(blankCtx) + if blankRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for blank title, got %d", blankRec.Code) + } + + activeCreateCtx, activeCreateRec := newJSONContext(t, http.MethodPost, "/api/admin/popups", []byte(`{"title":"Notice","is_active":true}`)) + activeCreateCtx.Set("userID", admin.ID) + env.handler.AdminCreatePopup(activeCreateCtx) + if activeCreateRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for active create without image, got %d", activeCreateRec.Code) + } + + linkCreateCtx, linkCreateRec := newJSONContext(t, http.MethodPost, "/api/admin/popups", []byte(`{"title":"Notice","link_url":"ftp://example.com/file"}`)) + linkCreateCtx.Set("userID", admin.ID) + env.handler.AdminCreatePopup(linkCreateCtx) + if linkCreateRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid link, got %d", linkCreateRec.Code) + } + + createCtx, createRec := newJSONContext(t, http.MethodPost, "/api/admin/popups", []byte(`{"title":123}`)) + createCtx.Set("userID", admin.ID) + env.handler.AdminCreatePopup(createCtx) + if createRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid create body, got %d", createRec.Code) + } + + validCtx, validRec := newJSONContext(t, http.MethodPost, "/api/admin/popups", []byte(`{"title":"Notice"}`)) + validCtx.Set("userID", admin.ID) + env.handler.AdminCreatePopup(validCtx) + if validRec.Code != http.StatusCreated { + t.Fatalf("expected created popup, got %d: %s", validRec.Code, validRec.Body.String()) + } + + var created popupResponse + if err := json.Unmarshal(validRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode created popup: %v", err) + } + + updateCtx, updateRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/bad", []byte(`{"title":"x"}`)) + env.handler.AdminUpdatePopup(updateCtx) + if updateRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid id, got %d", updateRec.Code) + } + + updateBodyCtx, updateBodyRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID), []byte(`{"title":123}`)) + updateBodyCtx.Params = append(updateBodyCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminUpdatePopup(updateBodyCtx) + if updateBodyRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid update body, got %d", updateBodyRec.Code) + } + + updateLinkCtx, updateLinkRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID), []byte(`{"link_url":"javascript:alert(1)"}`)) + updateLinkCtx.Params = append(updateLinkCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminUpdatePopup(updateLinkCtx) + if updateLinkRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid update link, got %d", updateLinkRec.Code) + } + + updateMissingCtx, updateMissingRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/999999", []byte(`{"title":"x"}`)) + updateMissingCtx.Params = append(updateMissingCtx.Params, ginParam("id", "999999")) + env.handler.AdminUpdatePopup(updateMissingCtx) + if updateMissingRec.Code != http.StatusNotFound { + t.Fatalf("expected not found for update, got %d", updateMissingRec.Code) + } + + deleteInvalidCtx, deleteInvalidRec := newJSONContext(t, http.MethodDelete, "/api/admin/popups/bad", nil) + env.handler.AdminDeletePopup(deleteInvalidCtx) + if deleteInvalidRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid delete id, got %d", deleteInvalidRec.Code) + } + + deleteMissingCtx, deleteMissingRec := newJSONContext(t, http.MethodDelete, "/api/admin/popups/999999", nil) + deleteMissingCtx.Params = append(deleteMissingCtx.Params, ginParam("id", "999999")) + env.handler.AdminDeletePopup(deleteMissingCtx) + if deleteMissingRec.Code != http.StatusNotFound { + t.Fatalf("expected not found for delete, got %d", deleteMissingRec.Code) + } + + uploadInvalidIDCtx, uploadInvalidIDRec := newJSONContext(t, http.MethodPost, "/api/admin/popups/bad/image/upload", []byte(`{"filename":"notice.png"}`)) + env.handler.AdminRequestPopupImageUpload(uploadInvalidIDCtx) + if uploadInvalidIDRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid upload id, got %d", uploadInvalidIDRec.Code) + } + + uploadBodyCtx, uploadBodyRec := newJSONContext(t, http.MethodPost, "/api/admin/popups/"+toStringID(created.ID)+"/image/upload", []byte(`{"filename":123}`)) + uploadBodyCtx.Params = append(uploadBodyCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminRequestPopupImageUpload(uploadBodyCtx) + if uploadBodyRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid upload body, got %d", uploadBodyRec.Code) + } + + uploadExtCtx, uploadExtRec := newJSONContext(t, http.MethodPost, "/api/admin/popups/"+toStringID(created.ID)+"/image/upload", []byte(`{"filename":"notice.gif"}`)) + uploadExtCtx.Params = append(uploadExtCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminRequestPopupImageUpload(uploadExtCtx) + if uploadExtRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid upload extension, got %d", uploadExtRec.Code) + } + + uploadCtx, uploadRec := newJSONContext(t, http.MethodPost, "/api/admin/popups/999999/image/upload", []byte(`{"filename":"notice.gif"}`)) + uploadCtx.Params = append(uploadCtx.Params, ginParam("id", "999999")) + env.handler.AdminRequestPopupImageUpload(uploadCtx) + if uploadRec.Code != http.StatusNotFound { + t.Fatalf("expected not found before filename validation, got %d body=%s", uploadRec.Code, uploadRec.Body.String()) + } + + finalizeInvalidIDCtx, finalizeInvalidIDRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/bad/image", []byte(`{"key":"popups/x.png","filename":"x.png"}`)) + env.handler.AdminFinalizePopupImageUpload(finalizeInvalidIDCtx) + if finalizeInvalidIDRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid finalize id, got %d", finalizeInvalidIDRec.Code) + } + + finalizeBodyCtx, finalizeBodyRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID)+"/image", []byte(`{"key":123}`)) + finalizeBodyCtx.Params = append(finalizeBodyCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminFinalizePopupImageUpload(finalizeBodyCtx) + if finalizeBodyRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid finalize body, got %d", finalizeBodyRec.Code) + } + + finalizeKeyCtx, finalizeKeyRec := newJSONContext(t, http.MethodPut, "/api/admin/popups/"+toStringID(created.ID)+"/image", []byte(`{"key":"profiles/x.png","filename":"x.png"}`)) + finalizeKeyCtx.Params = append(finalizeKeyCtx.Params, ginParam("id", toStringID(created.ID))) + env.handler.AdminFinalizePopupImageUpload(finalizeKeyCtx) + if finalizeKeyRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid finalize key, got %d", finalizeKeyRec.Code) + } + + deleteImageInvalidCtx, deleteImageInvalidRec := newJSONContext(t, http.MethodDelete, "/api/admin/popups/bad/image", nil) + env.handler.AdminDeletePopupImage(deleteImageInvalidCtx) + if deleteImageInvalidRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid delete image id, got %d", deleteImageInvalidRec.Code) + } + + deleteImageMissingCtx, deleteImageMissingRec := newJSONContext(t, http.MethodDelete, "/api/admin/popups/999999/image", nil) + deleteImageMissingCtx.Params = append(deleteImageMissingCtx.Params, ginParam("id", "999999")) + env.handler.AdminDeletePopupImage(deleteImageMissingCtx) + if deleteImageMissingRec.Code != http.StatusNotFound { + t.Fatalf("expected not found for delete image, got %d", deleteImageMissingRec.Code) + } +} + func TestHandlerSubmitFlagFlow(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "flag-user@example.com", "flag-user", "pass", models.UserRole) @@ -848,7 +1130,7 @@ func TestHandlerStackEndpoints(t *testing.T) { env := setupHandlerTest(t) provisioner := stackpkg.NewProvisionerMock() env.stackSvc = service.NewStackService(env.cfg.Stack, env.stackRepo, env.challengeRepo, env.submissionRepo, provisioner.Client(), env.redis) - env.handler = New(env.cfg, env.authSvc, env.wargameSvc, env.userSvc, env.affiliationSvc, env.scoreSvc, env.stackSvc, env.redis) + env.handler = New(env.cfg, env.authSvc, env.wargameSvc, env.userSvc, env.affiliationSvc, env.scoreSvc, env.stackSvc, env.redis, nil, env.popupSvc) user := createHandlerUser(t, env, "stack-user@example.com", "stack-user", "pass", models.UserRole) challenge := createHandlerChallenge(t, env, "Stack Target", 100, "FLAG{STACK}", true) diff --git a/internal/http/handlers/testenv_test.go b/internal/http/handlers/testenv_test.go index 3be2580..3d5fcbe 100644 --- a/internal/http/handlers/testenv_test.go +++ b/internal/http/handlers/testenv_test.go @@ -35,11 +35,13 @@ type handlerEnv struct { challengeRepo *repo.ChallengeRepo submissionRepo *repo.SubmissionRepo stackRepo *repo.StackRepo + popupRepo *repo.PopupRepo authSvc *service.AuthService userSvc *service.UserService affiliationSvc *service.AffiliationService scoreSvc *service.ScoreboardService wargameSvc *service.WargameService + popupSvc *service.PopupService stackSvc *service.StackService handler *Handler } @@ -203,17 +205,20 @@ func setupHandlerTest(t *testing.T) handlerEnv { writeupRepo := repo.NewWriteupRepo(handlerDB) scoreRepo := repo.NewScoreboardRepo(handlerDB) stackRepo := repo.NewStackRepo(handlerDB) + popupRepo := repo.NewPopupRepo(handlerDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) + mediaStore := storage.NewMemoryMediaFileStore(10 * time.Minute) - userSvc := service.NewUserService(userRepo, affiliationRepo, storage.NewMemoryProfileImageStore(10*time.Minute)) + userSvc := service.NewUserService(userRepo, affiliationRepo, mediaStore) + popupSvc := service.NewPopupService(popupRepo, mediaStore) affiliationSvc := service.NewAffiliationService(affiliationRepo) authSvc := service.NewAuthService(handlerCfg, userRepo, handlerRedis) scoreSvc := service.NewScoreboardService(scoreRepo) wargameSvc := service.NewWargameService(handlerCfg, challengeRepo, submissionRepo, voteRepo, writeupRepo, repo.NewChallengeCommentRepo(handlerDB), repo.NewCommunityRepo(handlerDB), handlerRedis, fileStore, repo.NewChallengeSeriesRepo(handlerDB)) stackSvc := service.NewStackService(handlerCfg.Stack, stackRepo, challengeRepo, submissionRepo, &stack.MockClient{}, handlerRedis) - handler := New(handlerCfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, handlerRedis) + handler := New(handlerCfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, handlerRedis, nil, popupSvc) env := handlerEnv{ cfg: handlerCfg, @@ -223,11 +228,13 @@ func setupHandlerTest(t *testing.T) handlerEnv { challengeRepo: challengeRepo, submissionRepo: submissionRepo, stackRepo: stackRepo, + popupRepo: popupRepo, authSvc: authSvc, userSvc: userSvc, affiliationSvc: affiliationSvc, scoreSvc: scoreSvc, wargameSvc: wargameSvc, + popupSvc: popupSvc, stackSvc: stackSvc, handler: handler, } @@ -238,7 +245,7 @@ func setupHandlerTest(t *testing.T) handlerEnv { func resetHandlerState(t *testing.T) { t.Helper() - if _, err := handlerDB.ExecContext(context.Background(), "TRUNCATE TABLE challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { + if _, err := handlerDB.ExecContext(context.Background(), "TRUNCATE TABLE popups, challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } diff --git a/internal/http/handlers/types.go b/internal/http/handlers/types.go index a383f2e..a888023 100644 --- a/internal/http/handlers/types.go +++ b/internal/http/handlers/types.go @@ -65,6 +65,27 @@ type adminAffiliationCreateRequest struct { Name string `json:"name" binding:"required"` } +type createPopupRequest struct { + Title string `json:"title" binding:"required"` + LinkURL *string `json:"link_url"` + IsActive *bool `json:"is_active"` +} + +type updatePopupRequest struct { + Title optionalString `json:"title"` + LinkURL optionalString `json:"link_url"` + IsActive *bool `json:"is_active"` +} + +type popupImageUploadRequest struct { + Filename string `json:"filename" binding:"required"` +} + +type popupImageFinalizeRequest struct { + Key string `json:"key" binding:"required"` + Filename string `json:"filename" binding:"required"` +} + type createChallengeSeriesRequest struct { Title string `json:"title" binding:"required"` Description string `json:"description" binding:"required"` @@ -503,6 +524,27 @@ type profileImageUploadResponse struct { Upload presignedUploadResponse `json:"upload"` } +type popupResponse struct { + ID int64 `json:"id"` + Title string `json:"title"` + ImageKey *string `json:"image_key"` + ImageName *string `json:"image_name"` + LinkURL *string `json:"link_url"` + IsActive bool `json:"is_active"` + CreatedByUserID *int64 `json:"created_by_user_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type popupsResponse struct { + Popups []popupResponse `json:"popups"` +} + +type popupImageUploadResponse struct { + Popup popupResponse `json:"popup"` + Upload presignedUploadResponse `json:"upload"` +} + type timelineResponse struct { Submissions []models.TimelineSubmission `json:"submissions"` } @@ -893,3 +935,26 @@ func newCommunityPostLikeResponse(row models.CommunityPostLikeDetail) communityP CreatedAt: row.CreatedAt.UTC(), } } + +func newPopupResponse(row models.Popup) popupResponse { + return popupResponse{ + ID: row.ID, + Title: row.Title, + ImageKey: row.ImageKey, + ImageName: row.ImageName, + LinkURL: row.LinkURL, + IsActive: row.IsActive, + CreatedByUserID: row.CreatedByUserID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func newPopupsResponse(rows []models.Popup) popupsResponse { + resp := make([]popupResponse, 0, len(rows)) + for _, row := range rows { + resp = append(resp, newPopupResponse(row)) + } + + return popupsResponse{Popups: resp} +} diff --git a/internal/http/integration/community_test.go b/internal/http/integration/community_test.go index c29cac4..5f33ac5 100644 --- a/internal/http/integration/community_test.go +++ b/internal/http/integration/community_test.go @@ -174,7 +174,7 @@ func TestCommunityIntegrationFlow(t *testing.T) { t.Fatalf("expected comment_count 1, got %+v", authedDetail) } - for i := 0; i < models.PopularPostLikeThreshold-1; i += 1 { + for i := range models.PopularPostLikeThreshold - 1 { u := createUser(t, env, "popular-http-"+itoa(int64(i))+"@example.com", "popular-http-"+itoa(int64(i)), "pass", models.UserRole) access, _, _ := loginUser(t, env.router, u.Email, "pass") rec = doRequest(t, env.router, http.MethodPost, "/api/community/"+itoa(notice.ID)+"/likes", nil, authHeader(access)) diff --git a/internal/http/integration/popups_test.go b/internal/http/integration/popups_test.go new file mode 100644 index 0000000..b3bbc65 --- /dev/null +++ b/internal/http/integration/popups_test.go @@ -0,0 +1,161 @@ +package http_test + +import ( + "context" + "net/http" + "testing" + "time" + + "wargame/internal/models" +) + +func TestPopupPublicAndAdminEndpoints(t *testing.T) { + env := setupTest(t, testCfg) + _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) + adminAccess, _, _ := loginUser(t, env.router, "admin@example.com", "adminpass") + + rec := doRequest(t, env.router, http.MethodGet, "/api/popups/active", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("active empty status %d: %s", rec.Code, rec.Body.String()) + } + + var activeEmpty struct { + Popups []struct { + ID int64 `json:"id"` + } `json:"popups"` + } + decodeJSON(t, rec, &activeEmpty) + if len(activeEmpty.Popups) != 0 { + t.Fatalf("expected no active popups, got %+v", activeEmpty) + } + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": "Notice 1", "is_active": true}, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized create, got %d: %s", rec.Code, rec.Body.String()) + } + + userAccess, _, _ := registerAndLogin(t, env, "user@example.com", "user", "strong-password") + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": "Notice 1", "is_active": true}, authHeader(userAccess)) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected forbidden create, got %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": "Notice 1", "is_active": true}, authHeader(adminAccess)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected active create without image to fail, got %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": "Notice 1", "link_url": "https://example.com/notice-1", "is_active": false}, authHeader(adminAccess)) + if rec.Code != http.StatusCreated { + t.Fatalf("create status %d: %s", rec.Code, rec.Body.String()) + } + + var created1 struct { + ID int64 `json:"id"` + Title string `json:"title"` + LinkURL *string `json:"link_url"` + IsActive bool `json:"is_active"` + } + decodeJSON(t, rec, &created1) + if created1.ID <= 0 || created1.Title != "Notice 1" || created1.LinkURL == nil || *created1.LinkURL != "https://example.com/notice-1" || created1.IsActive { + t.Fatalf("unexpected created popup: %+v", created1) + } + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": "Notice 2", "is_active": false}, authHeader(adminAccess)) + if rec.Code != http.StatusCreated { + t.Fatalf("create second status %d: %s", rec.Code, rec.Body.String()) + } + + var created2 struct { + ID int64 `json:"id"` + } + decodeJSON(t, rec, &created2) + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups/"+itoa(created2.ID)+"/image/upload", map[string]string{"filename": "notice.webp"}, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String()) + } + + var uploadResp struct { + Upload struct { + Fields map[string]string `json:"fields"` + } `json:"upload"` + } + decodeJSON(t, rec, &uploadResp) + key2 := uploadResp.Upload.Fields["key"] + if key2 == "" { + t.Fatalf("expected upload key, got %+v", uploadResp) + } + + rec = doRequest(t, env.router, http.MethodPut, "/api/admin/popups/"+itoa(created2.ID)+"/image", map[string]string{"key": key2, "filename": "notice.webp"}, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("finalize status %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodPut, "/api/admin/popups/"+itoa(created2.ID), map[string]any{"is_active": true}, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("activate status %d: %s", rec.Code, rec.Body.String()) + } + + key1 := "popups/manual.png" + name1 := "manual.png" + row1, err := env.popupRepo.GetByID(context.Background(), created1.ID) + if err != nil { + t.Fatalf("get popup1: %v", err) + } + + row1.ImageKey = &key1 + row1.ImageName = &name1 + row1.IsActive = true + row1.CreatedAt = time.Now().UTC().Add(-time.Hour) + if err := env.popupRepo.Update(context.Background(), row1); err != nil { + t.Fatalf("update popup1 image: %v", err) + } + + rec = doRequest(t, env.router, http.MethodGet, "/api/popups/active", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("active status %d: %s", rec.Code, rec.Body.String()) + } + + var activeResp struct { + Popups []struct { + ID int64 `json:"id"` + ImageKey *string `json:"image_key"` + } `json:"popups"` + } + decodeJSON(t, rec, &activeResp) + if len(activeResp.Popups) != 2 || activeResp.Popups[0].ID != created2.ID || activeResp.Popups[1].ID != created1.ID { + t.Fatalf("expected active popups newest first, got %+v", activeResp.Popups) + } + + rec = doRequest(t, env.router, http.MethodPut, "/api/admin/popups/"+itoa(created1.ID), map[string]any{"title": "Updated", "link_url": "https://example.com/updated", "is_active": false}, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("update status %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/popups/"+itoa(created2.ID), nil, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("delete status %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestPopupAdminValidation(t *testing.T) { + env := setupTest(t, testCfg) + _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) + adminAccess, _, _ := loginUser(t, env.router, "admin@example.com", "adminpass") + + rec := doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": " "}, authHeader(adminAccess)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for blank title, got %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups", map[string]any{"title": "Notice", "link_url": "ftp://example.com"}, authHeader(adminAccess)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for invalid link, got %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodPost, "/api/admin/popups/999999/image/upload", map[string]string{"filename": "notice.gif"}, authHeader(adminAccess)) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected not found for missing popup, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/http/integration/testenv_test.go b/internal/http/integration/testenv_test.go index 3303d6b..77d9e68 100644 --- a/internal/http/integration/testenv_test.go +++ b/internal/http/integration/testenv_test.go @@ -44,8 +44,10 @@ type testEnv struct { challengeRepo *repo.ChallengeRepo submissionRepo *repo.SubmissionRepo stackRepo *repo.StackRepo + popupRepo *repo.PopupRepo authSvc *service.AuthService wargameSvc *service.WargameService + popupSvc *service.PopupService stackSvc *service.StackService vmSvc *service.VMService } @@ -245,18 +247,21 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { writeupRepo := repo.NewWriteupRepo(testDB) scoreRepo := repo.NewScoreboardRepo(testDB) stackRepo := repo.NewStackRepo(testDB) + popupRepo := repo.NewPopupRepo(testDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) + mediaStore := storage.NewMemoryMediaFileStore(10 * time.Minute) authSvc := service.NewAuthService(cfg, userRepo, testRedis) - userSvc := service.NewUserService(userRepo, affiliationRepo, storage.NewMemoryProfileImageStore(10*time.Minute)) + userSvc := service.NewUserService(userRepo, affiliationRepo, mediaStore) + popupSvc := service.NewPopupService(popupRepo, mediaStore) affiliationSvc := service.NewAffiliationService(affiliationRepo) scoreSvc := service.NewScoreboardService(scoreRepo) wargameSvc := service.NewWargameService(cfg, challengeRepo, submissionRepo, voteRepo, writeupRepo, repo.NewChallengeCommentRepo(testDB), repo.NewCommunityRepo(testDB), testRedis, fileStore, repo.NewChallengeSeriesRepo(testDB)) stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, &stack.MockClient{}, testRedis) vmSvc := service.NewVMService(cfg.VM, repo.NewVMRepo(testDB), challengeRepo, submissionRepo, &vm.MockClient{}, testRedis) - router := apphttp.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, testRedis, testLogger) + router := apphttp.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, popupSvc, testRedis, testLogger) env := testEnv{ cfg: cfg, @@ -266,8 +271,10 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { challengeRepo: challengeRepo, submissionRepo: submissionRepo, stackRepo: stackRepo, + popupRepo: popupRepo, authSvc: authSvc, wargameSvc: wargameSvc, + popupSvc: popupSvc, stackSvc: stackSvc, vmSvc: vmSvc, } @@ -278,7 +285,7 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { func resetState(t *testing.T) { t.Helper() - if _, err := testDB.ExecContext(context.Background(), "TRUNCATE TABLE challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { + if _, err := testDB.ExecContext(context.Background(), "TRUNCATE TABLE popups, challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } @@ -603,18 +610,21 @@ func setupStackTest(t *testing.T, cfg config.Config, mockClient stack.API) testE writeupRepo := repo.NewWriteupRepo(testDB) scoreRepo := repo.NewScoreboardRepo(testDB) stackRepo := repo.NewStackRepo(testDB) + popupRepo := repo.NewPopupRepo(testDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) + mediaStore := storage.NewMemoryMediaFileStore(10 * time.Minute) authSvc := service.NewAuthService(cfg, userRepo, testRedis) - userSvc := service.NewUserService(userRepo, affiliationRepo, storage.NewMemoryProfileImageStore(10*time.Minute)) + userSvc := service.NewUserService(userRepo, affiliationRepo, mediaStore) + popupSvc := service.NewPopupService(popupRepo, mediaStore) affiliationSvc := service.NewAffiliationService(affiliationRepo) scoreSvc := service.NewScoreboardService(scoreRepo) wargameSvc := service.NewWargameService(cfg, challengeRepo, submissionRepo, voteRepo, writeupRepo, repo.NewChallengeCommentRepo(testDB), repo.NewCommunityRepo(testDB), testRedis, fileStore, repo.NewChallengeSeriesRepo(testDB)) stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, mockClient, testRedis) vmSvc := service.NewVMService(cfg.VM, repo.NewVMRepo(testDB), challengeRepo, submissionRepo, &vm.MockClient{}, testRedis) - router := apphttp.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, testRedis, testLogger) + router := apphttp.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, popupSvc, testRedis, testLogger) return testEnv{ cfg: cfg, @@ -624,8 +634,10 @@ func setupStackTest(t *testing.T, cfg config.Config, mockClient stack.API) testE challengeRepo: challengeRepo, submissionRepo: submissionRepo, stackRepo: stackRepo, + popupRepo: popupRepo, authSvc: authSvc, wargameSvc: wargameSvc, + popupSvc: popupSvc, stackSvc: stackSvc, vmSvc: vmSvc, } @@ -644,18 +656,21 @@ func setupVMTest(t *testing.T, cfg config.Config, mockClient vm.API) testEnv { writeupRepo := repo.NewWriteupRepo(testDB) scoreRepo := repo.NewScoreboardRepo(testDB) stackRepo := repo.NewStackRepo(testDB) + popupRepo := repo.NewPopupRepo(testDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) + mediaStore := storage.NewMemoryMediaFileStore(10 * time.Minute) authSvc := service.NewAuthService(cfg, userRepo, testRedis) - userSvc := service.NewUserService(userRepo, affiliationRepo, storage.NewMemoryProfileImageStore(10*time.Minute)) + userSvc := service.NewUserService(userRepo, affiliationRepo, mediaStore) + popupSvc := service.NewPopupService(popupRepo, mediaStore) affiliationSvc := service.NewAffiliationService(affiliationRepo) scoreSvc := service.NewScoreboardService(scoreRepo) wargameSvc := service.NewWargameService(cfg, challengeRepo, submissionRepo, voteRepo, writeupRepo, repo.NewChallengeCommentRepo(testDB), repo.NewCommunityRepo(testDB), testRedis, fileStore, repo.NewChallengeSeriesRepo(testDB)) stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, &stack.MockClient{}, testRedis) vmSvc := service.NewVMService(cfg.VM, repo.NewVMRepo(testDB), challengeRepo, submissionRepo, mockClient, testRedis) - router := apphttp.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, testRedis, testLogger) + router := apphttp.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, popupSvc, testRedis, testLogger) return testEnv{ cfg: cfg, @@ -665,8 +680,10 @@ func setupVMTest(t *testing.T, cfg config.Config, mockClient vm.API) testEnv { challengeRepo: challengeRepo, submissionRepo: submissionRepo, stackRepo: stackRepo, + popupRepo: popupRepo, authSvc: authSvc, wargameSvc: wargameSvc, + popupSvc: popupSvc, stackSvc: stackSvc, vmSvc: vmSvc, } diff --git a/internal/http/router.go b/internal/http/router.go index f3686e3..0d4543d 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -15,7 +15,7 @@ import ( "github.com/redis/go-redis/v9" ) -func NewRouter(cfg config.Config, authSvc *service.AuthService, wargameSvc *service.WargameService, userSvc *service.UserService, affiliationSvc *service.AffiliationService, scoreSvc *service.ScoreboardService, stackSvc *service.StackService, vmSvc *service.VMService, redis *redis.Client, logger *logging.Logger) *gin.Engine { +func NewRouter(cfg config.Config, authSvc *service.AuthService, wargameSvc *service.WargameService, userSvc *service.UserService, affiliationSvc *service.AffiliationService, scoreSvc *service.ScoreboardService, stackSvc *service.StackService, vmSvc *service.VMService, popupSvc *service.PopupService, redis *redis.Client, logger *logging.Logger) *gin.Engine { if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } @@ -26,7 +26,7 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, wargameSvc *serv r.Use(middleware.CORS(cfg.AppEnv == "local", cfg.CORS.AllowedOrigins)) r.Use(middleware.CSRF()) - h := handlers.New(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, redis, vmSvc) + h := handlers.New(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, redis, vmSvc, popupSvc) r.GET("/healthz", func(ctx *gin.Context) { ctx.JSON(nethttp.StatusOK, gin.H{"status": "ok"}) }) r.GET("/metrics", gin.WrapH(promhttp.Handler())) @@ -65,6 +65,7 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, wargameSvc *serv api.GET("/community/:id", middleware.OptionalAuth(cfg.JWT), h.GetCommunityPost) api.GET("/community/:id/likes", h.CommunityPostLikes) api.GET("/community/:id/comments", h.CommunityComments) + api.GET("/popups/active", h.ListActivePopups) auth := api.Group("") auth.Use(middleware.Auth(cfg.JWT)) @@ -125,6 +126,13 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, wargameSvc *serv admin.POST("/users/:id/block", h.AdminBlockUser) admin.POST("/users/:id/unblock", h.AdminUnblockUser) admin.POST("/affiliations", h.AdminCreateAffiliation) + admin.GET("/popups", h.AdminListPopups) + admin.POST("/popups", h.AdminCreatePopup) + admin.PUT("/popups/:id", h.AdminUpdatePopup) + admin.DELETE("/popups/:id", h.AdminDeletePopup) + admin.POST("/popups/:id/image/upload", h.AdminRequestPopupImageUpload) + admin.PUT("/popups/:id/image", h.AdminFinalizePopupImageUpload) + admin.DELETE("/popups/:id/image", h.AdminDeletePopupImage) } return r diff --git a/internal/models/popup.go b/internal/models/popup.go new file mode 100644 index 0000000..e8fb3c5 --- /dev/null +++ b/internal/models/popup.go @@ -0,0 +1,21 @@ +package models + +import ( + "time" + + "github.com/uptrace/bun" +) + +// Database model for site popups. +type Popup struct { + bun.BaseModel `bun:"table:popups"` + ID int64 `bun:"id,pk,autoincrement"` + Title string `bun:"title,notnull"` + ImageKey *string `bun:"image_key,nullzero"` + ImageName *string `bun:"image_name,nullzero"` + LinkURL *string `bun:"link_url,nullzero"` + IsActive bool `bun:"is_active,notnull,default:false"` + CreatedByUserID *int64 `bun:"created_by_user_id,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` +} diff --git a/internal/repo/community_repo_test.go b/internal/repo/community_repo_test.go index 2c690f2..cda40ea 100644 --- a/internal/repo/community_repo_test.go +++ b/internal/repo/community_repo_test.go @@ -173,7 +173,7 @@ func TestCommunityRepoAdditionalBranches(t *testing.T) { t.Fatalf("create new: %v", err) } - for i := 0; i < models.PopularPostLikeThreshold; i += 1 { + for i := range models.PopularPostLikeThreshold { u := createUser(t, env, "extra-like-"+itoa(i)+"@example.com", "extra-like-"+itoa(i), "pass", models.UserRole) if err := repo.CreateLike(context.Background(), newPost.ID, u.ID); err != nil { t.Fatalf("seed like: %v", err) diff --git a/internal/repo/popup_repo.go b/internal/repo/popup_repo.go new file mode 100644 index 0000000..3846c26 --- /dev/null +++ b/internal/repo/popup_repo.go @@ -0,0 +1,79 @@ +package repo + +import ( + "context" + + "wargame/internal/models" + + "github.com/uptrace/bun" +) + +type PopupRepo struct { + db *bun.DB +} + +func NewPopupRepo(db *bun.DB) *PopupRepo { + return &PopupRepo{db: db} +} + +func (r *PopupRepo) Create(ctx context.Context, popup *models.Popup) error { + if _, err := r.db.NewInsert().Model(popup).Exec(ctx); err != nil { + return wrapError("popupRepo.Create", err) + } + + return nil +} + +func (r *PopupRepo) GetByID(ctx context.Context, id int64) (*models.Popup, error) { + popup := new(models.Popup) + if err := r.db.NewSelect(). + Model(popup). + Where("id = ?", id). + Scan(ctx); err != nil { + return nil, wrapNotFound("popupRepo.GetByID", err) + } + + return popup, nil +} + +func (r *PopupRepo) List(ctx context.Context) ([]models.Popup, error) { + rows := make([]models.Popup, 0) + if err := r.db.NewSelect(). + Model(&rows). + OrderExpr("created_at DESC, id DESC"). + Scan(ctx); err != nil { + return nil, wrapError("popupRepo.List", err) + } + + return rows, nil +} + +func (r *PopupRepo) ListActiveWithImages(ctx context.Context) ([]models.Popup, error) { + rows := make([]models.Popup, 0) + if err := r.db.NewSelect(). + Model(&rows). + Where("is_active = true"). + Where("image_key IS NOT NULL"). + OrderExpr("created_at DESC, id DESC"). + Scan(ctx); err != nil { + return nil, wrapError("popupRepo.ListActiveWithImages", err) + } + + return rows, nil +} + +func (r *PopupRepo) Update(ctx context.Context, popup *models.Popup) error { + if _, err := r.db.NewUpdate().Model(popup).WherePK().Exec(ctx); err != nil { + return wrapError("popupRepo.Update", err) + } + + return nil +} + +func (r *PopupRepo) Delete(ctx context.Context, popup *models.Popup) error { + if _, err := r.db.NewDelete().Model(popup).WherePK().Exec(ctx); err != nil { + return wrapError("popupRepo.Delete", err) + } + + return nil +} diff --git a/internal/repo/popup_repo_test.go b/internal/repo/popup_repo_test.go new file mode 100644 index 0000000..e5c2b77 --- /dev/null +++ b/internal/repo/popup_repo_test.go @@ -0,0 +1,85 @@ +package repo + +import ( + "context" + "errors" + "testing" + "time" + + "wargame/internal/models" +) + +func TestPopupRepoCRUDAndActiveList(t *testing.T) { + env := setupRepoTest(t) + repo := NewPopupRepo(env.db) + user := createUser(t, env, "admin@example.com", "admin", "pass", models.AdminRole) + + oldTime := time.Now().UTC().Add(-time.Hour) + key := "popups/first.png" + name := "first.png" + link := "https://example.com/first" + inactiveKey := "popups/inactive.png" + rows := []*models.Popup{ + {Title: "First", ImageKey: &key, ImageName: &name, LinkURL: &link, IsActive: true, CreatedByUserID: &user.ID, CreatedAt: oldTime, UpdatedAt: oldTime}, + {Title: "Draft", IsActive: false, CreatedByUserID: &user.ID, CreatedAt: oldTime.Add(time.Minute), UpdatedAt: oldTime.Add(time.Minute)}, + {Title: "Inactive", ImageKey: &inactiveKey, IsActive: false, CreatedByUserID: &user.ID, CreatedAt: oldTime.Add(2 * time.Minute), UpdatedAt: oldTime.Add(2 * time.Minute)}, + } + + for _, row := range rows { + if err := repo.Create(context.Background(), row); err != nil { + t.Fatalf("create popup: %v", err) + } + } + + got, err := repo.GetByID(context.Background(), rows[0].ID) + if err != nil { + t.Fatalf("get popup: %v", err) + } + + if got.Title != "First" || got.ImageKey == nil || *got.ImageKey != key || got.LinkURL == nil || *got.LinkURL != link { + t.Fatalf("unexpected popup: %+v", got) + } + + all, err := repo.List(context.Background()) + if err != nil { + t.Fatalf("list popups: %v", err) + } + + if len(all) != 3 || all[0].Title != "Inactive" || all[2].Title != "First" { + t.Fatalf("expected newest first list, got %+v", all) + } + + latestKey := "popups/latest.webp" + rows[1].ImageKey = &latestKey + rows[1].IsActive = true + rows[1].UpdatedAt = time.Now().UTC() + if err := repo.Update(context.Background(), rows[1]); err != nil { + t.Fatalf("update popup: %v", err) + } + + active, err := repo.ListActiveWithImages(context.Background()) + if err != nil { + t.Fatalf("list active popups: %v", err) + } + + if len(active) != 2 || active[0].Title != "Draft" || active[1].Title != "First" { + t.Fatalf("expected active popups with images newest first, got %+v", active) + } + + if err := repo.Delete(context.Background(), rows[0]); err != nil { + t.Fatalf("delete popup: %v", err) + } + + if _, err := repo.GetByID(context.Background(), rows[0].ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected not found after delete, got %v", err) + } +} + +func TestPopupRepoGetNotFound(t *testing.T) { + env := setupRepoTest(t) + repo := NewPopupRepo(env.db) + + if _, err := repo.GetByID(context.Background(), 99999); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected not found, got %v", err) + } +} diff --git a/internal/repo/testenv_test.go b/internal/repo/testenv_test.go index 58de33b..418cddf 100644 --- a/internal/repo/testenv_test.go +++ b/internal/repo/testenv_test.go @@ -155,7 +155,7 @@ func setupRepoTest(t *testing.T) repoEnv { func resetRepoState(t *testing.T) { t.Helper() - if _, err := repoDB.ExecContext(context.Background(), "TRUNCATE TABLE challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { + if _, err := repoDB.ExecContext(context.Background(), "TRUNCATE TABLE popups, challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } } diff --git a/internal/service/community_service_test.go b/internal/service/community_service_test.go index ca65d62..2ec44e0 100644 --- a/internal/service/community_service_test.go +++ b/internal/service/community_service_test.go @@ -91,7 +91,7 @@ func TestWargameServiceCommunityCRUDAndPolicies(t *testing.T) { t.Fatalf("toggle like off failed: liked=%v likeCount=%d err=%v", liked, likeCount, err) } - for i := 0; i < models.PopularPostLikeThreshold; i += 1 { + for i := range models.PopularPostLikeThreshold { u := createUser(t, env, "popular-like-"+toString(i)+"@example.com", "popular-like-"+toString(i), "pass", models.UserRole) if _, _, err := env.wargameSvc.ToggleCommunityPostLike(context.Background(), u.ID, notice.ID); err != nil { t.Fatalf("seed popular likes: %v", err) diff --git a/internal/service/errors.go b/internal/service/errors.go index 2a2b22a..32ef247 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -8,6 +8,7 @@ var ( ErrInvalidInput = errors.New("invalid input") ErrUserBlocked = errors.New("user blocked") ErrChallengeNotFound = errors.New("challenge not found") + ErrPopupNotFound = errors.New("popup not found") ErrChallengeSeriesNotFound = errors.New("challenge series not found") ErrChallengeSeriesExists = errors.New("challenge series already exists") ErrWriteupNotFound = errors.New("writeup not found") diff --git a/internal/service/popup_service.go b/internal/service/popup_service.go new file mode 100644 index 0000000..8474fc5 --- /dev/null +++ b/internal/service/popup_service.go @@ -0,0 +1,345 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/url" + "path/filepath" + "strings" + "time" + + "wargame/internal/models" + "wargame/internal/repo" + "wargame/internal/storage" + + "github.com/google/uuid" +) + +const maxPopupImageBytes int64 = 10 * 1024 * 1024 + +type PopupService struct { + repo *repo.PopupRepo + mediaStore storage.ProfileImageStore +} + +type PopupUpdate struct { + Title *string + TitleSet bool + LinkURL *string + LinkSet bool + IsActive *bool +} + +func NewPopupService(popupRepo *repo.PopupRepo, mediaStore storage.ProfileImageStore) *PopupService { + return &PopupService{repo: popupRepo, mediaStore: mediaStore} +} + +func (s *PopupService) List(ctx context.Context) ([]models.Popup, error) { + rows, err := s.repo.List(ctx) + if err != nil { + return nil, fmt.Errorf("popup.List: %w", err) + } + + return rows, nil +} + +func (s *PopupService) ListActive(ctx context.Context) ([]models.Popup, error) { + rows, err := s.repo.ListActiveWithImages(ctx) + if err != nil { + return nil, fmt.Errorf("popup.ListActive: %w", err) + } + + return rows, nil +} + +func (s *PopupService) Create(ctx context.Context, title string, linkURL *string, active bool, createdByUserID int64) (*models.Popup, error) { + title = normalizeTrim(title) + normalizedLink, linkErr := normalizePopupLinkURL(linkURL) + validator := newFieldValidator() + validator.Required("title", title) + validator.PositiveID("created_by_user_id", createdByUserID) + if linkErr != nil { + validator.fields = append(validator.fields, FieldError{Field: "link_url", Reason: "invalid"}) + } + + if active { + validator.fields = append(validator.fields, FieldError{Field: "is_active", Reason: "image required"}) + } + + if err := validator.Error(); err != nil { + return nil, err + } + + now := time.Now().UTC() + popup := &models.Popup{ + Title: title, + LinkURL: normalizedLink, + IsActive: active, + CreatedByUserID: &createdByUserID, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.repo.Create(ctx, popup); err != nil { + return nil, fmt.Errorf("popup.Create: %w", err) + } + + return popup, nil +} + +func (s *PopupService) GetByID(ctx context.Context, id int64) (*models.Popup, error) { + validator := newFieldValidator() + validator.PositiveID("id", id) + if err := validator.Error(); err != nil { + return nil, err + } + + popup, err := s.repo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, ErrPopupNotFound + } + + return nil, fmt.Errorf("popup.GetByID: %w", err) + } + + return popup, nil +} + +func (s *PopupService) Update(ctx context.Context, id int64, update PopupUpdate) (*models.Popup, error) { + popup, err := s.GetByID(ctx, id) + if err != nil { + return nil, err + } + + validator := newFieldValidator() + if update.TitleSet { + if update.Title == nil { + validator.fields = append(validator.fields, FieldError{Field: "title", Reason: "required"}) + } else { + title := normalizeTrim(*update.Title) + validator.Required("title", title) + popup.Title = title + } + } + + if update.LinkSet { + normalizedLink, err := normalizePopupLinkURL(update.LinkURL) + if err != nil { + validator.fields = append(validator.fields, FieldError{Field: "link_url", Reason: "invalid"}) + } else { + popup.LinkURL = normalizedLink + } + } + + if err := validator.Error(); err != nil { + return nil, err + } + + if update.IsActive != nil { + if *update.IsActive && popupImageKey(popup) == "" { + return nil, NewValidationError(FieldError{Field: "is_active", Reason: "image required"}) + } + + popup.IsActive = *update.IsActive + } + + popup.UpdatedAt = time.Now().UTC() + + if err := s.repo.Update(ctx, popup); err != nil { + return nil, fmt.Errorf("popup.Update: %w", err) + } + + return popup, nil +} + +func (s *PopupService) Delete(ctx context.Context, id int64) error { + popup, err := s.GetByID(ctx, id) + if err != nil { + return err + } + + oldKey := "" + if popup.ImageKey != nil { + oldKey = strings.TrimSpace(*popup.ImageKey) + } + + if err := s.repo.Delete(ctx, popup); err != nil { + return fmt.Errorf("popup.Delete: %w", err) + } + + if oldKey != "" && s.mediaStore != nil { + _ = s.mediaStore.Delete(ctx, oldKey) + } + + return nil +} + +func (s *PopupService) RequestImageUpload(ctx context.Context, id int64, filename string) (*models.Popup, storage.PresignedUpload, error) { + filename = normalizeTrim(filename) + validator := newFieldValidator() + validator.PositiveID("id", id) + validator.Required("filename", filename) + if err := validator.Error(); err != nil { + return nil, storage.PresignedUpload{}, err + } + + if s.mediaStore == nil { + return nil, storage.PresignedUpload{}, ErrStorageUnavailable + } + + popup, err := s.GetByID(ctx, id) + if err != nil { + return nil, storage.PresignedUpload{}, err + } + + ext := strings.ToLower(filepath.Ext(filename)) + contentType := popupImageContentType(ext) + if contentType == "" { + return nil, storage.PresignedUpload{}, NewValidationError(FieldError{Field: "filename", Reason: "must be a .png, .jpg, .jpeg, or .webp file"}) + } + + key := "popups/" + uuid.NewString() + ext + upload, err := s.mediaStore.PresignUpload(ctx, key, contentType, maxPopupImageBytes) + if err != nil { + return nil, storage.PresignedUpload{}, fmt.Errorf("popup.RequestImageUpload presign: %w", err) + } + + return popup, upload, nil +} + +func (s *PopupService) FinalizeImageUpload(ctx context.Context, id int64, key, filename string) (*models.Popup, error) { + validator := newFieldValidator() + validator.PositiveID("id", id) + validator.Required("key", normalizeTrim(key)) + validator.Required("filename", normalizeTrim(filename)) + if err := validator.Error(); err != nil { + return nil, err + } + + if s.mediaStore == nil { + return nil, ErrStorageUnavailable + } + + normalizedKey, err := normalizePopupImageKey(key) + if err != nil { + return nil, err + } + + filename = normalizeTrim(filename) + ext := strings.ToLower(filepath.Ext(filename)) + if popupImageContentType(ext) == "" { + return nil, NewValidationError(FieldError{Field: "filename", Reason: "must be a .png, .jpg, .jpeg, or .webp file"}) + } + + popup, err := s.GetByID(ctx, id) + if err != nil { + return nil, err + } + + oldKey := "" + if popup.ImageKey != nil { + oldKey = strings.TrimSpace(*popup.ImageKey) + } + + popup.ImageKey = &normalizedKey + popup.ImageName = &filename + popup.UpdatedAt = time.Now().UTC() + if err := s.repo.Update(ctx, popup); err != nil { + return nil, fmt.Errorf("popup.FinalizeImageUpload update: %w", err) + } + + if oldKey != "" && oldKey != normalizedKey { + _ = s.mediaStore.Delete(ctx, oldKey) + } + + return popup, nil +} + +func (s *PopupService) DeleteImage(ctx context.Context, id int64) (*models.Popup, error) { + if s.mediaStore == nil { + return nil, ErrStorageUnavailable + } + + popup, err := s.GetByID(ctx, id) + if err != nil { + return nil, err + } + + oldKey := "" + if popup.ImageKey != nil { + oldKey = strings.TrimSpace(*popup.ImageKey) + } + + popup.ImageKey = nil + popup.ImageName = nil + popup.IsActive = false + popup.UpdatedAt = time.Now().UTC() + if err := s.repo.Update(ctx, popup); err != nil { + return nil, fmt.Errorf("popup.DeleteImage update: %w", err) + } + + if oldKey != "" { + _ = s.mediaStore.Delete(ctx, oldKey) + } + + return popup, nil +} + +func normalizePopupLinkURL(linkURL *string) (*string, error) { + if linkURL == nil { + return nil, nil + } + + normalized := strings.TrimSpace(*linkURL) + if normalized == "" { + return nil, nil + } + + parsed, err := url.Parse(normalized) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, ErrInvalidInput + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, ErrInvalidInput + } + + return &normalized, nil +} + +func popupImageKey(popup *models.Popup) string { + if popup == nil || popup.ImageKey == nil { + return "" + } + + return strings.TrimSpace(*popup.ImageKey) +} + +func popupImageContentType(ext string) string { + switch ext { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + default: + return "" + } +} + +func normalizePopupImageKey(key string) (string, error) { + normalized := strings.TrimSpace(strings.TrimLeft(key, "/")) + if normalized == "" || strings.Contains(normalized, "..") || !strings.HasPrefix(normalized, "popups/") { + return "", NewValidationError(FieldError{Field: "key", Reason: "invalid"}) + } + + ext := strings.ToLower(filepath.Ext(normalized)) + if popupImageContentType(ext) == "" { + return "", NewValidationError(FieldError{Field: "key", Reason: "invalid"}) + } + + return normalized, nil +} diff --git a/internal/service/popup_service_test.go b/internal/service/popup_service_test.go new file mode 100644 index 0000000..092ff6d --- /dev/null +++ b/internal/service/popup_service_test.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "wargame/internal/models" + "wargame/internal/repo" + "wargame/internal/storage" +) + +func TestPopupServiceCreateListUpdateDelete(t *testing.T) { + env := setupServiceTest(t) + admin := createUser(t, env, "admin@example.com", "admin", "pass", models.AdminRole) + + if _, err := env.popupSvc.Create(context.Background(), " ", nil, true, 1); err == nil { + t.Fatalf("expected validation error for blank title") + } + + if _, err := env.popupSvc.Create(context.Background(), "Notice", nil, true, 0); err == nil { + t.Fatalf("expected validation error for invalid creator") + } + + if _, err := env.popupSvc.Create(context.Background(), "Notice", nil, true, admin.ID); err == nil { + t.Fatalf("expected validation error for active popup without image") + } + + badLink := "javascript:alert(1)" + if _, err := env.popupSvc.Create(context.Background(), "Notice", &badLink, false, admin.ID); err == nil { + t.Fatalf("expected validation error for invalid link") + } + + link := "https://example.com/notice" + first, err := env.popupSvc.Create(context.Background(), "First", &link, false, admin.ID) + if err != nil { + t.Fatalf("create first: %v", err) + } + + if first.LinkURL == nil || *first.LinkURL != link { + t.Fatalf("expected link to be stored, got %+v", first.LinkURL) + } + + second, err := env.popupSvc.Create(context.Background(), "Second", nil, false, admin.ID) + if err != nil { + t.Fatalf("create second: %v", err) + } + + rows, err := env.popupSvc.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + + if len(rows) != 2 || rows[0].Title != "Second" || rows[1].Title != "First" { + t.Fatalf("unexpected list order: %+v", rows) + } + + title := "Updated" + active := true + updated, err := env.popupSvc.Update(context.Background(), second.ID, PopupUpdate{Title: &title, TitleSet: true, IsActive: &active}) + if err == nil { + t.Fatalf("expected active update without image to fail") + } + + updated, err = env.popupSvc.Update(context.Background(), second.ID, PopupUpdate{Title: &title, TitleSet: true}) + if err != nil { + t.Fatalf("update title: %v", err) + } + + if updated.Title != "Updated" || updated.IsActive { + t.Fatalf("unexpected update: %+v", updated) + } + + if _, err := env.popupSvc.Update(context.Background(), second.ID, PopupUpdate{TitleSet: true}); err == nil { + t.Fatalf("expected validation error for null title") + } + + updatedLink := "http://example.com/updated" + updated, err = env.popupSvc.Update(context.Background(), second.ID, PopupUpdate{LinkURL: &updatedLink, LinkSet: true}) + if err != nil { + t.Fatalf("update link: %v", err) + } + + if updated.LinkURL == nil || *updated.LinkURL != updatedLink { + t.Fatalf("unexpected updated link: %+v", updated.LinkURL) + } + + if _, err := env.popupSvc.Update(context.Background(), second.ID, PopupUpdate{LinkURL: &badLink, LinkSet: true}); err == nil { + t.Fatalf("expected validation error for invalid update link") + } + + updated, err = env.popupSvc.Update(context.Background(), second.ID, PopupUpdate{LinkSet: true}) + if err != nil { + t.Fatalf("clear link: %v", err) + } + + if updated.LinkURL != nil { + t.Fatalf("expected link to be cleared, got %+v", updated.LinkURL) + } + + if err := env.popupSvc.Delete(context.Background(), first.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := env.popupSvc.GetByID(context.Background(), first.ID); !errors.Is(err, ErrPopupNotFound) { + t.Fatalf("expected popup not found, got %v", err) + } +} + +func TestPopupServiceImageUploadAndActiveList(t *testing.T) { + env := setupServiceTest(t) + admin := createUser(t, env, "admin@example.com", "admin", "pass", models.AdminRole) + popup, err := env.popupSvc.Create(context.Background(), "Notice", nil, false, admin.ID) + if err != nil { + t.Fatalf("create popup: %v", err) + } + + if _, _, err := env.popupSvc.RequestImageUpload(context.Background(), popup.ID, "notice.gif"); err == nil { + t.Fatalf("expected invalid extension error") + } + + _, upload, err := env.popupSvc.RequestImageUpload(context.Background(), popup.ID, "notice.webp") + if err != nil { + t.Fatalf("request upload: %v", err) + } + + key := upload.Fields["key"] + if !strings.HasPrefix(key, "popups/") || !strings.HasSuffix(key, ".webp") { + t.Fatalf("unexpected upload key %q", key) + } + + if upload.Fields["Content-Type"] != "image/webp" { + t.Fatalf("unexpected content type fields: %+v", upload.Fields) + } + + finalized, err := env.popupSvc.FinalizeImageUpload(context.Background(), popup.ID, key, "notice.webp") + if err != nil { + t.Fatalf("finalize upload: %v", err) + } + + if finalized.ImageKey == nil || *finalized.ImageKey != key || finalized.ImageName == nil || *finalized.ImageName != "notice.webp" { + t.Fatalf("unexpected finalized popup: %+v", finalized) + } + + setActive := true + finalized, err = env.popupSvc.Update(context.Background(), popup.ID, PopupUpdate{IsActive: &setActive}) + if err != nil { + t.Fatalf("activate popup with image: %v", err) + } + + active, err := env.popupSvc.ListActive(context.Background()) + if err != nil { + t.Fatalf("list active: %v", err) + } + + if len(active) != 1 || active[0].ID != popup.ID { + t.Fatalf("expected active popup after image finalize, got %+v", active) + } + + if _, err := env.popupSvc.FinalizeImageUpload(context.Background(), popup.ID, "../bad.png", "bad.png"); err == nil { + t.Fatalf("expected invalid key error") + } + + withoutImage, err := env.popupSvc.DeleteImage(context.Background(), popup.ID) + if err != nil { + t.Fatalf("delete image: %v", err) + } + + if withoutImage.ImageKey != nil || withoutImage.ImageName != nil || withoutImage.IsActive { + t.Fatalf("expected image fields to be cleared: %+v", withoutImage) + } +} + +func TestPopupServiceStorageUnavailable(t *testing.T) { + env := setupServiceTest(t) + admin := createUser(t, env, "admin@example.com", "admin", "pass", models.AdminRole) + popup, err := env.popupSvc.Create(context.Background(), "Notice", nil, false, admin.ID) + if err != nil { + t.Fatalf("create popup: %v", err) + } + + svc := NewPopupService(repo.NewPopupRepo(env.db), nil) + if _, _, err := svc.RequestImageUpload(context.Background(), popup.ID, "notice.png"); !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("expected storage unavailable for upload, got %v", err) + } + + if _, err := svc.FinalizeImageUpload(context.Background(), popup.ID, "popups/x.png", "x.png"); !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("expected storage unavailable for finalize, got %v", err) + } + + if _, err := svc.DeleteImage(context.Background(), popup.ID); !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("expected storage unavailable for delete image, got %v", err) + } + + errorSvc := NewPopupService(repo.NewPopupRepo(env.db), errorPopupMediaStore{}) + if _, _, err := errorSvc.RequestImageUpload(context.Background(), popup.ID, "notice.png"); err == nil || !strings.Contains(err.Error(), "presign") { + t.Fatalf("expected wrapped presign error, got %v", err) + } +} + +type errorPopupMediaStore struct{} + +func (errorPopupMediaStore) PresignUpload(context.Context, string, string, int64) (storage.PresignedUpload, error) { + return storage.PresignedUpload{}, errors.New("presign failed") +} + +func (errorPopupMediaStore) Delete(context.Context, string) error { + return nil +} diff --git a/internal/service/testenv_test.go b/internal/service/testenv_test.go index 21e41c6..7bcd751 100644 --- a/internal/service/testenv_test.go +++ b/internal/service/testenv_test.go @@ -34,11 +34,13 @@ type serviceEnv struct { submissionRepo *repo.SubmissionRepo scoreRepo *repo.ScoreboardRepo stackRepo *repo.StackRepo + popupRepo *repo.PopupRepo authSvc *AuthService userSvc *UserService affiliationSvc *AffiliationService scoreSvc *ScoreboardService wargameSvc *WargameService + popupSvc *PopupService stackSvc *StackService } @@ -195,11 +197,14 @@ func setupServiceTest(t *testing.T) serviceEnv { writeupRepo := repo.NewWriteupRepo(serviceDB) scoreRepo := repo.NewScoreboardRepo(serviceDB) stackRepo := repo.NewStackRepo(serviceDB) + popupRepo := repo.NewPopupRepo(serviceDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) + mediaStore := storage.NewMemoryMediaFileStore(10 * time.Minute) authSvc := NewAuthService(serviceCfg, userRepo, serviceRedis) - userSvc := NewUserService(userRepo, affiliationRepo, storage.NewMemoryProfileImageStore(10*time.Minute)) + userSvc := NewUserService(userRepo, affiliationRepo, mediaStore) + popupSvc := NewPopupService(popupRepo, mediaStore) affiliationSvc := NewAffiliationService(affiliationRepo) scoreSvc := NewScoreboardService(scoreRepo) wargameSvc := NewWargameService(serviceCfg, challengeRepo, submissionRepo, voteRepo, writeupRepo, repo.NewChallengeCommentRepo(serviceDB), repo.NewCommunityRepo(serviceDB), serviceRedis, fileStore, repo.NewChallengeSeriesRepo(serviceDB)) @@ -215,11 +220,13 @@ func setupServiceTest(t *testing.T) serviceEnv { submissionRepo: submissionRepo, scoreRepo: scoreRepo, stackRepo: stackRepo, + popupRepo: popupRepo, authSvc: authSvc, userSvc: userSvc, affiliationSvc: affiliationSvc, scoreSvc: scoreSvc, wargameSvc: wargameSvc, + popupSvc: popupSvc, stackSvc: stackSvc, } @@ -229,7 +236,7 @@ func setupServiceTest(t *testing.T) serviceEnv { func resetServiceState(t *testing.T) { t.Helper() - if _, err := serviceDB.ExecContext(context.Background(), "TRUNCATE TABLE challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { + if _, err := serviceDB.ExecContext(context.Background(), "TRUNCATE TABLE popups, challenge_series_challenges, challenge_series, community_comments, challenge_comments, challenge_votes, writeups, community_post_likes, community_posts, submissions, vms, stacks, challenges, users, affiliations RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } diff --git a/internal/service/vm_service_test.go b/internal/service/vm_service_test.go index d755bbe..f66a9d9 100644 --- a/internal/service/vm_service_test.go +++ b/internal/service/vm_service_test.go @@ -548,8 +548,7 @@ func TestVMServiceStartTTLReaper(t *testing.T) { t.Fatalf("create vm: %v", err) } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() svc.StartTTLReaper(ctx, 10*time.Millisecond) deadline := time.Now().Add(500 * time.Millisecond) @@ -583,8 +582,7 @@ func TestVMServiceStartTTLReaperNonPositiveInterval(t *testing.T) { t.Fatalf("create vm: %v", err) } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() svc.StartTTLReaper(ctx, 0) time.Sleep(50 * time.Millisecond) diff --git a/internal/storage/memory.go b/internal/storage/memory.go index 21ec91b..78fa013 100644 --- a/internal/storage/memory.go +++ b/internal/storage/memory.go @@ -12,7 +12,7 @@ type MemoryChallengeFileStore struct { keys map[string]struct{} } -type MemoryProfileImageStore struct { +type MemoryMediaFileStore struct { presignTTL time.Duration } @@ -28,12 +28,12 @@ func NewMemoryChallengeFileStore(presignTTL time.Duration) *MemoryChallengeFileS } } -func NewMemoryProfileImageStore(presignTTL time.Duration) *MemoryProfileImageStore { +func NewMemoryMediaFileStore(presignTTL time.Duration) *MemoryMediaFileStore { if presignTTL <= 0 { presignTTL = defaultPresignTTL } - return &MemoryProfileImageStore{presignTTL: presignTTL} + return &MemoryMediaFileStore{presignTTL: presignTTL} } func (m *MemoryChallengeFileStore) PresignUpload(ctx context.Context, key, contentType string) (PresignedUpload, error) { @@ -72,12 +72,12 @@ func (m *MemoryChallengeFileStore) Delete(ctx context.Context, key string) error return nil } -func (m *MemoryProfileImageStore) PresignUpload(ctx context.Context, key, contentType string, maxSizeBytes int64) (PresignedUpload, error) { +func (m *MemoryMediaFileStore) PresignUpload(ctx context.Context, key, contentType string, maxSizeBytes int64) (PresignedUpload, error) { _ = ctx _ = maxSizeBytes return PresignedUpload{ - URL: "https://example.com/upload-profile", + URL: "https://example.com/upload-media", Method: "POST", Fields: map[string]string{ "key": key, @@ -87,7 +87,7 @@ func (m *MemoryProfileImageStore) PresignUpload(ctx context.Context, key, conten }, nil } -func (m *MemoryProfileImageStore) Delete(ctx context.Context, key string) error { +func (m *MemoryMediaFileStore) Delete(ctx context.Context, key string) error { _ = ctx _ = key return nil diff --git a/internal/storage/s3_media_test.go b/internal/storage/s3_media_test.go index 4baf76e..6e0f86a 100644 --- a/internal/storage/s3_media_test.go +++ b/internal/storage/s3_media_test.go @@ -16,8 +16,8 @@ func TestNewS3MediaFileStoreDisabled(t *testing.T) { } } -func TestMemoryProfileImageStorePresignUpload(t *testing.T) { - store := NewMemoryProfileImageStore(5 * time.Minute) +func TestMemoryMediaFileStorePresignUpload(t *testing.T) { + store := NewMemoryMediaFileStore(5 * time.Minute) upload, err := store.PresignUpload(context.Background(), "profiles/3.jpg", "image/jpeg", 100*1024) if err != nil { t.Fatalf("presign upload: %v", err) diff --git a/migrations/2026-06-06/001_add_popups.sql b/migrations/2026-06-06/001_add_popups.sql new file mode 100644 index 0000000..cca23a0 --- /dev/null +++ b/migrations/2026-06-06/001_add_popups.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS popups ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + image_key TEXT NULL, + image_name VARCHAR(255) NULL, + link_url TEXT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + link_url TEXT NULL, + created_by_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_popups_active_created ON popups (is_active, created_at DESC, id DESC); + +COMMIT; diff --git a/migrations/2026-06-06/999_rollback.sql b/migrations/2026-06-06/999_rollback.sql new file mode 100644 index 0000000..c6122f4 --- /dev/null +++ b/migrations/2026-06-06/999_rollback.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS popups; + +COMMIT; From 00337f6918c0433c563fb762ef1baebcb1095346 Mon Sep 17 00:00:00 2001 From: "JunYoung, Kim" Date: Sat, 6 Jun 2026 22:37:31 +0900 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- migrations/2026-06-06/001_add_popups.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/migrations/2026-06-06/001_add_popups.sql b/migrations/2026-06-06/001_add_popups.sql index cca23a0..d49cf47 100644 --- a/migrations/2026-06-06/001_add_popups.sql +++ b/migrations/2026-06-06/001_add_popups.sql @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS popups ( image_name VARCHAR(255) NULL, link_url TEXT NULL, is_active BOOLEAN NOT NULL DEFAULT FALSE, - link_url TEXT NULL, created_by_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() From be540b584c8c8f42905f70f5903bf1a992bb228b Mon Sep 17 00:00:00 2001 From: "JunYoung, Kim" Date: Sat, 6 Jun 2026 22:38:06 +0900 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal/http/handlers/types.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/http/handlers/types.go b/internal/http/handlers/types.go index a888023..26a0828 100644 --- a/internal/http/handlers/types.go +++ b/internal/http/handlers/types.go @@ -945,8 +945,8 @@ func newPopupResponse(row models.Popup) popupResponse { LinkURL: row.LinkURL, IsActive: row.IsActive, CreatedByUserID: row.CreatedByUserID, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: row.CreatedAt.UTC(), + UpdatedAt: row.UpdatedAt.UTC(), } } From c8649c7392ad7af22f1eb8860d2084e0269b32ef Mon Sep 17 00:00:00 2001 From: "JunYoung, Kim" Date: Sat, 6 Jun 2026 22:46:19 +0900 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal/service/popup_service.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/service/popup_service.go b/internal/service/popup_service.go index 8474fc5..34093f9 100644 --- a/internal/service/popup_service.go +++ b/internal/service/popup_service.go @@ -228,10 +228,13 @@ func (s *PopupService) FinalizeImageUpload(ctx context.Context, id int64, key, f } filename = normalizeTrim(filename) - ext := strings.ToLower(filepath.Ext(filename)) - if popupImageContentType(ext) == "" { + filenameExt := strings.ToLower(filepath.Ext(filename)) + if popupImageContentType(filenameExt) == "" { return nil, NewValidationError(FieldError{Field: "filename", Reason: "must be a .png, .jpg, .jpeg, or .webp file"}) } + if strings.ToLower(filepath.Ext(normalizedKey)) != filenameExt { + return nil, NewValidationError(FieldError{Field: "filename", Reason: "does not match key"}) + } popup, err := s.GetByID(ctx, id) if err != nil {