generated from ttipo/project-base
feat(frontend): 대용량 첨부 — 쓰기 · 읽기 · 설정 · 공개 내려받기 화면
- 쓰기: 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). 고르자마자 서버에 만들고 16MiB 조각(Content-Digest)을 파일 3개까지 함께, 끊기면 2 · 5 · 15 · 30초로 다시 · 1분 넘으면 멈춤 + [다시 시도], 새로고침 뒤 같은 파일을 다시 고르면 잇는다. 다 올릴 때까지 [보내기]는 aria-disabled + 까닭, 위험 판정은 빨간 줄 + 실시간 알림. 대용량 파일이 있으면 첫 원문 인용 앞을 data-ara-quote-start · bodyQuoteStart 로 알린다(서버가 그 자리에 덩이를 넣는다). - 읽기: 첨부 칸의 대용량 항목(서버의 largeFiles), 본문 덩이 접기, 보낸 사람의 받은 수 · 링크 끊기(끊은 날 — revokedAt). - 설정 › 메일 › 대용량 첨부: 보관 사용량 · 보내지 않은 파일 · 보낸 링크(끊기). - 공개 화면 /s/:token: 세션 복원 없이(meta.public) · credentials omit · no-referrer, 라우트는 대소문자를 가린다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { fail, installMockApi, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import router from './index'
|
||||
|
||||
// 라우터 가드 — 공개 화면(11단계 내려받기 /s/:token)은 세션을 묻지 않는다 (계약 4절 "화면 /s/{token}")
|
||||
// 받는 쪽은 우리 직원이 아니다 — 쿠키 없는 바깥 브라우저에서 /auth/me · /auth/refresh 가 오가지 않게
|
||||
|
||||
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
|
||||
|
||||
describe('router 가드', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('공개 화면(/s/:token)은 세션 복원 없이 바로 연다', async () => {
|
||||
const requests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
await router.push(`/s/${TOKEN}`)
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('public-share')
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
it('공개 화면 주소는 대소문자를 가린다 — /S/<토큰> 은 공개 화면이 아니다(앞단 nginx 의 /s/ 와 같게)', () => {
|
||||
expect(router.resolve(`/s/${TOKEN}`).name).toBe('public-share')
|
||||
expect(router.resolve(`/S/${TOKEN}`).name).toBe('not-found')
|
||||
})
|
||||
|
||||
it('로그인해야 하는 화면은 세션을 복원해 보고, 없으면 로그인으로 보낸다', async () => {
|
||||
const requests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
await router.push('/settings/mail/large-files')
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(['/auth/me', '/auth/refresh'])
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/settings/mail/large-files')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,11 @@ declare module 'vue-router' {
|
||||
requiresAuth?: boolean
|
||||
/** 로그인한 사용자는 들어올 필요가 없는 화면 (로그인) */
|
||||
guestOnly?: boolean
|
||||
/**
|
||||
* 로그인 없는 공개 화면 (11단계 내려받기 화면 /s/:token) — 세션 복원(/auth/me · /auth/refresh)을 건너뛴다.
|
||||
* 받는 쪽은 우리 직원이 아니다 — 쿠키 없는 브라우저에서 갱신 요청이 오가지 않게 (계약 4절 "화면 /s/{token}")
|
||||
*/
|
||||
public?: boolean
|
||||
/** 문서 제목 — "제목 · ARA SYSTEM" */
|
||||
title?: string
|
||||
}
|
||||
@@ -24,6 +29,8 @@ const router = createRouter({
|
||||
|
||||
// 네비게이션 가드 — 앱 시작 시 /auth/me 로 세션을 복원한 뒤 첫 네비게이션을 판정한다
|
||||
router.beforeEach(async (to) => {
|
||||
// 공개 화면은 세션을 묻지 않는다 — 로그인 없이 그린다 (그 뒤 다른 화면으로 가면 그때 복원한다)
|
||||
if (to.meta.public) return true
|
||||
// 첫 호출만 실제로 조회하고, 이후에는 같은 결과를 바로 돌려준다
|
||||
await useAuth().restoreSession()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
|
||||
import { PUBLIC_SHARE_ROUTE } from '@/shared/constants/large-file'
|
||||
import { MAIL_PRINT_ROUTE } from '@/shared/constants/mail-print'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import {
|
||||
@@ -95,7 +96,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림) + 하위 화면.
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면.
|
||||
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
@@ -139,6 +140,13 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/settings/MailBlocksPage.vue'),
|
||||
meta: { title: '차단한 주소' },
|
||||
},
|
||||
{
|
||||
// 대용량 첨부 (11단계 — 디자인 README "11단계 › 설정 › 메일 › 대용량 첨부") — 보관 사용량 · 보내지 않은 파일 · 보낸 링크
|
||||
path: 'mail/large-files',
|
||||
name: SETTINGS_ROUTES.largeFiles,
|
||||
component: () => import('@/pages/settings/MailLargeFilesPage.vue'),
|
||||
meta: { title: '대용량 첨부' },
|
||||
},
|
||||
{
|
||||
path: 'mail/notify',
|
||||
name: SETTINGS_ROUTES.notify,
|
||||
@@ -147,6 +155,17 @@ export const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// 공개 내려받기 화면 (11단계 — 계약 4절 "공개 내려받기" · 디자인 README "11단계 › 내려받기 화면") — 로그인 없는 독립 화면.
|
||||
// 받는 사람(바깥 거래처)이 메일 속 링크로 연다 — 세션 복원(/auth/me · /auth/refresh)과 로그인 화면 보내기를 건너뛴다(public).
|
||||
// 운영의 공개 호스트(files.)에서는 이 화면과 그 API(/api/public-links/…)만 열린다 — 앞단이 그 밖을 막는다
|
||||
// 대소문자를 가린다(sensitive) — 앞단 nginx 의 /s/ 는 소문자만이라, 포털이 닫아도 /S/<토큰> 이 SPA 로 와 이 화면이 그려지지 않게(2026-10-30 리뷰)
|
||||
path: '/s/:token',
|
||||
sensitive: true,
|
||||
name: PUBLIC_SHARE_ROUTE,
|
||||
component: () => import('@/pages/share/SharePage.vue'),
|
||||
meta: { public: true, title: '파일 내려받기' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
Reference in New Issue
Block a user