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:
2026-10-31 00:37:55 +09:00
parent 93154ad9a5
commit 3461c361f8
84 changed files with 10084 additions and 137 deletions
+44
View File
@@ -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')
})
})
+7
View File
@@ -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()
+20 -1
View File
@@ -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',