From 28c6dafa02939068c6a50a7a48adc1d1d10d5a16 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 3 Apr 2026 16:36:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EA=B0=80:=20=ED=8B=B0=EC=96=B4?= =?UTF-8?q?=ED=91=9C=20=EA=B3=B5=EC=9C=A0=20=EB=A7=81=ED=81=AC=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=A9=94=ED=83=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/index.js | 2 + backend/src/routes/share.js | 75 +++++++++++++++++++++++++++ docs/history.md | 5 ++ docs/update.md | 7 +++ frontend/nginx.conf | 9 ++++ frontend/src/lib/paths.js | 4 ++ frontend/src/views/TierEditorView.vue | 7 +-- 7 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/share.js diff --git a/backend/index.js b/backend/index.js index 936e574..8912ead 100644 --- a/backend/index.js +++ b/backend/index.js @@ -14,6 +14,7 @@ const topicsRoutes = require('./src/routes/topics') const tierListsRoutes = require('./src/routes/tierlists') const usersRoutes = require('./src/routes/users') const adminRoutes = require('./src/routes/admin') +const shareRoutes = require('./src/routes/share') const app = express() const PORT = process.env.PORT ? Number(process.env.PORT) : 5179 @@ -88,6 +89,7 @@ app.use('/api/topics', topicsRoutes) app.use('/api/tierlists', tierListsRoutes) app.use('/api/users', usersRoutes) app.use('/api/admin', adminRoutes) +app.use('/share', shareRoutes) app.listen(PORT, () => { console.log(`[backend] listening on http://localhost:${PORT}`) diff --git a/backend/src/routes/share.js b/backend/src/routes/share.js new file mode 100644 index 0000000..3963ee8 --- /dev/null +++ b/backend/src/routes/share.js @@ -0,0 +1,75 @@ +const express = require('express') +const { findTierListById } = require('../db') + +const router = express.Router() +const APP_ORIGIN = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '') +const DEFAULT_TITLE = 'Tier Maker | 템플릿으로 쉽게 만드는 티어표' +const DEFAULT_DESCRIPTION = '템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요.' +const DEFAULT_IMAGE_URL = `${APP_ORIGIN}/og-card.png` + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function toAbsoluteUrl(pathname) { + const src = String(pathname || '').trim() + if (!src) return DEFAULT_IMAGE_URL + if (/^https?:\/\//i.test(src)) return src + return `${APP_ORIGIN}${src.startsWith('/') ? src : `/${src}`}` +} + +function buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }) { + const safeTitle = escapeHtml(title || DEFAULT_TITLE) + const safeDescription = escapeHtml(description || DEFAULT_DESCRIPTION) + const safeImageUrl = escapeHtml(imageUrl || DEFAULT_IMAGE_URL) + const safeShareUrl = escapeHtml(shareUrl || APP_ORIGIN) + const safeAppUrl = escapeHtml(appUrl || APP_ORIGIN) + + return ` + + + + + ${safeTitle} + + + + + + + + + + + + + + + + + + + +` +} + +router.get('/editor/:topicId/:tierListId', async (req, res) => { + const { topicId, tierListId } = req.params + const appUrl = `${APP_ORIGIN}/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}?preview=1` + const shareUrl = `${APP_ORIGIN}${req.originalUrl || `/share/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}`}` + + const tierList = await findTierListById(tierListId) + const isPublicMatch = tierList?.isPublic && (tierList.topicSlug === topicId || tierList.topicId === topicId) + const title = isPublicMatch ? tierList.title : DEFAULT_TITLE + const description = isPublicMatch && tierList.description ? tierList.description : DEFAULT_DESCRIPTION + const imageUrl = isPublicMatch ? toAbsoluteUrl(tierList.thumbnailSrc) : DEFAULT_IMAGE_URL + + res.type('html').send(buildShareHtml({ title, description, imageUrl, shareUrl, appUrl })) +}) + +module.exports = router diff --git a/docs/history.md b/docs/history.md index 7730a71..d841512 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-03 v1.4.70 +- 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다. +- 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다. +- 다만 비공개 티어표의 제목/설명/썸네일이 외부 크롤러에게 노출되면 안 되므로, 공유 메타 생성은 공개 티어표이면서 URL의 주제 식별자와 실제 티어표 소속이 일치하는 경우에만 개별 메타를 사용하고, 그 외에는 서비스 기본 메타로 떨어지게 제한했다. + ## 2026-04-03 v1.4.69 - 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다. - 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다. diff --git a/docs/update.md b/docs/update.md index 643a120..30277e6 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 로그 +## 2026-04-03 v1.4.70 +- 저장된 티어표의 `공유하기` 버튼이 기존 `preview=1` 편집기 주소 대신 `/share/editor/:topicId/:tierListId` 공유 전용 주소를 복사하도록 바꿨다. +- 이 공유 전용 주소는 공개 티어표인 경우 해당 티어표의 제목, 설명, 썸네일을 기반으로 Open Graph/Twitter 메타 태그를 서버에서 동적으로 생성한 뒤, 실제 뷰어 화면 `/editor/:topicId/:tierListId?preview=1`로 즉시 이동시킨다. +- 비공개 티어표이거나 주제 경로와 티어표 소속이 맞지 않는 경우에는 개별 제목/설명/썸네일을 노출하지 않고 서비스 기본 공유 메타를 사용하도록 제한했다. +- 운영 프런트 Nginx에서 `/share/` 경로를 백엔드로 프록시하도록 추가해, 카카오톡/디스코드/X 같은 크롤러가 JS 실행 전에 공유 메타를 먼저 읽을 수 있게 했다. +- `backend/index.js`, `backend/src/routes/share.js` 문법 검사와 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다. + ## 2026-04-03 v1.4.69 - 티어표 편집 화면의 아이템 검색에서 한글 아이템명이 검색어와 눈으로는 같아 보여도 내부 유니코드 정규형 차이 때문에 일부 항목이 매칭되지 않을 수 있던 문제를 보강했다. - 검색어와 아이템 라벨을 비교하기 전에 `NFC`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d83a404..8c09279 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -19,6 +19,15 @@ server { proxy_set_header X-Forwarded-Proto https; } + location /share/ { + proxy_pass http://backend:5179/share/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + location /uploads/ { proxy_pass http://backend:5179/uploads/; proxy_http_version 1.1; diff --git a/frontend/src/lib/paths.js b/frontend/src/lib/paths.js index 9c8de43..347b393 100644 --- a/frontend/src/lib/paths.js +++ b/frontend/src/lib/paths.js @@ -25,6 +25,10 @@ export function editorPath(topicId, tierListId, { preview = false } = {}) { return preview ? `${base}?preview=1` : base } +export function shareEditorPath(topicId, tierListId) { + return `/share/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}` +} + export function mePath() { return '/me' } diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 89e72c9..665d3f3 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg' import shareIcon from '../assets/icons/share.svg' import RightRailAd from '../components/RightRailAd.vue' import { api } from '../lib/api' -import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths' +import { editorNewPath, editorPath, loginPath, mePath, shareEditorPath, topicPath, userProfilePath } from '../lib/paths' import { toApiUrl } from '../lib/runtime' import { useAuthStore } from '../stores/auth' import { useToast } from '../composables/useToast' @@ -155,8 +155,9 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor const shareTierListUrl = computed(() => { const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '') if (!savedTierListId) return '' - if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true }) - return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString() + const sharePath = shareEditorPath(templateId.value, savedTierListId) + if (typeof window === 'undefined') return sharePath + return new URL(sharePath, window.location.origin).toString() }) watch(error, (message) => { if (!message) return