Author SHA1 Message Date
static 48bf9c1165 사소한 리팩토링 2026-10-20 03:12:53 +09:00
static d986493431 업로드 및 썸네일 등록의 경쟁 조건을 수정하고 캐시 무효화와 스트리밍 캐시의 메모리 사용량을 개선 2026-10-12 14:02:41 +09:00
staticandClaude Opus 5.5 75e8ed6c77 파일 시스템 및 업로드 관련 비즈니스 규칙과 트랜잭션 경계를 Repository에서 서비스로 이동하고 IntegrityError를 제거하며 즐겨찾기 API를 멱등하게 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:13:58 +09:00
staticandClaude Opus 5.5 73b2d29b2c DB 접근 시 Executor를 명시적으로 전달하고 서비스에서 트랜잭션 경계를 관리하도록 인증 및 키 관련 코드를 개선하고 직렬화 실패 시 트랜잭션을 재시도하도록 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:05:34 +09:00
staticandClaude Opus 5.5 5b8621e524 리다이렉트 경로 처리, 파일 정보 변환, 상태 표시 UI 등 중복된 코드를 통합하고 모듈 간 순환 의존성 및 계층 위반 해소
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:51:07 +09:00
staticandClaude Opus 5.5 9119c21881 형식이 잘못된 세션 쿠키로 인해 서버 오류가 발생하던 문제와 업로드 중 오류가 무시되거나 무한히 대기하던 문제 등 버그 수정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:19:41 +09:00
staticandClaude Opus 5.5 bb0328b39e Playwright 기반 E2E 테스트 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:42:51 +09:00
staticandClaude Opus 5.5 306fcb2420 Svelte 컴포넌트를 제외한 파일, 폴더 및 라우팅 경로의 이름을 kebab-case로 통일하고 import 경로에 실제 파일 확장자를 사용하도록 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:41:28 +09:00
staticandClaude Opus 5.5 225ad3515f 파일을 업로드할 때 캐시에 저장하고 동영상을 스트리밍하는 과정에서도 청크 단위로 캐시에 저장하여 다음 재생에 활용하도록 개선
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:40:03 +09:00
staticandClaude Opus 5.5 06fdfcc447 스트리밍되는 동영상의 썸네일을 생성할 때 프레임이 준비되지 않으면 무한히 대기하던 문제 수정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:00:23 +09:00
staticandClaude Opus 5.5 3e52fa7a7c 파일을 다운로드할 때 전체 파일을 메모리에 올리는 대신 서비스 워커에서 스트리밍으로 복호화하도록 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:48:21 +09:00
staticandClaude Opus 5.5 877cf9c42e 컴포넌트를 도메인과 무관한 UI 컴포넌트와 도메인 컴포넌트로 재구성하고 중복된 파일 목록 컴포넌트 통합 및 모듈 간 의존성 방향을 ESLint로 강제
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:30:10 +09:00
staticandClaude Opus 5.5 4e82c5e556 파일 시스템 관련 상태와 변경 로직을 도메인 모듈로 통합하여 캐시가 자동으로 갱신되도록 개선하고 오류를 토스트로 표시하도록 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:22:25 +09:00
staticandClaude Opus 5.5 2ce73aa331 키 관리 로직을 Keyring으로 통합하여 하드코딩된 MEK 및 HSK 버전을 제거하고 Svelte Store를 Rune으로 대체
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:10:37 +09:00
staticandClaude Opus 5.5 aee87fd9b6 서버, 클라이언트, 서비스 워커 공용 코드를 lib/shared로 분리하고 브라우저 전용 모듈을 lib/client로 재구성
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:04:56 +09:00
staticandClaude Opus 5.5 6a6aa1765b 서버 코드를 Router, Service, Repository 계층으로 분리하고 오류 처리 및 파일 삭제 로직 개선
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:00:21 +09:00
staticandClaude Opus 5.5 ed732be999 상위 디렉터리 및 카테고리의 소유권을 검증하지 않던 문제 등 버그 수정 및 Vitest 기반 테스트 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:45:56 +09:00
staticandClaude Opus 5.5 67989f4741 패키지 버전 업데이트 및 SvelteKit 3, Tailwind CSS 4로 마이그레이션
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:49:24 +09:00
staticandClaude Opus 5.5 35f3e92b5a SSR을 비활성화하여 SPA로 전환
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:49:24 +09:00
static b35585c7bb 패키지 버전 업데이트 2026-07-15 19:27:24 +09:00
static 7f68f6d580 파일을 업로드할 때 청크별로 개별 저장하는 대신 파일 하나에 저장하도록 변경 2026-03-10 22:44:11 +09:00
static c2874035ba 암호화 마이그레이션 기능 삭제 2026-03-10 19:33:12 +09:00
static 3c1e98c872 패키지 버전 업데이트 2026-03-09 06:14:37 +09:00
static 9635d2a51b IndexedDB에 가끔 파일 및 디렉터리 정보를 저장하지 못하던 버그 수정 2026-01-18 16:32:06 +09:00
static 3b0cfd5a92 즐겨찾기 검색 필터를 재귀적으로 동작하도록 변경 2026-01-18 16:16:38 +09:00
static 2f6d35c335 모바일 환경에서 SearchBar의 레이아웃이 깨지는 문제 수정 2026-01-18 14:16:40 +09:00
static 72babc532f 검색 필터에 즐겨찾기 여부 추가 2026-01-18 13:29:06 +09:00
static 63163d6279 파일 및 디렉터리 메타데이터 복호화 로직 리팩토링 2026-01-18 13:01:44 +09:00
static 4797ccfd23 FileRepo의 함수 중 디렉터리 관련된 함수들을 DirectoryRepo로 분리 2026-01-18 12:34:04 +09:00
static 14693160b8 사소한 리팩토링 2026-01-18 12:03:24 +09:00
static bcb57bb12d IndexedDB에 즐겨찾기 여부를 항상 저장하도록 변경 2026-01-18 11:33:30 +09:00
static ff6ea3a0b9 파일 페이지에서 즐겨찾기 설정이 가능하도록 변경 및 즐겨찾기에 추가된 경우 목록에서 즐겨찾기 여부를 아이콘으로 표시하도록 개선 2026-01-17 20:11:01 +09:00
static 420e30f677 즐겨찾기 기능 구현 2026-01-17 19:41:52 +09:00
static befa535526 비효율적인 비디오 스트리밍 로직 개선 2026-01-17 18:25:54 +09:00
static d8e18fb1d3 OR 조건 대신 IN 연산자를 사용하도록 일부 SQL 쿼리 수정 2026-01-16 10:24:32 +09:00
static cb5105a355 패키지 버전 업데이트 2026-01-16 07:51:17 +09:00
static fe83a71a1f 썸네일이 누락된 파일 조회 및 레거시 파일 조회 네트워크 호출 최적화 2026-01-15 20:33:27 +09:00
static ebcdbd2d83 이름 검색 로직 개선 및 뒤로가기로 검색 페이지로 돌아온 경우에 필터 및 검색 결과가 유지되도록 개선 2026-01-15 18:25:01 +09:00
static b3e3671c09 파일 검색 쿼리 최적화 2026-01-15 15:54:45 +09:00
static 37bd6a9315 검색 기능 구현 2026-01-15 15:11:03 +09:00
static 96d5397cb5 docker.yaml 파일의 오타 수정 2026-01-13 00:37:29 +09:00
static b952bfae86 릴리즈 때마다 Docker 이미지를 자동으로 빌드하는 Action 추가 2026-01-13 00:28:52 +09:00
static 4cdf2b342f 청크 업로드 성능 개선 및 네트워크 속도를 더 정확하게 측정하도록 개선 2026-01-12 23:37:04 +09:00
static a4912c8952 사소한 리팩토링 2026-01-12 20:50:19 +09:00
static 00b9858db7 업로드된 청크 목록을 비트맵을 활용해 효율적으로 저장하도록 개선 2026-01-12 18:37:36 +09:00
static c778a4fb9e 파일 업로드 로직 리팩토링 2 2026-01-12 16:58:28 +09:00
static e7dc96bb47 HMAC 계산을 Web Worker에서 처리하도록 변경 2026-01-12 15:16:43 +09:00
static b636d75ea0 파일 업로드 로직 리팩토링 2026-01-12 12:02:20 +09:00
static 27e90ef4d7 이전 버전에서 업로드된 파일을 청크 업로드 방식으로 마이그레이션할 수 있는 기능 추가 2026-01-12 08:40:07 +09:00
static 594c3654c9 파일 및 썸네일 다운로드 Endpoint의 핸들러를 하나로 통합 2026-01-12 05:04:07 +09:00
static 614d0e74b4 패키지 버전 업데이트 2026-01-11 16:01:02 +09:00
static efc2b08b1f Merge pull request #18 from kmc7468/add-chunked-upload
Chunked Upload 도입
2026-01-11 15:56:35 +09:00
static 80368c3a29 사소한 리팩토링 2 2026-01-11 15:54:05 +09:00
static 83369f83e3 DB에 청크 업로드 경로를 저장하도록 변경 2026-01-11 15:16:03 +09:00
static 2801eed556 사소한 리팩토링 2026-01-11 14:35:30 +09:00
static 57c27b76be 썸네일 업로드도 새로운 업로드 방식으로 변경 2026-01-11 14:07:32 +09:00
static 3628e6d21a 업로드할 때에도 스트리밍 방식으로 처리하도록 변경 2026-01-11 13:19:54 +09:00
static 1efcdd68f1 스트리밍 방식으로 동영상을 불러올 때 다운로드 메뉴가 표시되지 않는 버그 수정 2026-01-11 09:25:40 +09:00
static 0c295a2ffa Service Worker를 활용한 스트리밍 방식 파일 복호화 구현 2026-01-11 09:06:49 +09:00
static 4b783a36e9 파일 업로드 방식을 Chunking 방식으로 변경 2026-01-11 04:45:21 +09:00
static b9e6f17b0c IV를 암호화된 파일 및 썸네일 앞에 합쳐서 전송하도록 변경 2026-01-11 00:29:59 +09:00
static 5d130204a6 사소한 버그 수정 2026-01-06 07:46:07 +09:00
static 4997b1f38c 불필요하게 분리된 컴포넌트 삭제 2026-01-06 07:17:58 +09:00
static 1d3704bfad 디렉터리 및 카테고리 페이지에서 탐색시의 깜빡임 현상 완화 2026-01-06 06:48:35 +09:00
static ae1d34fc6b 파일, 카테고리, 디렉터리 정보를 불러올 때 특정 조건에서 네트워크 요청이 여러 번 발생할 수 있는 버그 수정 2026-01-05 06:49:12 +09:00
static f10a0a2da3 썸네일 로딩 로직 최적화 2026-01-04 20:01:30 +09:00
static 0eb1d29259 Scheduler 클래스의 스케쥴링 로직 개선 2026-01-04 17:54:42 +09:00
static cf0f8fe0b9 누락된 @eslint/js 패키지 추가 2026-01-04 01:50:02 +09:00
static 30c56e0926 삭제된 파일, 카테고리, 디렉터리에 대한 정보가 IndexedDB에서 삭제되지 않는 버그 수정 2026-01-03 00:54:32 +09:00
static 83d595636b 동시에 업로드할 수 있는 파일의 메모리 용량을 제한하여 메모리 부족으로 인해 발생하던 크래시 해결 2026-01-02 23:00:25 +09:00
static 008c8ad6ba 디렉터리 페이지 하단에 여백이 생기지 않는 버그 수정 2026-01-02 18:24:09 +09:00
static 5729af380d 모바일 환경에서 갤러리 페이지에서의 스크롤이 부자연스럽게 이뤄지는 버그 수정 2026-01-02 17:04:08 +09:00
static c0e71993e9 Merge pull request #16 from kmc7468/optimize-networking
네트워크 호출 최적화
2026-01-02 15:04:45 +09:00
static 280d46b48d 사소한 리팩토링 2 2026-01-02 14:55:26 +09:00
static d1f9018213 사소한 리팩토링 2026-01-02 00:31:58 +09:00
static 2e3cd4f8a2 네트워크 호출 결과가 IndexedDB에 캐시되지 않던 버그 수정 2026-01-01 23:52:47 +09:00
static d98be331ad 홈, 갤러리, 캐시 설정, 썸네일 설정 페이지에서의 네트워크 호출 최적화 2026-01-01 23:31:01 +09:00
static 841c57e8fc 삭제된 파일의 캐시가 존재하는 경우 캐시 페이지의 로딩이 끝나지 않는 버그 수정 2026-01-01 21:41:53 +09:00
static 182ec18a2b 사소한 리팩토링 2025-12-31 02:43:07 +09:00
static 7b666cf692 파일이 다운로드/업로드된 직후에 다운로드/업로드 페이지의 목록에서 바로 사라지던 버그 수정 2025-12-31 01:32:54 +09:00
static 26323c2d4d 프론트엔드 파일시스템 모듈 리팩토링 2025-12-31 00:43:12 +09:00
static e4413ddbf6 파일 페이지에서의 네트워크 호출 최적화 2025-12-30 23:30:50 +09:00
static b5522a4c6d 카테고리 페이지에서의 네트워크 호출 최적화 2025-12-30 20:53:20 +09:00
static 1e57941f4c 디렉터리 페이지에서 하위 디렉터리도 가상 리스트로 표시하도록 개선 2025-12-30 18:44:46 +09:00
static 409ae09f4f 디렉터리 페이지에서의 네트워크 호출 최적화 2025-12-30 17:21:54 +09:00
static cdb652cacf 사진 또는 동영상이 없을 때 홈 페이지의 레이아웃이 깨지는 버그 수정 2025-12-29 19:43:25 +09:00
static 15b6a53710 사소한 리팩토링 2 2025-12-29 18:14:42 +09:00
static 174305ca1b 파일 페이지와 카테고리 페이지에서 파일 목록을 표시할 때도 가상 리스트를 사용하여 효율적으로 랜더링하도록 개선 2025-12-27 23:27:57 +09:00
static 0d13d3baef 사소한 리팩토링 2025-12-27 14:10:33 +09:00
static 576d41da7f 디렉터리 페이지에 상위 디렉터리로 이동 버튼 추가 2025-12-27 03:04:09 +09:00
static 9eb67d5877 파일 페이지에 다운로드 및 폴더로 이동 메뉴 추가 2025-12-27 02:37:56 +09:00
static a9da8435cb tRPC 클라이언트에 최대 URL 길이 설정 2025-12-26 23:54:49 +09:00
static 3e98e3d591 갤러리 페이지에서 파일이 표시되지 않던 버그 수정 2025-12-26 23:29:29 +09:00
static 27a46bcc2e eslint.config.js 파일 업데이트 2025-12-26 23:12:37 +09:00
static a1f30ee154 홈 페이지와 갤러리 페이지에서 사진 및 동영상만 표시되도록 개선 2025-12-26 22:58:09 +09:00
static 6d02178c69 홈 페이지 구현 2025-12-26 22:47:31 +09:00
static ed21a9cd31 갤러리 페이지 구현 2025-12-26 22:29:44 +09:00
static b7a7536461 Merge pull request #14 from kmc7468/migrate-to-trpc
tRPC 도입
2025-12-26 15:58:24 +09:00
static 3eb7411438 사소한 리팩토링 3 2025-12-26 15:57:05 +09:00
static c9d4b10356 사소한 리팩토링 2 2025-12-26 15:45:03 +09:00
static d94d14cf83 사소한 리팩토링 2025-12-26 15:07:59 +09:00
static 3fc29cf8db /api/auth 아래의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 23:44:23 +09:00
static b92b4a0b1b Zod 4 마이그레이션 2025-12-25 22:53:51 +09:00
static 6d95059450 /api/category, /api/directory, /api/file 아래의 대부분의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 22:45:55 +09:00
static a08ddf2c09 tRPC Endpoint를 /api/trpc로 변경 2025-12-25 20:22:58 +09:00
static 208252f6b2 /api/hsk, /api/mek, /api/user 아래의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 20:00:15 +09:00
static aa4a1a74ea /api/client 아래의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 18:59:41 +09:00
static 640e12d2c3 tRPC Authorization 미들웨어 구현 2025-12-25 16:50:41 +09:00
static 7779910949 tRPC 초기 설정 2025-11-02 23:09:01 +09:00
static 328baba395 패키지 버전 업데이트 2025-11-02 02:57:18 +09:00
static 4e91cdad95 서버로부터 파일의 DEK를 다운로드한 후에야 썸네일이 표시되던 현상 수정 2025-07-20 05:17:38 +09:00
static 9f53874d1d 비디오 재생이 지원되지 않는 포맷일 때 썸네일 생성 작업이 무한히 끝나지 않던 버그 수정 2025-07-17 01:54:58 +09:00
static af20f6ec4e package.json 파일 업데이트 2025-07-12 19:47:59 +09:00
static 301216915e 브라우저가 heic 디코딩을 지원하는 경우 heic2any를 사용하지 않도록 개선 및 브라우저가 webp 인코딩을 지원하지 않는 경우 썸네일을 생성하지 않도록 수정 2025-07-12 19:44:16 +09:00
static 393bba45db 비밀번호 변경시 비밀번호 확인 필드 추가 2025-07-12 18:24:43 +09:00
static 3ebfcdaa7d 하위 카테고리의 파일 표시 여부를 기억하도록 개선 2025-07-12 18:14:33 +09:00
static 89921ef1df 사소한 리팩토링 3 2025-07-12 05:58:35 +09:00
static 4679b1d6bd 동영상의 썸네일이 가끔 흰색으로 잘못 생성되던 버그 수정 2025-07-12 05:39:39 +09:00
static 0d35f0b607 사소한 리팩토링 2 2025-07-12 04:57:15 +09:00
static 1304cc3868 사소한 리팩토링 2025-07-12 04:22:26 +09:00
static 823ad7f59a 패키지 버전 업데이트 2025-07-12 03:46:23 +09:00
static 01732037a6 package.json 파일 업데이트 2025-07-12 03:40:28 +09:00
static 381edce0c5 페이지가 열릴 때 영구 저장소 사용을 요청하도록 개선 2025-07-12 03:37:47 +09:00
static eda5ff7570 로그인할 때마다 다른 디바이스에서 삭제된 파일을 스캔하여 현재 디바이스에서도 삭제하도록 구현 2025-07-12 03:27:49 +09:00
static fa7ba451c3 비디오의 경우 원하는 장면으로 썸네일을 변경할 수 있도록 개선 2025-07-12 02:53:30 +09:00
static eac81abe5a 키 가져오기 기능 추가 2025-07-12 01:28:44 +09:00
static c47885d571 강제 로그인 기능 추가 2025-07-11 23:15:35 +09:00
static fa8c163347 .dockerignore 및 .gitignore 파일 업데이트 2025-07-11 20:06:26 +09:00
static 6e14b45656 이미 클라이언트가 로그인된 상태에서 세션을 업그레이드하려는 경우 발생하던 500 오류 수정 2025-07-08 19:38:49 +09:00
static 983cb2cc57 세션 쿠키를 계속 롤링하도록 개선하여 세션이 유효함에도 브라우저에서 쿠키가 삭제되던 문제 해결 2025-07-08 13:49:48 +09:00
static 18660844e6 썸네일을 일괄적으로 생성하는 경우 발생하던 Out of Memory 문제 해결 2025-07-08 04:31:19 +09:00
static 69b31ad9af Merge pull request #11 from kmc7468/add-file-thumbnail
파일에 대한 썸네일 기능 구현
2025-07-08 02:34:58 +09:00
static 2c7d085e6d 사소한 리팩토링 3 2025-07-08 02:34:14 +09:00
static a42ec28176 사소한 리팩토링 2 2025-07-08 02:26:51 +09:00
static 9b1e27c20b 사소한 리팩토링 2025-07-08 02:07:54 +09:00
static 5d9042d149 세로로 긴 썸네일이 정사각형으로 제대로 표시되지 않던 버그 수정 2025-07-07 23:09:43 +09:00
static 40a87aa81f 파일 썸네일이 캐시되는 OPFS의 경로 변경 2025-07-07 18:29:04 +09:00
static d3de06a7f9 파일 목록이 랜더링되지 않던 버그 수정 2025-07-07 17:48:55 +09:00
static c092545b58 Merge branch 'dev' into add-file-thumbnail 2025-07-07 00:43:41 +09:00
static e4cce6b8a0 OPFS에 캐시된 썸네일을 모두 삭제하는 기능 추가 2025-07-07 00:30:38 +09:00
static 8fefbc1bcb 썸네일 설정 페이지 완성 2025-07-06 23:17:48 +09:00
static bcb969dc22 heic 파일에 대한 썸네일 지원 추가 및 카테고리 페이지에서도 파일의 썸네일이 표시되도록 개선 2025-07-06 19:55:13 +09:00
static 8975a0200d 파일을 삭제할 경우 서버와 클라이언트에 저장된 썸네일을 함께 삭제하도록 개선 2025-07-06 17:38:04 +09:00
static 781642fed6 썸네일을 메모리와 OPFS에 캐시하도록 개선 2025-07-06 05:36:05 +09:00
static 3a637b14b4 누락된 썸네일 생성 기능 구현 2025-07-06 00:25:50 +09:00
static 9e67920968 썸네일 표시 구현 2025-07-05 18:18:10 +09:00
static eaf2d7f202 썸네일 업로드 구현 2025-07-05 16:55:09 +09:00
static c236242136 thumbnail 테이블의 created_at 컬럼의 이름을 updated_at으로 변경 2025-07-05 05:54:55 +09:00
static 36d082e0f8 /api/file/[id]/thumbnail, /api/file/[id]/thumbnail/download, /api/file/[id]/thumbnail/upload Endpoint 구현 2025-07-05 05:44:00 +09:00
static 7b88679ff0 패키지 버전 업데이트 2 2025-07-05 04:13:39 +09:00
static c9331ae5b7 클라이언트가 Decryption Oracle로 사용될 수 있는 취약점 수정 2025-07-04 23:26:58 +09:00
static 13bac59824 패키지 버전 업데이트 2025-07-04 22:33:44 +09:00
static 2a5200fe9d Revert "데모용 임시 회원가입 구현"
This reverts commit eb913366646f43fda669f0550788e0888c44b95a.
2025-05-31 21:36:27 +09:00
static 451dd3c129 데모용 임시 회원가입 구현 2025-05-28 18:00:17 +09:00
static 2105b66cc3 DB에 thumbnail 테이블 추가 2025-02-01 20:33:41 +09:00
static ad0f3ff950 이미지/비디오 썸네일 생성 함수 구현 2025-01-31 00:37:23 +09:00
static 36006a9b72 사소한 리팩토링 2 2025-01-30 20:32:38 +09:00
static 0a5ef2b970 사소한 리팩토링 2025-01-30 18:46:16 +09:00
static 23c82fee12 package.json 파일 업데이트 2025-01-29 00:05:41 +09:00
static 49f9c1e21f Merge pull request #9 from kmc7468/refactor-to-atomic-patterns
Atomic 디자인 패턴 도입
2025-01-29 00:03:55 +09:00
static 489f5daba8 Merge branch 'dev' into refactor-to-atomic-patterns 2025-01-29 00:02:57 +09:00
static 43f0245ff9 사소한 리팩토링 2025-01-28 23:34:21 +09:00
static 183ce583b0 FloatingActionButton 컴포넌트 리팩토링 2 2025-01-28 22:59:22 +09:00
static d2aca276ac FloatingButton 컴포넌트 리팩토링 2025-01-28 22:53:50 +09:00
static b6edc2a8bc 레이아웃에 필수적인 Snippet은 반드시 전달해야하도록 Props 타입 수정 2025-01-28 22:41:35 +09:00
static acb6e1ac6e 컴포넌트 관련 사소한 리팩토링 2025-01-27 20:17:20 +09:00
static 7230810f3a components 디렉터리 밖에 있던 molecule/organism 컴포넌트들을 해당 디렉터리 내부로 이동 2025-01-27 19:45:40 +09:00
static df9831da8e TitleDiv 컴포넌트 리팩토링 2025-01-27 19:33:38 +09:00
static cf51f2618e BottomSheet 컴포넌트 리팩토링 2025-01-27 15:03:57 +09:00
static 0f2c5f8b33 ActionEntryButton 컴포넌트 추가 2025-01-26 23:18:52 +09:00
static 32ecf46341 IconEntryButton 컴포넌트 추가 2025-01-26 22:04:53 +09:00
static c709a79266 Button 컴포넌트 전체에 클릭시 작아지는 애니메이션 적용 2025-01-25 23:43:52 +09:00
static 0998d0662e FullscreenDiv 컴포넌트 추가 및 TopBar 컴포넌트 리팩토링 2025-01-25 23:39:19 +09:00
static 7dba1cf4c6 기존에 제작된 모달들을 ActionModal 컴포넌트 기반으로 재구성 2025-01-25 22:30:06 +09:00
static cdbe5594f9 파일 업로드에 성공한 경우 캐시에도 파일을 저장하도록 개선 2025-01-25 01:16:02 +09:00
static 38a1c8d7e0 ActionModal 레이아웃 및 Props 이름 관련 리팩토링 2025-01-24 21:58:51 +09:00
static fea9cd729c Modal, AdaptiveDiv, BottomDiv 컴포넌트를 molecules 디렉터리로 이동 및 리팩토링 2025-01-24 21:24:59 +09:00
static 1c09d93b41 Button 및 Input 컴포넌트를 atoms 디렉터리로 이동 및 리팩토링 2025-01-24 16:39:09 +09:00
static a01137bbf9 Merge pull request #8 from kmc7468/add-file-category
카테고리를 활용한 파일 분류 시스템 도입
2025-01-23 13:31:12 +09:00
static 3ee98166c6 종종 카테고리에서 파일을 삭제했음에도 삭제되지 않은 것으로 표시되던 버그 수정 2025-01-23 13:30:50 +09:00
static fd10f13a4d 사소한 리팩토링 2025-01-23 13:21:34 +09:00
static ca67f5a81c /api/category/[id]/file/list Endpoint에서, recursive 쿼리 파라미터의 값을 false로 설정해도 재귀적으로 검색되던 버그 수정 2025-01-23 12:57:37 +09:00
static b8b87877d2 파일 페이지에서 뷰어 로딩 메세지를 더 빠르게 표시하도록 개선 2025-01-23 00:32:44 +09:00
static 606609d468 카테고리 목록 이름 기반 정렬 구현 2025-01-23 00:28:30 +09:00
static b48b9719ca 카테고리 페이지에서, 하위 카테고리에만 등록된 파일인 경우 삭제 버튼을 표시하지 않도록 변경 2025-01-22 23:19:06 +09:00
static a1fbea3e45 현재 카테고리에 파일이 존재하지 않으면 하위 카테고리에 있는 파일 목록이 가져와지지 않던 버그 수정 2025-01-22 23:14:32 +09:00
static dd0a887576 카테고리 관련 정보도 IndexedDB에 캐싱하도록 개선 2025-01-22 22:49:07 +09:00
static f34764ffe0 카테고리 목록에서 파일 목록을 재귀적으로 표시할 수 있는 기능 구현 2025-01-22 22:24:44 +09:00
static 8f8bad6d10 카테고리 페이지에 파일 제거 버튼 구현 2025-01-22 15:48:46 +09:00
static 368868910d 카테고리 이름 변경 및 삭제, 카테고리에서 파일 삭제 구현 2025-01-22 15:39:48 +09:00
static 4c0d668cc1 파일 페이지에 카테고리 목록 및 카테고리에 추가 버튼 구현 2025-01-22 13:50:36 +09:00
static a2402f37a0 카테고리에 파일을 추가할 수 있는 BottomSheet 구현 (WiP) 2025-01-22 13:22:16 +09:00
static dbe2262d07 카테고리 페이지의 주요 요소를 별도 컴포넌트로 분리 2025-01-22 11:28:13 +09:00
static 88d4757cf7 카테고리 페이지에 파일 목록 부분 구현 2025-01-21 17:32:08 +09:00
static efe2782db0 카테고리 페이지 구현 (WiP)
아직은 하위 카테고리의 목록만 볼 수 있습니다.
2025-01-21 16:07:23 +09:00
static 2993593770 /api/category/[id], /api/category/create Endpoint 구현 2025-01-21 14:35:34 +09:00
static f66421a5dc @types/better-sqlite3 패키지 삭제 2025-01-21 11:10:16 +09:00
static 2a2d01b50e 카테고리 관련 DB 스키마/코드를 Kysely 기반으로 마이그레이션 2025-01-21 10:57:32 +09:00
static 698d2455ff Merge branch 'dev' into add-file-category 2025-01-21 09:37:59 +09:00
static d0f1e06525 Merge pull request #7 from kmc7468/migrate-to-postgresql
PostgreSQL + Kysely로 마이그레이션
2025-01-20 21:51:23 +09:00
static 9419e5e2b4 README.md 파일 업데이트 2025-01-20 19:51:00 +09:00
static eed60bb4a1 컴파일 오류 등 수정 2025-01-20 19:40:38 +09:00
static 803110606b Production 환경에서의 DB 자동 Migration 구현 2025-01-20 19:15:15 +09:00
static ce329891ae Drizzle 및 SQLite3 관련 패키지/코드 삭제 2025-01-20 17:37:34 +09:00
static a3c169f706 레포지토리 레이어의 코드를 Kysely 기반으로 모두 마이그레이션 (WiP) 2025-01-20 16:05:35 +09:00
static 63eacbb1b3 Kysely 및 PostgreSQL 도입 (WiP) 2025-01-20 10:56:58 +09:00
static 3da3be3b73 Merge branch 'dev' into add-file-category 2025-01-19 10:51:11 +09:00
static 0002b4e5f2 hskLog 테이블의 actionBy 필드의 Foreign key constraint이 잘못 지정되어 있던 버그 수정 2025-01-19 02:03:44 +09:00
static 6018b03523 파일 업로드/다운로드 현황 페이지에서 TopBar가 고정되어 있지 않던 버그 수정 2025-01-19 01:21:52 +09:00
static 5517d9f811 삭제된 파일이나 디렉터리의 경우에도 메타데이터를 복호화하려고 시도하던 버그 수정 2025-01-19 00:19:10 +09:00
static 53bc426487 파일을 업로드할 때, 파일의 내용이 아닌 해시가 서버의 파일 시스템에 기록되던 버그 수정 2025-01-18 18:54:49 +09:00
static 10eba78444 파일 업로드시의 체크섬 검사 구현 2025-01-18 18:12:40 +09:00
static 2af3caf3b9 파일 및 디렉터리 목록을 정렬할 때 자연 정렬을 사용하도록 변경 2025-01-18 16:45:07 +09:00
static 2b303f9197 파일 목록에서 업로드 중인 파일이 표시되지 않던 버그 수정 2025-01-18 16:29:39 +09:00
static b8e1584575 사소한 리팩토링 2 2025-01-18 13:18:07 +09:00
static c24e84a79c 불필요한 CSS 속성 제거 2 2025-01-18 13:09:51 +09:00
static 3d620d716d 불필요한 CSS 속성 제거 2025-01-18 13:00:16 +09:00
static da47a07da7 사소한 리팩토링 2025-01-18 12:48:01 +09:00
static d0d4afd2c3 다운로드 및 업로드 속도가 잘못 표기되던 버그 수정 2025-01-18 11:23:22 +09:00
static 811713cd03 파일 업로드/다운로드 현황을 모두 볼 수 있는 페이지 구현 2025-01-18 10:26:35 +09:00
static bde090c464 파일 다운로드 스케쥴링 및 진행률 표시 기능 구현 2025-01-18 08:20:09 +09:00
static 620d174e9b 클라이언트가 시작될 때 삭제된 파일이나 디렉터리 정보를 IndexedDB에서 삭제하도록 개선 2025-01-17 13:02:21 +09:00
static 7aa6ba0eab 파일 및 디렉터리 목록을 IndexedDB에 캐싱하도록 구현 2025-01-17 12:22:51 +09:00
static 7e711c1b8f /api/file/upload Endpoint에서의 dekVersion 제한 완화 및 파일 업로드 중 페이지를 떠나려는 경우 경고 표시 기능 구현
dekVersion의 경우, Request를 받은 시점으로부터 하루 전 ~ 1분 후 사이에 있어야 하도록 완화했습니다. 기존에는 1분 전 ~ 1분 후 사이에 있어야 했습니다. 파일을 한 번에 업로드하는 경우 오류가 발생하는 것을 방지하기 위한 조치입니다.
2025-01-17 07:54:09 +09:00
static 0ed3d17fef 여러 파일을 한 번에 업로드할 수 있도록 변경 2025-01-17 07:39:01 +09:00
static 40745d5da4 파일/디렉터리 목록에서 무한한 Request가 발생하던 버그 수정 2025-01-16 04:25:20 +09:00
static cc9d355ac1 파일을 업로드하는 중에, 해당되는 디렉터리에 업로드 Progress를 표시하도록 구현 2025-01-16 04:23:31 +09:00
static 937c4e2453 파일 업로드 스케쥴링 구현
암호화는 동시에 최대 4개까지, 업로드는 1개까지 가능하도록 설정했습니다.
2025-01-16 02:33:00 +09:00
static 366f657113 heic2any를 동적으로 import하도록 변경 2025-01-15 08:34:50 +09:00
static 9f9c52ff94 파일을 업로드하는 도중에 HTTP 연결이 끊기면 서버가 크래시되던 버그 수정 2025-01-15 06:09:27 +09:00
static ed4da7b1df 파일 업로드에 5분 이상 걸리는 경우 408 오류가 발생하던 버그 수정 2025-01-15 02:53:14 +09:00
static f4b9f87087 파일을 업로드할 때 스트리밍이 되지 않고 버퍼링하던 버그 수정 2025-01-14 18:06:41 +09:00
static 6015a9bca4 캐시되지 않은 파일의 캐시를 삭제하려고 시도하던 버그 수정 2025-01-14 03:39:44 +09:00
static 4bd666a5d5 디렉터리를 삭제하는 경우, 디렉터리 하위에 있던 파일의 캐시를 자동으로 삭제하도록 구현 2025-01-14 03:37:31 +09:00
static 27d2b83464 캐시 삭제 구현 2025-01-14 03:26:32 +09:00
static f37df53991 캐시 목록 페이지 추가 2025-01-14 03:07:54 +09:00
static ea0f0e4a71 파일 캐시 추가 2025-01-14 01:03:26 +09:00
static e1262506c4 카테고리 관련 DB 테이블 추가 2025-01-13 09:10:56 +09:00
static 9ab107794a 로그아웃 버튼 추가 2025-01-13 07:40:10 +09:00
static 919a67fedf DB 마이그레이션 스크립트 생성 2025-01-13 07:27:42 +09:00
static f914026922 파일 생성 시각 및 파일 마지막 수정 시각을 저장하도록 변경
파일 마지막 수정 시각은 반드시 지정되어야 하며, 파일 시스템에서 읽어옵니다. 파일 생성 시각은 선택적으로 지정될 수 있으며, 이미지일 경우 EXIF에서 추출을 시도합니다. 두 값 모두 클라이언트에서 암호화되어 서버에 저장됩니다.
2025-01-13 07:06:31 +09:00
408 changed files with 18749 additions and 8646 deletions

No files matched your search

+6 -3
View File
@@ -1,5 +1,6 @@
.git
node_modules
/Makefile
# Output
.output
@@ -10,13 +11,15 @@ node_modules
/build
/data
/library
/thumbnails
# OS
.DS_Store
Thumbs.db
# VSCode
# Editors
/.vscode
/.idea
# Env
.env
@@ -28,5 +31,5 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db
# E2E tests
/test/e2e/results
+6 -1
View File
@@ -1,9 +1,14 @@
# Required environment variables
DATABASE_PASSWORD=
SESSION_SECRET=
# Optional environment variables
DATABASE_URL=
DATABASE_HOST=
DATABASE_PORT=
DATABASE_USER=
DATABASE_NAME=
SESSION_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH=
THUMBNAILS_PATH=
+45
View File
@@ -0,0 +1,45 @@
name: Docker Image Build
on:
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+5 -3
View File
@@ -9,13 +9,15 @@ node_modules
/build
/data
/library
/thumbnails
# OS
.DS_Store
Thumbs.db
# VSCode
# Editors
/.vscode
/.idea
# Env
.env
@@ -27,5 +29,5 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db
# E2E tests
/test/e2e/results
-3
View File
@@ -3,8 +3,5 @@ package-lock.json
pnpm-lock.yaml
yarn.lock
# Output
/drizzle
# Documents
*.md
+1
View File
@@ -1,6 +1,7 @@
{
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css",
"overrides": [
{
"files": "*.svelte",
+5 -6
View File
@@ -2,16 +2,17 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN npm install -g pnpm@9
COPY pnpm-lock.yaml .
RUN npm install -g pnpm@12
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
# Build Stage
FROM base AS build
RUN pnpm fetch
COPY . .
RUN pnpm install --offline
RUN pnpm build
RUN pnpm install --offline && \
pnpm build && \
sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
# Deploy Stage
FROM base
@@ -21,9 +22,7 @@ COPY package.json .
RUN pnpm install --offline --prod
COPY --from=build /app/build ./build
COPY drizzle ./drizzle
EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"]
+3 -2
View File
@@ -23,7 +23,7 @@ vim .env # 아래를 참고하여 환경 변수를 설정해 주세요.
docker compose up --build -d
```
모든 데이터는 `./data` 디렉터리에 저장될 거예요.
모든 데이터는 `./data` 디렉터리에 아래에 저장될 거예요.
### Environment Variables
@@ -31,7 +31,8 @@ docker compose up --build -d
|이름|필수|기본값|설명|
|:-|:-:|:-:|:-|
|`SESSION_SECRET`|Y||Session ID의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|`DATABASE_PASSWORD`|Y||데이터베이스에 접근하기 위해 필요한 비밀번호예요. 안전한 값으로 설정해 주세요.|
|`SESSION_SECRET`|Y||Session ID의 서명에 사용되는 비밀번호예요. 안전한 값으로 설정해 주세요.|
|`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
+15
View File
@@ -0,0 +1,15 @@
services:
database:
image: postgres:17
restart: always
volumes:
- database:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${DATABASE_USER:-}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} # Required
- POSTGRES_DB=${DATABASE_NAME:-}
ports:
- ${DATABASE_PORT:-5432}:5432
volumes:
database:
+24 -2
View File
@@ -2,20 +2,42 @@ services:
server:
build: .
restart: unless-stopped
depends_on:
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data:/app/data
- ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails
environment:
# ArkVault
- DATABASE_URL=/app/data/database.sqlite
- DATABASE_HOST=database
- DATABASE_USER=arkvault
- DATABASE_PASSWORD=${DATABASE_PASSWORD:?} # Required
- SESSION_SECRET=${SESSION_SECRET:?} # Required
- SESSION_EXPIRES
- USER_CLIENT_CHALLENGE_EXPIRES
- SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails
# SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-}
- NODE_ENV=${NODE_ENV:-production}
ports:
- ${PORT:-80}:3000
database:
image: postgres:17-alpine
restart: unless-stopped
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/database:/var/lib/postgresql/data
environment:
- POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/server/db/schema",
dbCredentials: {
url: process.env.DATABASE_URL || "local.db",
},
verbose: true,
strict: true,
dialect: "sqlite",
});
-175
View File
@@ -1,175 +0,0 @@
CREATE TABLE `client` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`encryption_public_key` text NOT NULL,
`signature_public_key` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user_client` (
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`state` text DEFAULT 'challenging' NOT NULL,
PRIMARY KEY(`client_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_client_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`client_id`) REFERENCES `user_client`(`user_id`,`client_id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `directory` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`parent_id` integer,
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`encrypted_name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `directory_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`directory_id` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`new_name` text,
FOREIGN KEY (`directory_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `file` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`parent_id` integer,
`user_id` integer NOT NULL,
`path` text NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`hmac_secret_key_version` integer,
`content_hmac` text,
`content_type` text NOT NULL,
`encrypted_content_iv` text NOT NULL,
`encrypted_name` text NOT NULL,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `file_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`file_id` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`new_name` text,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `hmac_secret_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`state` text NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_key` text NOT NULL,
PRIMARY KEY(`user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `hmac_secret_key_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`hmac_secret_key_version` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`action_by` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`action_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `client_master_encryption_key` (
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`version` integer NOT NULL,
`encrypted_key` text NOT NULL,
`encrypted_key_signature` text NOT NULL,
PRIMARY KEY(`client_id`, `user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `master_encryption_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`state` text NOT NULL,
`retired_at` integer,
PRIMARY KEY(`user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `master_encryption_key_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`action_by` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`action_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer,
`created_at` integer NOT NULL,
`last_used_at` integer NOT NULL,
`last_used_by_ip` text,
`last_used_by_user_agent` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `session_upgrade_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`client_id` integer NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text NOT NULL,
`password` text NOT NULL,
`nickname` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_client_challenge_answer_unique` ON `user_client_challenge` (`answer`);--> statement-breakpoint
CREATE UNIQUE INDEX `directory_encrypted_data_encryption_key_unique` ON `directory` (`encrypted_data_encryption_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `file_path_unique` ON `file` (`path`);--> statement-breakpoint
CREATE UNIQUE INDEX `file_encrypted_data_encryption_key_unique` ON `file` (`encrypted_data_encryption_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `hmac_secret_key_encrypted_key_unique` ON `hmac_secret_key` (`encrypted_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_user_id_client_id_unique` ON `session` (`user_id`,`client_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_upgrade_challenge_session_id_unique` ON `session_upgrade_challenge` (`session_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_upgrade_challenge_answer_unique` ON `session_upgrade_challenge` (`answer`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);
File diff suppressed because it is too large. Load diff
-13
View File
@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1736704436996,
"tag": "0000_unknown_stark_industries",
"breakpoints": true
}
]
}
+50 -9
View File
@@ -1,21 +1,40 @@
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { loadConfig } from "@sveltejs/load-config";
import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
import { fileURLToPath } from "url";
const svelteConfig = (await loadConfig("./", { traverse: false }))?.config;
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config(
// Restricts the direction of dependencies between modules
const defineImportBoundaries = (boundaries) =>
Object.entries(boundaries).map(([files, regex]) => ({
files: [files],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [{ regex, message: "Violates the dependency direction between modules." }],
},
],
},
}));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
...tailwind.configs["flat/recommended"],
...svelte.configs.recommended,
tailwind.configs.recommended,
prettier,
...svelte.configs["flat/prettier"],
...svelte.configs.prettier,
{
languageOptions: {
globals: {
@@ -23,13 +42,35 @@ export default ts.config(
...globals.node,
},
},
settings: {
tailwindcss: {
cssConfigPath: "./src/app.css",
},
},
rules: {
"no-undef": "off",
},
},
...defineImportBoundaries({
// Pure code shared by the server, the client, and the service worker
"src/lib/shared/**": "^(#lib/(?!shared/)|#trpc/|#workers/|\\$app/|svelte($|/))",
"src/lib/server/**": "^(#lib/(client|features|ui)/|#trpc/)",
// Infrastructure of the browser
"src/lib/client/**": "^#lib/(server|features|ui)/",
// Domain-agnostic components
"src/lib/ui/**": "^(#lib/(server|client|features)/|#trpc/)",
"src/lib/features/**": "^#lib/server/",
"src/service-worker/**":
"^(#lib/(?!shared/|client/storage/|client/cache/file-cache\\.ts$)|#trpc/|#workers/)",
}),
{
files: ["**/*.svelte"],
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "kysely-ctl";
import { createDialect } from "./src/lib/server/db/dialect.ts";
// Empty values (e.g. `DATABASE_HOST=` in .env) are treated as unset, as in src/env.ts
const optional = (name: string) => process.env[name] || undefined;
export default defineConfig({
dialect: createDialect({
host: optional("DATABASE_HOST"),
port: optional("DATABASE_PORT") ? Number(optional("DATABASE_PORT")) : undefined,
user: optional("DATABASE_USER"),
password: optional("DATABASE_PASSWORD"),
name: optional("DATABASE_NAME"),
}),
migrations: {
migrationFolder: "./src/lib/server/db/migrations",
},
});
+62 -40
View File
@@ -1,64 +1,86 @@
{
"name": "arkvault",
"private": true,
"version": "0.2.0",
"version": "0.9.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:remote": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.ts.net vite dev --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --noEmit --project ./src/service-worker",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "bash test/e2e/run.sh",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "kysely migrate"
},
"devDependencies": {
"@eslint/compat": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.12",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/better-sqlite3": "^7.6.12",
"@eslint/compat": "^2.1.1",
"@eslint/js": "^10.0.1",
"@iconify-json/material-symbols": "^1.2.95",
"@noble/hashes": "^2.4.0",
"@playwright/test": "1.64.0",
"@sveltejs/adapter-node": "^6.0.0",
"@sveltejs/kit": "^3.0.1",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/svelte-virtual": "^3.13.40",
"@testcontainers/postgresql": "^12.2.0",
"@trpc/client": "^11.19.0",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7",
"autoprefixer": "^10.4.20",
"dexie": "^4.0.10",
"drizzle-kit": "^0.22.8",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-tailwindcss": "^3.17.5",
"@types/ms": "^2.1.0",
"@types/node-schedule": "^2.1.8",
"@types/pg": "^8.23.1",
"axios": "^1.20.0",
"dexie": "^4.4.6",
"es-hangul": "^2.4.0",
"eslint": "^10.12.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1",
"eslint-plugin-tailwindcss": "^4.4.0",
"exifreader": "^4.48.0",
"file-saver": "^2.0.5",
"globals": "^15.14.0",
"globals": "^17.13.0",
"heic2any": "^0.0.4",
"mime": "^4.0.6",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.1",
"svelte-check": "^4.1.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.19.1",
"unplugin-icons": "^0.22.0",
"vite": "^5.4.11"
"kysely-ctl": "^0.21.0",
"lru-cache": "^11.5.3",
"mime": "^4.1.0",
"p-limit": "^7.3.3",
"prettier": "^3.9.10",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.57.2",
"svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.1",
"unplugin-icons": "^24.0.0",
"vite": "^8.3.4",
"vitest": "^5.0.3"
},
"dependencies": {
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.2",
"drizzle-orm": "^0.33.0",
"@trpc/server": "^11.19.0",
"argon2": "^0.45.1",
"kysely": "^0.29.6",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"uuid": "^11.0.4",
"zod": "^3.24.1"
"pg": "^8.23.1",
"superjson": "^2.2.6",
"zod": "^4.6.5"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^9.0.0"
"node": "^22.17.0",
"pnpm": "^12.0.0"
},
"imports": {
"#lib/*": "./src/lib/*",
"#trpc/*": "./src/trpc/*",
"#workers/*": "./src/workers/*"
}
}
+3291 -2474
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
allowBuilds:
argon2: true
cpu-features: false
exifreader: false
protobufjs: false
ssh2: false
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+48 -3
View File
@@ -1,3 +1,48 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "tailwindcss";
@theme {
--color-primary-100: #ffe3e3;
--color-primary-200: #ffc9c9;
--color-primary-300: #ffa8a8;
--color-primary-400: #ff8787;
--color-primary-500: #ff6b6b;
--color-primary-600: #fa5252;
--color-primary-700: #f03e3e;
--color-primary-800: #e03131;
--color-primary-900: #c92a2a;
--color-primary-000: #fff5f5;
--color-gray-100: #f1f3f5;
--color-gray-200: #e9ecef;
--color-gray-300: #dee2e6;
--color-gray-400: #ced4da;
--color-gray-500: #adb5bd;
--color-gray-600: #868e96;
--color-gray-700: #495057;
--color-gray-800: #343a40;
--color-gray-900: #212529;
--color-gray-000: #f8f9fa;
--font-sans:
"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto,
"Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic",
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
+45
View File
@@ -0,0 +1,45 @@
import { building } from "$app/env";
import { defineEnvVars } from "@sveltejs/kit/env";
import ms, { type StringValue } from "ms";
// Empty values (e.g. `LIBRARY_PATH=` in .env) are treated as unset
const required = (value: string | undefined) => {
if (!value && !building) throw new Error("Required environment variable not set");
return value ?? "";
};
const optional = (value: string | undefined) => value || undefined;
const withDefault = (defaultValue: string) => (value: string | undefined) => value || defaultValue;
const duration = (defaultValue: StringValue) => (value: string | undefined) => {
const result = ms((value || defaultValue) as StringValue);
if (!Number.isFinite(result)) throw new Error(`Invalid duration: ${value}`);
return result;
};
export const variables = defineEnvVars({
NODE_ENV: { schema: withDefault("development") },
DATABASE_HOST: { schema: optional },
DATABASE_PORT: {
schema: (value) => {
if (!value) return undefined;
const port = Number(value);
if (!Number.isInteger(port)) throw new Error(`Invalid port: ${value}`);
return port;
},
},
DATABASE_USER: { schema: optional },
DATABASE_PASSWORD: { schema: required },
DATABASE_NAME: { schema: optional },
SESSION_SECRET: { schema: required },
SESSION_EXPIRES: { schema: duration("14d") },
USER_CLIENT_CHALLENGE_EXPIRES: { schema: duration("5m") },
SESSION_UPGRADE_CHALLENGE_EXPIRES: { schema: duration("5m") },
LIBRARY_PATH: { schema: withDefault("library") },
THUMBNAILS_PATH: { schema: withDefault("thumbnails") },
});
+17 -27
View File
@@ -1,33 +1,23 @@
import type { ClientInit } from "@sveltejs/kit";
import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
import type { ClientInit } from "@sveltejs/kit/hooks";
import { cleanupFileCaches } from "#lib/client/cache/index.ts";
import { keyring } from "#lib/client/keys/index.ts";
import { listenForServiceWorkerMessages } from "#lib/client/service-worker.ts";
import { cleanupDanglingInfos } from "#lib/client/storage/indexed-db/index.ts";
const prepareClientKeyStore = async () => {
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
getClientKey("encrypt"),
getClientKey("decrypt"),
getClientKey("sign"),
getClientKey("verify"),
]);
if (encryptKey && decryptKey && signKey && verifyKey) {
clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey });
}
};
const prepareMasterKeyStore = async () => {
const masterKeys = await getMasterKeys();
if (masterKeys.length > 0) {
masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey])));
}
};
const prepareHmacSecretStore = async () => {
const hmacSecrets = await getHmacSecrets();
if (hmacSecrets.length > 0) {
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
const requestPersistentStorage = async () => {
const isPersistent = await navigator.storage.persist();
if (isPersistent) {
console.log("[ArkVault] Persistent storage granted.");
} else {
console.warn("[ArkVault] Persistent storage not granted.");
}
};
export const init: ClientInit = async () => {
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
listenForServiceWorkerMessages();
await Promise.all([requestPersistentStorage(), keyring.load()]);
cleanupDanglingInfos(); // Intended
cleanupFileCaches().catch(console.error);
};
+22 -14
View File
@@ -1,22 +1,30 @@
import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { sequence, type ServerInit } from "@sveltejs/kit/hooks";
import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/drizzle";
import { migrateDB } from "#lib/server/db/kysely.ts";
import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.ts";
import {
cleanupExpiredSessions,
cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
AuthService,
ClientService,
FileDeletionService,
UploadService,
} from "#lib/server/services/index.ts";
export const init: ServerInit = () => {
migrateDB();
export const init: ServerInit = async () => {
await migrateDB();
schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges();
cleanupExpiredSessions();
cleanupExpiredSessionUpgradeChallenges();
schedule.scheduleJob("0 * * * *", async () => {
// Failures are logged and retried at the next run, so they don't crash the server
const results = await Promise.allSettled([
AuthService.cleanupExpiredSessions(),
ClientService.cleanupExpiredChallenges(),
UploadService.cleanupExpiredUploadSessions(),
]);
results.forEach((result) => {
if (result.status === "rejected") console.error(result.reason);
});
FileDeletionService.schedulePendingFileDeletions();
});
FileDeletionService.schedulePendingFileDeletions();
};
export const handle = sequence(setAgentInfo, authenticate);
+185
View File
@@ -0,0 +1,185 @@
import {
deleteFileCacheIndex,
getFileCacheIndex as getAllFileCacheIndexes,
getFileCacheIndexOf,
storeFileCacheIndex,
updateFileCacheRetrievedAt,
} from "#lib/client/storage/indexed-db/cache-index.ts";
import {
createWritable,
deleteDirectory,
deleteFile,
deleteFilesInDirectory,
getFile,
listDirectoryNames,
listFileNames,
moveFile,
} from "#lib/client/storage/opfs.ts";
import { CHUNK_SIZE, getChunkCount } from "#lib/shared/format/index.ts";
// Caches the plaintext of files in chunks, so that partially loaded files (e.g. videos which were
// partially played) can also be cached. Chunk `i` starts at `i * CHUNK_SIZE` of the plaintext,
// except that legacy files are cached as a single chunk. Used by both the pages and the service
// worker, so the index is always read from IndexedDB.
export interface FileCacheLayout {
totalSize: number;
chunkCount: number;
}
const getDirectoryPath = (fileId: number) => `/cache/file/${fileId}`;
const getChunkPath = (fileId: number, chunkIndex: number) =>
`${getDirectoryPath(fileId)}/${chunkIndex}`;
const getChunkSize = ({ totalSize, chunkCount }: FileCacheLayout, chunkIndex: number) => {
return chunkCount === 1 ? totalSize : Math.min(CHUNK_SIZE, totalSize - chunkIndex * CHUNK_SIZE);
};
export const getFileCacheIndex = async () => {
return await getAllFileCacheIndexes();
};
export const getCachedChunkIndexes = async (fileId: number) => {
const names = await listFileNames(getDirectoryPath(fileId));
return new Set(names.filter((name) => /^\d+$/.test(name)).map(Number));
};
export const readCachedChunk = async (fileId: number, chunkIndex: number) => {
return await getFile(getChunkPath(fileId, chunkIndex));
};
export const touchFileCache = async (fileId: number) => {
await updateFileCacheRetrievedAt(fileId, new Date());
};
const updateIndex = async (fileId: number, layout: FileCacheLayout) => {
const chunkIndexes = await getCachedChunkIndexes(fileId);
const size = chunkIndexes
.values()
.filter((chunkIndex) => chunkIndex < layout.chunkCount)
.reduce((size, chunkIndex) => size + getChunkSize(layout, chunkIndex), 0);
const now = new Date();
await storeFileCacheIndex({
fileId,
cachedAt: (await getFileCacheIndexOf(fileId))?.cachedAt ?? now,
lastRetrievedAt: now,
size,
totalSize: layout.totalSize,
chunkCount: layout.chunkCount,
isComplete: size === layout.totalSize,
});
};
// Writes of the same file are serialized so that the index isn't updated with a stale state
const writeQueues = new Map<number, Promise<unknown>>();
const enqueueWrite = <T>(fileId: number, write: () => Promise<T>) => {
const result = (writeQueues.get(fileId) ?? Promise.resolve()).catch(() => {}).then(write);
const settled = result.catch(() => {});
writeQueues.set(fileId, settled);
void settled.then(() => {
if (writeQueues.get(fileId) === settled) {
writeQueues.delete(fileId);
}
});
return result;
};
export const storeCachedChunk = (
fileId: number,
chunkIndex: number,
data: Uint8Array | Blob,
layout: FileCacheLayout,
) => enqueueWrite(fileId, () => writeChunk(fileId, chunkIndex, data, layout));
const writeChunk = async (
fileId: number,
chunkIndex: number,
data: Uint8Array | Blob,
layout: FileCacheLayout,
) => {
const path = getChunkPath(fileId, chunkIndex);
const tempPath = `${path}.${crypto.randomUUID()}.tmp`;
// Written to a temporary file first, so that incomplete chunks are never visible
const writable = await createWritable(tempPath);
try {
await writable.write(data as FileSystemWriteChunkType);
await writable.close();
await moveFile(tempPath, path);
} catch (e) {
await writable.abort().catch(() => {});
await deleteFile(tempPath).catch(() => {});
throw e;
}
await updateIndex(fileId, layout);
};
const UPLOAD_CACHE_DIRECTORY = "/cache/upload";
const STALE_UPLOAD_CACHE_AGE = 24 * 3600 * 1000; // 24 hours
// Caches the plaintext of a file while it is being uploaded. Chunks are written to a temporary
// directory, and moved to the cache once the id of the file is known.
export const createUploadFileCache = (totalSize: number) => {
const layout = { totalSize, chunkCount: getChunkCount(totalSize) };
// Prefixed with the creation time to find stale ones (e.g. the tab was closed while uploading)
const directoryPath = `${UPLOAD_CACHE_DIRECTORY}/${Date.now()}-${crypto.randomUUID()}`;
const writtenChunkIndexes = new Set<number>();
let isFailed = false;
const discard = async () => {
isFailed = true;
await deleteDirectory(directoryPath).catch(() => {});
};
return {
// Failing to cache shouldn't fail the upload, so errors are ignored
async writeChunk(chunkIndex: number, data: ArrayBuffer) {
if (isFailed) return;
try {
const writable = await createWritable(`${directoryPath}/${chunkIndex}`);
await writable.write(data);
await writable.close();
writtenChunkIndexes.add(chunkIndex);
} catch (e) {
console.error(e);
await discard();
}
},
async commit(fileId: number) {
if (isFailed || writtenChunkIndexes.size !== layout.chunkCount) {
await discard();
return;
}
await enqueueWrite(fileId, async () => {
for (const chunkIndex of writtenChunkIndexes) {
await moveFile(`${directoryPath}/${chunkIndex}`, getChunkPath(fileId, chunkIndex));
}
await updateIndex(fileId, layout);
}).finally(discard);
},
discard,
};
};
export const deleteFileCache = async (fileId: number) => {
await deleteFileCacheIndex(fileId);
await deleteDirectory(getDirectoryPath(fileId));
};
export const cleanupFileCaches = async () => {
// Files were cached as single files directly under `/cache` before
await deleteFilesInDirectory("/cache");
// Uploads might be in progress in other tabs, so only stale ones are deleted
for (const name of await listDirectoryNames(UPLOAD_CACHE_DIRECTORY)) {
const createdAt = parseInt(name, 10);
if (!(Date.now() - createdAt < STALE_UPLOAD_CACHE_AGE)) {
await deleteDirectory(`${UPLOAD_CACHE_DIRECTORY}/${name}`);
}
}
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./file-cache.ts";
export * from "./thumbnail-cache.svelte.ts";
+86
View File
@@ -0,0 +1,86 @@
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
import { LRUCache } from "lru-cache";
import { deleteDirectory, deleteFile, readFile, writeFile } from "#lib/client/storage/opfs.ts";
import { decryptChunk, encodeToBase64 } from "#lib/shared/crypto/index.ts";
export interface Thumbnail {
url?: string;
}
const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
};
// Thumbnails are reactive so that they can be updated in place
const loadedThumbnails = new LRUCache<number, Thumbnail>({ max: 100 });
const loadingThumbnails = new Map<number, Thumbnail>();
const fetchFromOpfs = async (fileId: number) => {
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (thumbnailBuffer) {
return getThumbnailUrl(thumbnailBuffer);
}
};
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
};
export const getFileThumbnail = (file: {
id: number;
contentType: string;
dataKey?: { key: CryptoKey };
}) => {
if (!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))) {
return undefined;
}
const thumbnail = loadedThumbnails.get(file.id) ?? loadingThumbnails.get(file.id);
if (thumbnail) return thumbnail;
const loadingThumbnail: Thumbnail = $state({});
loadingThumbnails.set(file.id, loadingThumbnail);
fetchFromOpfs(file.id)
.then((url) => url ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
.then((url) => {
if (url) {
loadingThumbnail.url = url;
loadedThumbnails.set(file.id, loadingThumbnail);
}
})
.catch(console.error)
.finally(() => {
loadingThumbnails.delete(file.id);
});
return loadingThumbnail;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
const url = getThumbnailUrl(thumbnailBuffer);
const thumbnail = loadedThumbnails.get(fileId) ?? loadingThumbnails.get(fileId);
if (thumbnail) {
thumbnail.url = url;
} else {
const newThumbnail: Thumbnail = $state({ url });
loadedThumbnails.set(fileId, newThumbnail);
}
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory(`/thumbnail/file`);
};
+29
View File
@@ -0,0 +1,29 @@
import type { ComputeMessage, ResultMessage } from "#workers/hmac.ts";
import HmacWorker from "#workers/hmac.ts?worker";
// Computes HMAC of large blobs incrementally in a worker, which WebCrypto doesn't support
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
const stream = message.stream();
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
const worker = new HmacWorker();
return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
if ("result" in data) {
resolve(data.result);
} else {
reject(data.error);
}
worker.terminate();
};
worker.onerror = ({ error }) => {
reject(error);
worker.terminate();
};
worker.postMessage({ stream, key: hmacSecretRaw } satisfies ComputeMessage, {
transfer: [stream, hmacSecretRaw.buffer],
});
});
};
+88
View File
@@ -0,0 +1,88 @@
import {
concatenateBuffers,
digestMessage,
exportRSAKey,
exportRSAKeyToBase64,
generateEncryptionKeyPair,
generateHmacSecret,
generateMasterKey,
generateSigningKeyPair,
importEncryptionKeyPairFromBase64,
importSigningKeyPairFromBase64,
makeAESKeyNonextractable,
makeRSAKeyNonextractable,
wrapHmacSecret,
wrapMasterKey,
} from "#lib/shared/crypto/index.ts";
import { keyring } from "./keyring.svelte.ts";
import { deserializeClientKeys } from "./serialization.ts";
// Generates new client keys and keeps them in the keyring without persisting
export const generateClientKeys = async () => {
const { encryptKey, decryptKey } = await generateEncryptionKeyPair();
const { signKey, verifyKey } = await generateSigningKeyPair();
keyring.setClientKeys({
encryptKey,
decryptKey: await makeRSAKeyNonextractable(decryptKey),
signKey: await makeRSAKeyNonextractable(signKey),
verifyKey,
});
return {
encryptKey,
encryptKeyBase64: await exportRSAKeyToBase64(encryptKey),
decryptKeyBase64: await exportRSAKeyToBase64(decryptKey),
signKeyBase64: await exportRSAKeyToBase64(signKey),
verifyKeyBase64: await exportRSAKeyToBase64(verifyKey),
};
};
export const importClientKeys = async (clientKeysSerialized: string) => {
const clientKeys = deserializeClientKeys(clientKeysSerialized);
if (!clientKeys) return false;
const { encryptKey, decryptKey } = await importEncryptionKeyPairFromBase64(
clientKeys.encryptKeyBase64,
clientKeys.decryptKeyBase64,
);
const { signKey, verifyKey } = await importSigningKeyPairFromBase64(
clientKeys.signKeyBase64,
clientKeys.verifyKeyBase64,
);
keyring.setClientKeys({
encryptKey,
decryptKey: await makeRSAKeyNonextractable(decryptKey),
signKey: await makeRSAKeyNonextractable(signKey),
verifyKey,
});
return true;
};
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
const { masterKey } = await generateMasterKey();
return {
masterKey: await makeAESKeyNonextractable(masterKey),
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
};
};
export const generateInitialHmacSecret = async (masterKey: CryptoKey) => {
const { hmacSecret } = await generateHmacSecret();
return {
hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey),
};
};
export const generatePublicKeyFingerprint = async (encryptKey: CryptoKey, verifyKey: CryptoKey) => {
const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey);
const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey);
const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer));
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
.toUpperCase()
.match(/.{1,4}/g)!
.join(" ");
};
+5
View File
@@ -0,0 +1,5 @@
export * from "./generation.ts";
export * from "./keyring.svelte.ts";
export * from "./serialization.ts";
export * from "./sync.ts";
export * from "./types.ts";
+113
View File
@@ -0,0 +1,113 @@
import {
getClientKey,
getHmacSecrets,
getMasterKeys,
storeClientKey,
storeHmacSecrets,
storeMasterKeys,
} from "#lib/client/storage/indexed-db/index.ts";
import type { ClientKeys, HmacSecret, MasterKey } from "./types.ts";
const findLatestActive = <T extends { version: number; state: string }>(
entries: ReadonlyMap<number, T>,
) => {
return entries
.values()
.filter(({ state }) => state === "active")
.reduce<T | undefined>(
(latest, entry) => ((latest?.version ?? 0) > entry.version ? latest : entry),
undefined,
);
};
// Holds the keys of this device and the user. Keys are persisted in IndexedDB.
class Keyring {
clientKeys = $state.raw<ClientKeys | null>(null);
masterKeys = $state.raw<ReadonlyMap<number, MasterKey>>(new Map());
hmacSecrets = $state.raw<ReadonlyMap<number, HmacSecret>>(new Map());
async load() {
const [encryptKey, decryptKey, signKey, verifyKey, masterKeys, hmacSecrets] = await Promise.all(
[
getClientKey("encrypt"),
getClientKey("decrypt"),
getClientKey("sign"),
getClientKey("verify"),
getMasterKeys(),
getHmacSecrets(),
],
);
if (encryptKey && decryptKey && signKey && verifyKey) {
this.clientKeys = { encryptKey, decryptKey, signKey, verifyKey };
}
this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]));
this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]));
}
// Client keys are kept in memory only until they are registered and persisted
setClientKeys(clientKeys: ClientKeys) {
this.clientKeys = clientKeys;
}
async persistClientKeys() {
const clientKeys = this.requireClientKeys();
await Promise.all([
storeClientKey(clientKeys.encryptKey, "encrypt"),
storeClientKey(clientKeys.decryptKey, "decrypt"),
storeClientKey(clientKeys.signKey, "sign"),
storeClientKey(clientKeys.verifyKey, "verify"),
]);
}
async setMasterKeys(masterKeys: MasterKey[]) {
await storeMasterKeys(masterKeys);
this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]));
}
async setHmacSecrets(hmacSecrets: HmacSecret[]) {
await storeHmacSecrets(hmacSecrets);
this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]));
}
get hasMasterKeys() {
return this.masterKeys.size > 0;
}
get hasHmacSecrets() {
return this.hmacSecrets.size > 0;
}
requireClientKeys() {
if (!this.clientKeys) {
throw new Error("Client keys not found");
}
return this.clientKeys;
}
getMasterKey(version: number) {
const masterKey = this.masterKeys.get(version);
if (!masterKey) {
throw new Error(`Master key (version ${version}) not found`);
}
return masterKey;
}
// The master key used to encrypt new data
get activeMasterKey() {
const masterKey = findLatestActive(this.masterKeys);
if (!masterKey) {
throw new Error("Active master key not found");
}
return masterKey;
}
get activeHmacSecret() {
const hmacSecret = findLatestActive(this.hmacSecrets);
if (!hmacSecret) {
throw new Error("Active HMAC secret not found");
}
return hmacSecret;
}
}
export const keyring = new Keyring();
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod";
const SerializedClientKeysSchema = z.intersection(
z.object({
generator: z.literal("ArkVault"),
exportedAt: z.iso.datetime(),
}),
z.object({
version: z.literal(1),
encryptKey: z.base64().nonempty(),
decryptKey: z.base64().nonempty(),
signKey: z.base64().nonempty(),
verifyKey: z.base64().nonempty(),
}),
);
type SerializedClientKeys = z.infer<typeof SerializedClientKeysSchema>;
type DeserializedClientKeys = {
encryptKeyBase64: string;
decryptKeyBase64: string;
signKeyBase64: string;
verifyKeyBase64: string;
};
export const serializeClientKeys = ({
encryptKeyBase64,
decryptKeyBase64,
signKeyBase64,
verifyKeyBase64,
}: DeserializedClientKeys) => {
return JSON.stringify({
version: 1,
generator: "ArkVault",
exportedAt: new Date().toISOString(),
encryptKey: encryptKeyBase64,
decryptKey: decryptKeyBase64,
signKey: signKeyBase64,
verifyKey: verifyKeyBase64,
} satisfies SerializedClientKeys);
};
export const deserializeClientKeys = (serialized: string) => {
const zodRes = SerializedClientKeysSchema.safeParse(JSON.parse(serialized));
if (zodRes.success) {
return {
encryptKeyBase64: zodRes.data.encryptKey,
decryptKeyBase64: zodRes.data.decryptKey,
signKeyBase64: zodRes.data.signKey,
verifyKeyBase64: zodRes.data.verifyKey,
} satisfies DeserializedClientKeys;
}
return undefined;
};
+182
View File
@@ -0,0 +1,182 @@
import {
decryptChallenge,
encodeToBase64,
exportRSAKeyToBase64,
signMasterKeyWrapped,
signMessageRSA,
unwrapHmacSecret,
unwrapMasterKey,
verifyMasterKeyWrapped,
} from "#lib/shared/crypto/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { keyring } from "./keyring.svelte.ts";
import type { ClientKeys } from "./types.ts";
// Synchronizes keys with the server
const exportPublicKeys = async ({ encryptKey, verifyKey }: ClientKeys) => ({
encPubKey: await exportRSAKeyToBase64(encryptKey),
sigPubKey: await exportRSAKeyToBase64(verifyKey),
});
const answerChallenge = async (challenge: string, { decryptKey, signKey }: ClientKeys) => {
const answer = await decryptChallenge(challenge, decryptKey);
return encodeToBase64(await signMessageRSA(answer, signKey));
};
export const requestSessionUpgrade = async (force = false) => {
const clientKeys = keyring.requireClientKeys();
let id, challenge;
try {
({ id, challenge } = await trpc.auth.upgrade.mutate(await exportPublicKeys(clientKeys)));
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const;
}
return [false] as const;
}
try {
await trpc.auth.verifyUpgrade.mutate({
id,
answerSig: await answerChallenge(challenge, clientKeys),
force,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const;
}
return [false] as const;
}
return [true] as const;
};
export const requestClientRegistration = async () => {
const clientKeys = keyring.requireClientKeys();
try {
const { id, challenge } = await trpc.client.register.mutate(await exportPublicKeys(clientKeys));
await trpc.client.verify.mutate({
id,
answerSig: await answerChallenge(challenge, clientKeys),
});
return true;
} catch (e) {
console.error(e);
return false;
}
};
export const requestClientRegistrationAndSessionUpgrade = async (force: boolean) => {
const [res, error] = await requestSessionUpgrade(force);
if (error === undefined) return [res] as const;
if (error === "Unregistered client" && !(await requestClientRegistration())) {
return [false] as const;
} else if (error === "Already logged in") {
return [false, force ? undefined : error] as const;
}
return [(await requestSessionUpgrade())[0]] as const;
};
export const registerInitialMasterKeyAndHmacSecret = async (
masterKeyWrapped: string,
hmacSecretWrapped: string,
) => {
const { signKey } = keyring.requireClientKeys();
try {
await trpc.mek.registerInitial.mutate({
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
} catch (e) {
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
return true; // Already registered by another client
}
console.error(e);
return false;
}
try {
await trpc.hsk.registerInitial.mutate({
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return true;
} catch (e) {
console.error(e);
return false;
}
};
export const downloadHmacSecrets = async () => {
let hmacSecretsWrapped;
try {
hmacSecretsWrapped = await trpc.hsk.list.query();
} catch (e) {
console.error(e);
return false;
}
const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, mekVersion, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(
hmacSecretWrapped,
keyring.getMasterKey(mekVersion).key,
);
return { version, state, secret: hmacSecret };
}),
);
await keyring.setHmacSecrets(hmacSecrets);
return true;
};
// Downloads the master keys (and HMAC secrets) once this client has been approved
export const downloadMasterKeys = async () => {
const { decryptKey, verifyKey } = keyring.requireClientKeys();
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc.mek.list.query();
} catch (e) {
console.error(e);
return false;
}
const masterKeys = await Promise.all(
masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey);
return {
version,
state,
key: masterKey,
isValid: await verifyMasterKeyWrapped(
masterKeyWrapped,
version,
masterKeyWrappedSig,
verifyKey,
),
};
},
),
);
if (masterKeys.length === 0 || !masterKeys.every(({ isValid }) => isValid)) return false;
await keyring.setMasterKeys(
masterKeys.map(({ version, state, key }) => ({ version, state, key })),
);
await downloadHmacSecrets();
return true;
};
export const ensureHmacSecrets = async () => {
if (!keyring.hasHmacSecrets && !(await downloadHmacSecrets())) {
throw new Error("Failed to download HMAC secrets");
}
};
+18
View File
@@ -0,0 +1,18 @@
export interface ClientKeys {
encryptKey: CryptoKey;
decryptKey: CryptoKey;
signKey: CryptoKey;
verifyKey: CryptoKey;
}
export interface MasterKey {
version: number;
state: "active" | "retired";
key: CryptoKey;
}
export interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
+52
View File
@@ -0,0 +1,52 @@
import ExifReader from "exifreader";
const isInteger = (value: number | undefined): value is number => Number.isInteger(value);
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
if (!dateTimeOriginal) return undefined;
const [date, time] = dateTimeOriginal.split(" ");
if (!date || !time) return undefined;
const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number);
// Hours, minutes, and seconds may be 0, so they are checked differently
if (!year || !month || !day || !isInteger(hour) || !isInteger(minute) || !isInteger(second)) {
return undefined;
}
if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone
return new Date(year, month - 1, day, hour, minute, second);
}
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
const EXIF_SEARCH_SIZE = 1024 * 1024; // 1 MiB
const MAX_FULL_READ_SIZE = 64 * 1024 * 1024; // 64 MiB
const tryExtractExifDateTime = (buffer: ArrayBuffer) => {
try {
return extractExifDateTime(buffer);
} catch {
return undefined;
}
};
// EXIF is usually at the beginning of the file, so the whole file is read only if not found
export const readExifDateTime = async (file: Blob) => {
const dateTime = tryExtractExifDateTime(await file.slice(0, EXIF_SEARCH_SIZE).arrayBuffer());
if (dateTime || file.size <= EXIF_SEARCH_SIZE || file.size > MAX_FULL_READ_SIZE) {
return dateTime;
}
return tryExtractExifDateTime(await file.arrayBuffer());
};
+5
View File
@@ -0,0 +1,5 @@
// Chromium can't decode HEIC images, so they are converted to be displayed
export const convertHeic = async (blob: Blob, toType: "image/png" | "image/jpeg") => {
const { default: heic2any } = await import("heic2any");
return (await heic2any({ blob, toType })) as Blob;
};
+149
View File
@@ -0,0 +1,149 @@
import { convertHeic } from "./heic.ts";
const scaleSize = (width: number, height: number, targetSize: number) => {
if (width <= targetSize || height <= targetSize) {
return { width, height };
}
const scale = targetSize / Math.min(width, height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
};
};
const capture = (
width: number,
height: number,
drawer: (context: CanvasRenderingContext2D, width: number, height: number) => void,
targetSize = 250,
) => {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas");
const { width: scaledWidth, height: scaledHeight } = scaleSize(width, height, targetSize);
canvas.width = scaledWidth;
canvas.height = scaledHeight;
const context = canvas.getContext("2d");
if (!context) {
return reject(new Error("Failed to generate thumbnail"));
}
drawer(context, scaledWidth, scaledHeight);
canvas.toBlob((blob) => {
if (blob && blob.type === "image/webp") {
resolve(blob);
} else {
reject(new Error("Failed to generate thumbnail"));
}
}, "image/webp");
});
};
const generateImageThumbnail = (imageUrl: string) => {
return new Promise<Blob>((resolve, reject) => {
const image = new Image();
image.onload = () => {
capture(image.width, image.height, (context, width, height) => {
context.drawImage(image, 0, 0, width, height);
})
.then(resolve)
.catch(reject);
};
image.onerror = reject;
image.src = imageUrl;
});
};
export const captureVideoThumbnail = (video: HTMLVideoElement) => {
return capture(video.videoWidth, video.videoHeight, (context, width, height) => {
context.drawImage(video, 0, 0, width, height);
});
};
const VIDEO_THUMBNAIL_TIMEOUT_MS = 30_000;
const generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video");
let isCaptured = false;
const cleanup = () => {
clearTimeout(timeout);
video.onloadedmetadata = video.onloadeddata = video.onseeked = video.onerror = null;
video.removeAttribute("src");
video.load(); // Stops loading the video
};
const fail = (reason: unknown) => {
cleanup();
reject(reason);
};
const captureFrame = () => {
if (isCaptured || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
isCaptured = true;
captureVideoThumbnail(video).then(resolve, reject).finally(cleanup);
};
// Streamed videos may stall without any error
const timeout = setTimeout(
() => fail(new Error("Timed out while loading the video")),
VIDEO_THUMBNAIL_TIMEOUT_MS,
);
video.onloadedmetadata = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return fail(new Error("Invalid video size"));
}
// Frame callbacks aren't guaranteed for videos not in the document, so also wait for seeking
video.requestVideoFrameCallback(captureFrame);
video.onseeked = captureFrame;
if (time === 0) {
video.onloadeddata = captureFrame;
}
video.currentTime = Math.min(time, video.duration);
};
video.onerror = () => fail(video.error);
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoUrl;
});
};
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
try {
if (contentType.startsWith("image/")) {
try {
return await generateImageThumbnail(url);
} catch {
if (contentType !== "image/heic") return null;
const blob = await (await fetch(url)).blob();
const png = URL.createObjectURL(await convertHeic(blob, "image/png"));
try {
return await generateImageThumbnail(png);
} finally {
URL.revokeObjectURL(png);
}
}
} else if (contentType.startsWith("video/")) {
return await generateVideoThumbnail(url);
}
return null;
} catch {
return null;
}
};
export const generateThumbnail = async (blob: Blob) => {
const url = URL.createObjectURL(blob);
try {
return await generateThumbnailFromUrl(url, blob.type);
} finally {
URL.revokeObjectURL(url);
}
};
+80
View File
@@ -0,0 +1,80 @@
import { LRUCache } from "lru-cache";
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts";
import {
DECRYPTED_FILE_URL_PREFIX,
MAX_PREPARED_FILES,
type FileMetadata,
type ServiceWorkerMessage,
type ServiceWorkerResponse,
} from "#lib/shared/service-worker.ts";
const PREPARE_TIMEOUT_MS = 5000;
// The service worker may be terminated at any time, losing its in-memory metadata.
// Keep a copy here so that it can be re-requested on demand.
const preparedFiles = new LRUCache<number, FileMetadata>({ max: MAX_PREPARED_FILES });
let isListening = false;
export const listenForServiceWorkerMessages = () => {
if (isListening) return;
isListening = true;
navigator.serviceWorker.addEventListener(
"message",
(event: MessageEvent<ServiceWorkerResponse>) => {
switch (event.data.type) {
case "decryption-metadata-request":
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
break;
case "download-progress":
handleDownloadProgress(event.data);
break;
}
},
);
};
const getServiceWorker = async () => {
const registration = await navigator.serviceWorker.ready;
const sw = registration.active;
if (!sw) {
throw new Error("Service worker not activated");
}
return sw;
};
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
preparedFiles.set(id, metadata);
listenForServiceWorkerMessages();
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Service worker timeout")),
PREPARE_TIMEOUT_MS,
);
const handler = (event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-ready" && event.data.fileId === id) {
clearTimeout(timeout);
navigator.serviceWorker.removeEventListener("message", handler);
resolve();
}
};
navigator.serviceWorker.addEventListener("message", handler);
sw.postMessage({
type: "decryption-prepare",
fileId: id,
...metadata,
} satisfies ServiceWorkerMessage);
});
};
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;
// Lets the service worker download, decrypt, and save the file
export const saveDecryptedFile = (id: number, filename: string) => {
const link = document.createElement("a");
link.href = `${getDecryptedFileUrl(id)}?download=${encodeURIComponent(filename)}`;
link.click();
};
@@ -0,0 +1,46 @@
import { Dexie, type EntityTable } from "dexie";
export interface FileCacheIndex {
fileId: number;
cachedAt: Date;
lastRetrievedAt: Date;
// Files are cached in chunks, possibly partially (e.g. videos which were partially played)
size: number;
totalSize: number;
chunkCount: number;
isComplete: boolean;
}
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
fileCache: EntityTable<FileCacheIndex, "fileId">;
};
// Files were cached as single files before version 2
cacheIndex
.version(2)
.stores({
fileCache: "fileId",
})
.upgrade(async (trx) => {
await trx.table("fileCache").clear();
});
export const getFileCacheIndex = async () => {
return await cacheIndex.fileCache.toArray();
};
export const getFileCacheIndexOf = async (fileId: number) => {
return await cacheIndex.fileCache.get(fileId);
};
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
await cacheIndex.fileCache.put(fileCacheIndex);
};
export const updateFileCacheRetrievedAt = async (fileId: number, lastRetrievedAt: Date) => {
await cacheIndex.fileCache.update(fileId, { lastRetrievedAt });
};
export const deleteFileCacheIndex = async (fileId: number) => {
await cacheIndex.fileCache.delete(fileId);
};
@@ -0,0 +1,212 @@
import { Dexie, type EntityTable } from "dexie";
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
name: string;
isFavorite: boolean;
}
interface FileInfo {
id: number;
parentId: DirectoryId;
name: string;
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds?: number[];
isFavorite: boolean;
}
interface CategoryInfo {
id: number;
parentId: CategoryId;
name: string;
files?: { id: number; isRecursive: boolean }[];
isFileRecursive?: boolean;
}
const filesystem = new Dexie("filesystem") as Dexie & {
directory: EntityTable<DirectoryInfo, "id">;
file: EntityTable<FileInfo, "id">;
category: EntityTable<CategoryInfo, "id">;
};
filesystem
.version(3)
.stores({
directory: "id, parentId",
file: "id, parentId",
category: "id, parentId",
})
.upgrade(async (trx) => {
await trx
.table("category")
.toCollection()
.modify((category) => {
category.isFileRecursive = false;
});
});
filesystem.version(4).upgrade(async (trx) => {
await Promise.all([
trx
.table("directory")
.toCollection()
.modify((directory) => {
directory.isFavorite = false;
}),
trx
.table("file")
.toCollection()
.modify((file) => {
file.isFavorite = false;
}),
]);
});
export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray();
};
export const getDirectoryInfo = async (id: number) => {
return await filesystem.directory.get(id);
};
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.upsert(directoryInfo.id, {
parentId: directoryInfo.parentId,
name: directoryInfo.name,
isFavorite: directoryInfo.isFavorite,
});
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
export const deleteDanglingDirectoryInfos = async (
parentId: DirectoryId,
validIds: Set<number>,
) => {
await filesystem.directory
.where({ parentId })
.and((directory) => !validIds.has(directory.id))
.delete();
};
export const getAllFileInfos = async () => {
return await filesystem.file.toArray();
};
export const getFileInfos = async (parentId: DirectoryId) => {
return await filesystem.file.where({ parentId }).toArray();
};
export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id);
};
export const bulkGetFileInfos = async (ids: number[]) => {
return await filesystem.file.bulkGet(ids);
};
export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.upsert(fileInfo.id, {
parentId: fileInfo.parentId,
name: fileInfo.name,
contentType: fileInfo.contentType,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
categoryIds: fileInfo.categoryIds,
isFavorite: fileInfo.isFavorite,
});
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
export const bulkDeleteFileInfos = async (ids: number[]) => {
await filesystem.file.bulkDelete(ids);
};
export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set<number>) => {
await filesystem.file
.where({ parentId })
.and((file) => !validIds.has(file.id))
.delete();
};
export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray();
};
export const getCategoryInfo = async (id: number) => {
return await filesystem.category.get(id);
};
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.upsert(categoryInfo.id, {
parentId: categoryInfo.parentId,
name: categoryInfo.name,
files: categoryInfo.files,
isFileRecursive: categoryInfo.isFileRecursive,
});
};
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};
export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id);
};
export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set<number>) => {
await filesystem.category
.where({ parentId })
.and((category) => !validIds.has(category.id))
.delete();
};
export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = [];
const validFileIds: number[] = [];
const directoryQueue: DirectoryId[] = ["root"];
while (true) {
const directoryId = directoryQueue.shift();
if (!directoryId) break;
const [subDirectories, files] = await Promise.all([
filesystem.directory.where({ parentId: directoryId }).toArray(),
filesystem.file.where({ parentId: directoryId }).toArray(),
]);
subDirectories.forEach(({ id }) => {
validDirectoryIds.push(id);
directoryQueue.push(id);
});
files.forEach(({ id }) => validFileIds.push(id));
}
const validCategoryIds: number[] = [];
const categoryQueue: CategoryId[] = ["root"];
while (true) {
const categoryId = categoryQueue.shift();
if (!categoryId) break;
const subCategories = await filesystem.category.where({ parentId: categoryId }).toArray();
subCategories.forEach(({ id }) => {
validCategoryIds.push(id);
categoryQueue.push(id);
});
}
await Promise.all([
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
filesystem.file.where("id").noneOf(validFileIds).delete(),
filesystem.category.where("id").noneOf(validCategoryIds).delete(),
]);
};
@@ -0,0 +1,3 @@
export * from "./cache-index.ts";
export * from "./filesystem.ts";
export * from "./key-store.ts";
@@ -70,12 +70,12 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
};
export const getHmacSecrets = async () => {
return await keyStore.hmacSecret.toArray();
return (await keyStore.hmacSecret.toArray()).filter(({ secret }) => secret.extractable);
};
export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => secret.extractable)) {
throw new Error("Hmac secrets must be nonextractable");
if (secrets.some(({ secret }) => !secret.extractable)) {
throw new Error("Hmac secrets must be extractable");
}
await keyStore.hmacSecret.bulkPut(secrets);
};
+144
View File
@@ -0,0 +1,144 @@
const getFileHandle = async (path: string, create = true) => {
if (path[0] !== "/") {
throw new Error("Path must be absolute");
}
const parts = path.split("/");
if (parts.length <= 1) {
throw new Error("Invalid path");
}
try {
let directoryHandle = await navigator.storage.getDirectory();
for (const part of parts.slice(0, -1)) {
if (!part) continue;
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
}
const filename = parts[parts.length - 1]!;
const fileHandle = await directoryHandle.getFileHandle(filename, { create });
return { parentHandle: directoryHandle, filename, fileHandle };
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") {
return {};
}
throw e;
}
};
export const getFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null;
return await fileHandle.getFile();
};
export const readFile = async (path: string) => {
return (await getFile(path))?.arrayBuffer() ?? null;
};
export const writeFile = async (path: string, data: ArrayBuffer) => {
const { fileHandle } = await getFileHandle(path);
const writable = await fileHandle!.createWritable();
try {
await writable.write(data);
} finally {
await writable.close();
}
};
// Changes are applied atomically when the stream is closed, and discarded if it is aborted
export const createWritable = async (path: string) => {
const { fileHandle } = await getFileHandle(path);
return await fileHandle!.createWritable();
};
export const deleteFile = async (path: string) => {
const { parentHandle, filename } = await getFileHandle(path, false);
if (!parentHandle) return;
await parentHandle.removeEntry(filename);
};
const getDirectoryHandle = async (path: string) => {
if (path[0] !== "/") {
throw new Error("Path must be absolute");
}
const parts = path.split("/");
if (parts.length <= 1) {
throw new Error("Invalid path");
}
try {
let directoryHandle = await navigator.storage.getDirectory();
let parentHandle;
for (const part of parts.slice(1)) {
if (!part) continue;
parentHandle = directoryHandle;
directoryHandle = await directoryHandle.getDirectoryHandle(part);
}
return { directoryHandle, parentHandle };
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") {
return {};
}
throw e;
}
};
export const deleteDirectory = async (path: string) => {
const { directoryHandle, parentHandle } = await getDirectoryHandle(path);
if (!parentHandle) return;
await parentHandle.removeEntry(directoryHandle.name, { recursive: true });
};
const listEntryNames = async (path: string, kind: FileSystemHandleKind) => {
const { directoryHandle } = await getDirectoryHandle(path);
if (!directoryHandle) return [];
const names: string[] = [];
for await (const handle of directoryHandle.values()) {
if (handle.kind === kind) {
names.push(handle.name);
}
}
return names;
};
export const listFileNames = (path: string) => listEntryNames(path, "file");
export const listDirectoryNames = (path: string) => listEntryNames(path, "directory");
// Deletes the files (not directories) directly under the directory
export const deleteFilesInDirectory = async (path: string) => {
const { directoryHandle } = await getDirectoryHandle(path);
if (!directoryHandle) return;
for (const name of await listFileNames(path)) {
await directoryHandle.removeEntry(name);
}
};
// Renames the file atomically
export const moveFile = async (path: string, newPath: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) {
throw new Error("File not found");
}
let directoryHandle = await navigator.storage.getDirectory();
const parts = newPath.split("/").filter((part) => part);
for (const part of parts.slice(0, -1)) {
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create: true });
}
// Supported by Chromium for the origin private file system
await (
fileHandle as FileSystemFileHandle & {
move(destination: FileSystemDirectoryHandle, name: string): Promise<void>;
}
).move(directoryHandle, parts.at(-1)!);
};
+130
View File
@@ -0,0 +1,130 @@
import { sha256 } from "@noble/hashes/sha2.js";
import axios from "axios";
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.ts";
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.ts";
import { BoundedQueue } from "#lib/utils/concurrency/index.ts";
import { createSpeedMeter } from "#lib/utils/speed-meter.ts";
interface UploadStats {
progress: number;
rate: number;
}
interface EncryptedChunk {
index: number;
data: ArrayBuffer;
}
const uploadEncryptedChunk = async (
uploadId: string,
chunkIndex: number,
encrypted: ArrayBuffer,
onChunkProgress: (chunkIndex: number, loaded: number) => void,
) => {
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
headers: { "Content-Type": "application/octet-stream" },
onUploadProgress(e) {
onChunkProgress(chunkIndex, e.loaded ?? 0);
},
});
onChunkProgress(chunkIndex, encrypted.byteLength);
};
export const uploadBlob = async (
uploadId: string,
blob: Blob,
dataKey: CryptoKey,
options?: {
concurrency?: number;
onProgress?: (s: UploadStats) => void;
// Receives each chunk of the plaintext before it is encrypted
onPlaintextChunk?: (chunkIndex: number, data: ArrayBuffer) => Promise<void>;
},
) => {
const onProgress = options?.onProgress;
const networkConcurrency = options?.concurrency ?? 4;
const maxQueueSize = 8;
const totalChunks = getChunkCount(blob.size);
const totalBytes = getEncryptedSize(blob.size);
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
const speedMeter = createSpeedMeter(3000, 200);
const hash = sha256.create();
const emit = () => {
if (!onProgress) return;
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
const rate = speedMeter(uploadedBytes);
const progress = Math.min(1, uploadedBytes / totalBytes);
onProgress({ progress, rate });
};
const onChunkProgress = (idx: number, loaded: number) => {
uploadedByChunk[idx] = loaded;
emit();
};
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
// Producer: encrypt chunks and push to queue
const encryptionProducer = async () => {
try {
for (let i = 0; i < totalChunks; i++) {
const chunk = await blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE).arrayBuffer();
await options?.onPlaintextChunk?.(i, chunk);
const data = await encryptChunk(chunk, dataKey);
hash.update(new Uint8Array(data));
await queue.push({ index: i, data });
}
} finally {
queue.close();
}
};
// Consumer: upload chunks from queue with concurrency limit
const uploadConsumer = async () => {
const activeTasks = new Set<Promise<void>>();
let failure: { reason: unknown } | undefined;
try {
while (!failure) {
const item = await queue.pop();
if (item === null) break;
const task: Promise<void> = uploadEncryptedChunk(
uploadId,
item.index,
item.data,
onChunkProgress,
)
.catch((reason) => {
failure ??= { reason };
})
.finally(() => activeTasks.delete(task));
activeTasks.add(task);
if (activeTasks.size >= networkConcurrency) {
await Promise.race(activeTasks);
}
}
await Promise.all(activeTasks);
if (failure) throw failure.reason;
} finally {
queue.close(); // Stops the producer if the upload failed
}
};
// Run producer and consumer concurrently
const [encryption, upload] = await Promise.allSettled([encryptionProducer(), uploadConsumer()]);
// The upload error takes precedence, as the producer fails due to the closed queue then
if (upload.status === "rejected") throw upload.reason;
if (encryption.status === "rejected") throw encryption.reason;
onProgress?.({ progress: 1, rate: speedMeter() });
return { encContentHash: encodeToBase64(hash.digest()) };
};
@@ -0,0 +1,55 @@
import type { DownloadProgressMessage } from "#lib/shared/service-worker.ts";
import { createSpeedMeter } from "#lib/utils/speed-meter.ts";
// Files are downloaded and decrypted by the service worker, which reports the progress
export interface FileDownloadState {
id: number;
status: "downloading" | "completed" | "error";
progress?: number;
rate?: number;
}
let downloadingFiles: FileDownloadState[] = $state([]);
const speedMeters = new WeakMap<FileDownloadState, ReturnType<typeof createSpeedMeter>>();
export const isFileDownloading = (status: FileDownloadState["status"]) => {
return status === "downloading";
};
export const getFileDownloadState = (fileId: number) => {
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
};
export const getDownloadingFiles = () => {
return downloadingFiles.filter((file) => isFileDownloading(file.status));
};
export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
export const handleDownloadProgress = ({
fileId,
status,
loaded,
total,
}: DownloadProgressMessage) => {
let state = getFileDownloadState(fileId);
if (!state) {
if (status !== "downloading") return;
downloadingFiles.push({ id: fileId, status });
state = downloadingFiles.at(-1)!;
speedMeters.set(state, createSpeedMeter());
}
if (status === "canceled") {
downloadingFiles = downloadingFiles.filter((file) => file !== state);
return;
}
state.status = status;
state.progress = total > 0 ? loaded / total : 1;
state.rate = speedMeters.get(state)?.(loaded);
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./download.svelte.ts";
export * from "./upload.svelte.ts";
+259
View File
@@ -0,0 +1,259 @@
import { limitFunction } from "p-limit";
import { createUploadFileCache } from "#lib/client/cache/index.ts";
import { signMessageHmac } from "#lib/client/crypto/hmac.ts";
import {
ensureHmacSecrets,
keyring,
type HmacSecret,
type MasterKey,
} from "#lib/client/keys/index.ts";
import { readExifDateTime } from "#lib/client/media/exif.ts";
import { generateThumbnail } from "#lib/client/media/thumbnail.ts";
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
encryptString,
} from "#lib/shared/crypto/index.ts";
import { getChunkCount } from "#lib/shared/format/index.ts";
import { Scheduler } from "#lib/utils/index.ts";
import { trpc } from "#trpc/client.ts";
const LIVE_STATUSES = [
"queued",
"encryption-pending",
"encrypting",
"upload-pending",
"uploading",
] as const;
type LiveStatus = (typeof LIVE_STATUSES)[number];
export interface FileUploadState {
id: string;
name: string;
parentId: DirectoryId;
status: LiveStatus | "uploaded" | "canceled" | "error";
progress?: number;
rate?: number;
estimated?: number;
}
export type LiveFileUploadState = FileUploadState & { status: LiveStatus };
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (file: FileUploadState): file is LiveFileUploadState => {
return (LIVE_STATUSES as readonly string[]).includes(file.status);
};
export const getUploadingFiles = (parentId?: DirectoryId) => {
return uploadingFiles
.filter(isFileUploading)
.filter((file) => parentId === undefined || file.parentId === parentId);
};
export const clearUploadedFiles = () => {
uploadingFiles = uploadingFiles.filter(isFileUploading);
};
const requestDuplicateFileScan = limitFunction(
async (
state: FileUploadState,
file: File,
hmacSecret: HmacSecret,
onDuplicate: () => Promise<boolean>,
) => {
state.status = "encryption-pending";
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
const files = await trpc.file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => {
if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic";
throw new Error("Unknown file type");
};
interface FileMetadata {
parentId: "root" | number;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const requestFileMetadataEncryption = limitFunction(
async (
state: FileUploadState,
file: Blob,
fileMetadata: FileMetadata,
masterKey: MasterKey,
hmacSecret: HmacSecret,
) => {
state.status = "encrypting";
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
await Promise.all([
encryptString(fileMetadata.name, dataKey),
fileMetadata.createdAt &&
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
]);
const { uploadId } = await trpc.upload.startFileUpload.mutate({
chunks: getChunkCount(file.size),
parent: fileMetadata.parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion,
hskVersion: hmacSecret.version,
contentType: file.type,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
});
state.status = "upload-pending";
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (
state: FileUploadState,
uploadId: string,
file: Blob,
fileSigned: string,
thumbnailBuffer: ArrayBuffer | undefined,
dataKey: CryptoKey,
dataKeyVersion: Date,
) => {
state.status = "uploading";
// The file is cached while uploading, so that it doesn't have to be read again
const cache = createUploadFileCache(file.size);
let fileId;
try {
const { encContentHash } = await uploadBlob(uploadId, file, dataKey, {
onProgress(s) {
state.progress = s.progress;
state.rate = s.rate;
},
onPlaintextChunk: cache.writeChunk,
});
({ file: fileId } = await trpc.upload.completeFileUpload.mutate({
uploadId,
contentHmac: fileSigned,
encContentHash,
}));
} catch (e) {
await cache.discard();
throw e;
}
await cache.commit(fileId).catch(console.error);
if (thumbnailBuffer) {
try {
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
file: fileId,
dekVersion: dataKeyVersion,
});
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
} catch (e) {
console.error(e);
}
}
state.status = "uploaded";
return { fileId };
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
onDuplicate: () => Promise<boolean>,
) => {
uploadingFiles.push({
id: crypto.randomUUID(),
name: file.name,
parentId,
status: "queued",
});
const state = uploadingFiles.at(-1)!;
return await scheduler.schedule(file.size, async () => {
try {
await ensureHmacSecrets();
const masterKey = keyring.activeMasterKey;
const hmacSecret = keyring.activeHmacSecret;
const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
if (!fileSigned) {
state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state);
return;
}
const fileType = getFileType(file);
const fileMetadata: FileMetadata = {
parentId,
name: file.name,
lastModifiedAt: new Date(file.lastModified),
};
if (fileType.startsWith("image/")) {
fileMetadata.createdAt = await readExifDateTime(file);
}
const blob = new Blob([file], { type: fileType });
const { uploadId, thumbnailBuffer, dataKey, dataKeyVersion } =
await requestFileMetadataEncryption(state, blob, fileMetadata, masterKey, hmacSecret);
const { fileId } = await requestFileUpload(
state,
uploadId,
blob,
fileSigned,
thumbnailBuffer,
dataKey,
dataKeyVersion,
);
return { fileId, thumbnailBuffer };
} catch (e) {
state.status = "error";
throw e;
}
});
};
-39
View File
@@ -1,39 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/divs";
interface Props {
children: Snippet;
onclose?: () => void;
isOpen: boolean;
}
let { children, onclose, isOpen = $bindable() }: Props = $props();
const closeBottomSheet = $derived(
onclose ||
(() => {
isOpen = false;
}),
);
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={closeBottomSheet} class="fixed inset-0 z-10 flex items-end justify-center">
<div class="absolute inset-0 bg-black bg-opacity-50" transition:fade={{ duration: 100 }}></div>
<div class="z-20 w-full">
<AdaptiveDiv>
<div
onclick={(e) => e.stopPropagation()}
class="flex max-h-[70vh] min-h-[30vh] rounded-t-2xl bg-white px-4"
transition:fly={{ y: 100, duration: 200 }}
>
{@render children?.()}
</div>
</AdaptiveDiv>
</div>
</div>
{/if}
-38
View File
@@ -1,38 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fade } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/divs";
interface Props {
children: Snippet;
onclose?: () => void;
isOpen: boolean;
}
let { children, onclose, isOpen = $bindable() }: Props = $props();
const closeModal = $derived(
onclose ||
(() => {
isOpen = false;
}),
);
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={closeModal}
class="fixed inset-0 z-10 bg-black bg-opacity-50"
transition:fade={{ duration: 100 }}
>
<AdaptiveDiv>
<div class="flex h-full items-center justify-center px-4">
<div onclick={(e) => e.stopPropagation()} class="rounded-2xl bg-white p-4">
{@render children?.()}
</div>
</div>
</AdaptiveDiv>
</div>
{/if}
-31
View File
@@ -1,31 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import IconArrowBack from "~icons/material-symbols/arrow-back";
interface Props {
children?: Snippet;
onback?: () => void;
title?: string;
}
let { children, onback, title }: Props = $props();
const back = $derived(() => {
setTimeout(onback || (() => history.back()), 100);
});
</script>
<div class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4">
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
<IconArrowBack class="text-2xl" />
</button>
{#if title}
<p class="flex-grow truncate px-2 text-center text-lg font-semibold">{title}</p>
{/if}
<div class="w-[2.3rem] flex-shrink-0">
{#if children}
{@render children?.()}
{/if}
</div>
</div>
-37
View File
@@ -1,37 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
color?: "primary" | "gray";
onclick?: () => void;
}
let { children, color = "primary", onclick }: Props = $props();
const bgColorStyle = $derived(
{
primary: "bg-primary-600 active:bg-primary-500",
gray: "bg-gray-300 active:bg-gray-400",
}[color],
);
const fontColorStyle = $derived(
{
primary: "text-white",
gray: "text-gray-800",
}[color],
);
</script>
<button
onclick={() => {
setTimeout(() => {
onclick?.();
}, 100);
}}
class="{bgColorStyle} {fontColorStyle} h-12 w-full min-w-fit rounded-xl font-medium"
>
<div class="h-full w-full p-3 transition active:scale-95">
{@render children?.()}
</div>
</button>
@@ -1,30 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import IconChevronRight from "~icons/material-symbols/chevron-right";
interface Props {
children: Snippet;
onclick?: () => void;
}
let { children, onclick }: Props = $props();
</script>
<button
onclick={() => {
setTimeout(() => {
onclick?.();
}, 100);
}}
class="w-full rounded-xl active:bg-gray-100"
>
<div class="flex w-full justify-between p-2 transition active:scale-95">
<div>
{@render children?.()}
</div>
<div class="flex items-center justify-center">
<IconChevronRight class="text-xl text-gray-800" />
</div>
</div>
</button>
@@ -1,36 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { AdaptiveDiv } from "$lib/components/divs";
interface Props {
icon: Component<SvelteHTMLElements["svg"]>;
offset?: string;
onclick?: () => void;
}
let { icon: Icon, offset = "bottom-20", onclick }: Props = $props();
const click = () => {
setTimeout(() => {
onclick?.();
}, 100);
};
</script>
<div class="pointer-events-none fixed inset-0">
<div class="absolute w-full {offset}">
<AdaptiveDiv>
<div class="relative">
<div class="absolute bottom-4 right-4">
<button
onclick={click}
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 shadow-lg transition active:scale-95 active:bg-gray-400"
>
<Icon class="text-xl" />
</button>
</div>
</div>
</AdaptiveDiv>
</div>
</div>
-4
View File
@@ -1,4 +0,0 @@
export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte";
@@ -1,7 +0,0 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="mx-auto h-full w-full max-w-screen-md">
{@render children?.()}
</div>
-7
View File
@@ -1,7 +0,0 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="sticky bottom-0 flex flex-col items-center gap-y-2 bg-white pb-4">
{@render children?.()}
</div>
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
interface Props {
children: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
topPadding?: boolean;
}
let { topPadding = true, children, icon: Icon }: Props = $props();
</script>
<div>
<div class="box-content flex min-h-[10vh] items-center {topPadding ? 'pt-4' : ''}">
{#if Icon}
<Icon class="text-5xl text-gray-600" />
{/if}
</div>
{@render children?.()}
</div>
-3
View File
@@ -1,3 +0,0 @@
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
export { default as BottomDiv } from "./BottomDiv.svelte";
export { default as TitleDiv } from "./TitleDiv.svelte";
-3
View File
@@ -1,3 +0,0 @@
export { default as BottomSheet } from "./BottomSheet.svelte";
export { default as Modal } from "./Modal.svelte";
export { default as TopBar } from "./TopBar.svelte";
@@ -1,35 +0,0 @@
<script lang="ts">
interface Props {
placeholder: string;
type?: "text" | "password";
value?: string;
}
let { placeholder, type = "text", value = $bindable("") }: Props = $props();
</script>
<div class="relative mt-5">
<input
bind:value
{type}
placeholder=""
class="w-full border-b-2 border-gray-300 py-1 text-xl outline-none transition duration-300 ease-in-out"
/>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
>
{placeholder}
</label>
</div>
<style>
input:focus,
input:not(:placeholder-shown) {
@apply border-primary-300;
}
input:focus + label,
input:not(:placeholder-shown) + label {
@apply top-0 -translate-y-full text-sm text-primary-400;
}
</style>
-1
View File
@@ -1 +0,0 @@
export { default as TextInput } from "./TextInput.svelte";
@@ -0,0 +1,22 @@
<script lang="ts">
import { ActionModal } from "#lib/ui/index.ts";
interface Props {
isOpen: boolean;
oncancel?: () => void;
onLoginClick: () => void;
}
let { isOpen = $bindable(), oncancel, onLoginClick }: Props = $props();
</script>
<ActionModal
bind:isOpen
title="다른 디바이스에 이미 로그인되어 있어요."
cancelText="아니요"
{oncancel}
confirmText="네"
onConfirmClick={onLoginClick}
>
<p>다른 디바이스에서는 로그아웃하고, 이 디바이스에서 로그인할까요?</p>
</ActionModal>
@@ -0,0 +1 @@
export { default as ForceLoginModal } from "./ForceLoginModal.svelte";
+13
View File
@@ -0,0 +1,13 @@
import { trpc } from "#trpc/client.ts";
export const login = async (email: string, password: string) => {
await trpc.auth.login.mutate({ email, password });
};
export const logout = async () => {
await trpc.auth.logout.mutate();
};
export const changePassword = async (oldPassword: string, newPassword: string) => {
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
};
+30
View File
@@ -0,0 +1,30 @@
import { showToast } from "#lib/ui/index.ts";
import { isTRPCClientError } from "#trpc/client.ts";
const getErrorMessage = (e: unknown, fallbackMessage: string) => {
if (isTRPCClientError(e)) {
switch (e.data?.code) {
case "NOT_FOUND":
return "대상을 찾을 수 없어요. 이미 삭제되었을 수 있어요.";
case undefined:
return "서버에 연결할 수 없어요.";
}
}
return fallbackMessage;
};
export const showErrorToast = (e: unknown, fallbackMessage: string) => {
console.error(e);
showToast(getErrorMessage(e, fallbackMessage));
};
// Runs the action and shows the error to the user if it fails. Returns whether it succeeded.
export const tryAction = async (action: () => Promise<unknown>, errorMessage: string) => {
try {
await action();
return true;
} catch (e) {
showErrorToast(e, errorMessage);
return false;
}
};
+54
View File
@@ -0,0 +1,54 @@
import {
getDirectoryInfo,
getFileInfo,
isExistingFileInfo,
isLocalDirectoryInfo,
setDirectoryFavorite,
setFileFavorite,
type LocalDirectoryInfo,
type SummarizedFileInfo,
} from "#lib/features/filesystem/index.ts";
import { sortEntries } from "#lib/utils/index.ts";
import { trpc } from "#trpc/client.ts";
export type FavoriteEntry =
| { type: "directory"; name: string; details: LocalDirectoryInfo }
| { type: "file"; name: string; details: SummarizedFileInfo };
export const getFavoriteEntries = async () => {
const favorites = await trpc.favorites.get.query();
const [directories, files] = await Promise.all([
Promise.all(
favorites.directories.map((directory) =>
getDirectoryInfo(directory.id, { serverResponse: { ...directory, isFavorite: true } }),
),
),
Promise.all(
favorites.files.map((file) =>
getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }),
),
),
]);
return [
...sortEntries(
directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({
type: "directory",
name: directory.name,
details: directory,
})),
),
...sortEntries(
files
.filter(isExistingFileInfo)
.map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })),
),
];
};
export const removeFavorite = async ({ type, details }: FavoriteEntry) => {
if (type === "directory") {
await setDirectoryFavorite(details, false);
} else {
await setFileFavorite(details, false);
}
};
+150
View File
@@ -0,0 +1,150 @@
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
import { untrack } from "svelte";
interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
}
interface Entry<V> {
value?: V;
promise?: Promise<V>;
isInvalidated: boolean;
}
// Caches filesystem entries as reactive objects which are updated in place, so that every view
// of an entry reflects its latest state. Values are first loaded from IndexedDB (if any) and then
// revalidated against the server.
export class FilesystemCache<K, V extends object> {
private map = new Map<K, Entry<V>>();
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
private getOrCreateEntry(key: K) {
let entry = this.map.get(key);
if (!entry) {
const newEntry: Entry<V> = $state({ isInvalidated: false });
entry = newEntry;
this.map.set(key, entry);
}
return entry;
}
private settle(entry: Entry<V>, value: V) {
if (entry.value) {
Object.assign(entry.value, value);
} else {
entry.value = value;
}
return entry.value!;
}
private finish(key: K, entry: Entry<V>) {
entry.promise = undefined;
// Mutations may invalidate the entry while it is being revalidated, in which case it is queried
// again unless it was evicted or replaced in the meantime
if (entry.isInvalidated && this.map.get(key) === entry) {
void Promise.resolve(this.get(key)).catch(() => {});
}
}
// Returns the cached value synchronously if available, and revalidates it in the background
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
return untrack(() => {
const entry = this.getOrCreateEntry(key);
if (entry.promise) return entry.value ?? entry.promise;
const { promise, resolve, reject } = Promise.withResolvers<V>();
promise.catch(() => {}); // Rejections are handled by callers waiting for the promise
entry.promise = promise;
entry.isInvalidated = false;
(async () => {
let cachedValue = entry.value && ($state.snapshot(entry.value) as V);
if (!cachedValue) {
cachedValue = await this.options.fetchFromIndexedDB(key);
if (cachedValue) {
resolve(this.settle(entry, cachedValue));
}
}
const loadedValue = await (options?.fetchFromServer?.(cachedValue) ??
this.options.fetchFromServer(key, cachedValue));
resolve(this.settle(entry, loadedValue));
})()
.catch((e) => {
console.error(e);
reject(e);
})
.finally(() => this.finish(key, entry));
return entry.value ?? promise;
});
}
bulkGet(keys: Set<K>) {
return untrack(() => {
const entries = new Map<K, Entry<V>>();
const pending = new Map<K, PromiseWithResolvers<V>>();
for (const key of keys) {
const entry = this.getOrCreateEntry(key);
entries.set(key, entry);
if (entry.promise) continue;
const resolvers = Promise.withResolvers<V>();
resolvers.promise.catch(() => {});
entry.promise = resolvers.promise;
entry.isInvalidated = false;
pending.set(key, resolvers);
}
const settleAll = (values: Map<K, V>) => {
values.forEach((value, key) => {
pending.get(key)?.resolve(this.settle(entries.get(key)!, value));
});
};
(async () => {
const pendingKeys = [...pending.keys()];
settleAll(
await this.options.bulkFetchFromIndexedDB!(
new Set(pendingKeys.filter((key) => !entries.get(key)!.value)),
),
);
settleAll(
await this.options.bulkFetchFromServer!(
new Map(pendingKeys.map((key) => [key, { cachedValue: entries.get(key)!.value }])),
),
);
})()
.catch((e) => {
console.error(e);
pending.forEach(({ reject }) => reject(e));
})
.finally(() => {
pending.forEach((_, key) => this.finish(key, entries.get(key)!));
});
const makeResult = () => new Map([...entries].map(([key, entry]) => [key, entry.value!]));
const waiting = [...entries.values()]
.filter((entry) => !entry.value)
.map((entry) => entry.promise!);
return waiting.length > 0 ? Promise.all(waiting).then(makeResult) : makeResult();
});
}
// Revalidates the entry if it has been loaded
invalidate(key: K) {
const entry = this.map.get(key);
if (entry) {
entry.isInvalidated = true;
void Promise.resolve(this.get(key)).catch(() => {});
}
}
evict(key: K) {
this.map.delete(key);
}
}
+168
View File
@@ -0,0 +1,168 @@
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { invalidateFile } from "./file.ts";
import { toCategoryInfo, toFileInfo } from "./dto.ts";
import { encryptName, generateEntryKeys } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
async fetchFromIndexedDB(id) {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category?.files
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
parentId: fileInfo.parentId,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isFavorite: fileInfo.isFavorite,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true,
subCategories,
};
} else if (category) {
return {
id,
exists: true,
parentId: category.parentId,
name: category.name,
subCategories,
files: files?.filter((file) => !!file) ?? [],
isFileRecursive: category.isFileRecursive ?? false,
};
}
},
async fetchFromServer(id, cachedInfo) {
try {
const category = await trpc.category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(category.subCategories.map(toCategoryInfo)),
Promise.all(
(category.files ?? []).map(async (file) => ({
...(await toFileInfo(file)),
isRecursive: file.isRecursive,
})),
),
category.metadata && toCategoryInfo(category.metadata),
]);
return storeToIndexedDB(
metadata
? {
...metadata,
subCategories,
files,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
}
: { id: "root", subCategories },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false };
}
throw e;
}
},
});
const storeToIndexedDB = (info: CategoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeCategoryInfo(info);
// TODO: Bulk Upsert
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
}
// TODO: Bulk Upsert
info.subCategories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
void IndexedDB.deleteDanglingCategoryInfos(
info.id,
new Set(info.subCategories.map(({ id }) => id)),
);
return { ...info, exists: true as const };
};
export const getCategoryInfo = (id: CategoryId) => {
return cache.get(id);
};
export const useCategory = (getId: () => CategoryId) => {
return useResource(getId, (id) => getCategoryInfo(id));
};
export const invalidateCategory = (id: CategoryId) => {
cache.invalidate(id);
};
export const createCategory = async (parentId: CategoryId, name: string) => {
await trpc.category.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
invalidateCategory(parentId);
};
export const renameCategory = async (
category: { id: number; parentId: CategoryId; dataKey?: DataKey },
newName: string,
) => {
if (!category.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.category.rename.mutate({
id: category.id,
dekVersion: category.dataKey.version,
...(await encryptName(newName, category.dataKey.key)),
});
invalidateCategory(category.parentId);
invalidateCategory(category.id);
};
export const deleteCategory = async (category: { id: number; parentId: CategoryId }) => {
await trpc.category.delete.mutate({ id: category.id });
cache.evict(category.id);
invalidateCategory(category.parentId);
};
export const addFileToCategory = async (fileId: number, categoryId: number) => {
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
invalidateFile(fileId);
invalidateCategory(categoryId);
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
invalidateFile(fileId);
invalidateCategory(categoryId);
};
// Whether to show the files of subcategories is a local preference
export const setCategoryFileRecursive = async (category: LocalCategoryInfo, value: boolean) => {
category.isFileRecursive = value;
await IndexedDB.updateCategoryInfo(category.id, { isFileRecursive: value });
};
@@ -0,0 +1,42 @@
<script module lang="ts">
import type { DataKey, SubCategoryInfo } from "#lib/features/filesystem/index.ts";
export interface SelectedCategory {
id: number;
dataKey?: DataKey;
name: string;
}
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton, CategoryLabel } from "#lib/ui/index.ts";
import { sortEntries } from "#lib/utils/index.ts";
interface Props {
categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
}
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries([...categories]));
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category (category.id)}
<ActionEntryButton
class="h-12"
onclick={() => onCategoryClick(category)}
actionButtonIcon={categoryMenuIcon}
onActionButtonClick={() => onCategoryMenuClick?.(category)}
>
<CategoryLabel name={category.name} />
</ActionEntryButton>
{/each}
</div>
{/if}
@@ -0,0 +1,18 @@
<script lang="ts">
import { TextInputModal } from "#lib/ui/index.ts";
interface Props {
isOpen: boolean;
onCreateClick: (name: string) => Promise<boolean>;
}
let { isOpen = $bindable(), onCreateClick }: Props = $props();
</script>
<TextInputModal
bind:isOpen
title="새 카테고리"
placeholder="카테고리 이름"
submitText="만들기"
onSubmitClick={onCreateClick}
/>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton, DirectoryEntryLabel } from "#lib/ui/index.ts";
interface Props {
action?: {
icon: Component<SvelteHTMLElements["svg"]>;
onclick: () => void;
};
info: { name: string; isFavorite?: boolean };
onclick?: () => void;
}
let { action, info, onclick }: Props = $props();
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={action?.icon}
onActionButtonClick={action?.onclick}
>
<DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
</ActionEntryButton>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { getFileThumbnail } from "#lib/client/cache/index.ts";
import { ActionEntryButton, DirectoryEntryLabel } from "#lib/ui/index.ts";
import { formatDateTime } from "#lib/utils/index.ts";
import type { SummarizedFileInfo } from "../types.ts";
interface Props {
action?: {
icon: Component<SvelteHTMLElements["svg"]>;
onclick: () => void;
class?: ClassValue;
};
info: SummarizedFileInfo;
onclick?: () => void;
showThumbnail?: boolean;
subtext?: string;
}
let { action, info, onclick, showThumbnail = true, subtext }: Props = $props();
let thumbnail = $derived(showThumbnail ? getFileThumbnail(info) : undefined);
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={action?.icon}
actionButtonClass={action?.class}
onActionButtonClick={action?.onclick}
>
<DirectoryEntryLabel
type="file"
thumbnail={thumbnail?.url}
name={info.name}
subtext={subtext ?? formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
/>
</ActionEntryButton>
@@ -0,0 +1,35 @@
<script lang="ts">
import { getFileThumbnail } from "#lib/client/cache/index.ts";
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
import { afterPress } from "#lib/ui/index.ts";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props {
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<button
onclick={afterPress(onclick && (() => onclick(info)))}
class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90"
>
{#if thumbnail?.url}
<img src={thumbnail?.url} alt={info.name} class="size-full object-cover" />
{:else}
<div class="size-full bg-gray-100"></div>
{/if}
{#if info.isFavorite}
<div class={["absolute right-0.5 bottom-0.5"]}>
<IconFavorite
class="text-sm text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
/>
</div>
{/if}
</button>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { Categories, type SelectedCategory } from "#lib/features/filesystem/components/index.ts";
import type { CategoryInfo } from "#lib/features/filesystem/index.ts";
import { IconEntryButton } from "#lib/ui/index.ts";
import IconAddCircle from "~icons/material-symbols/add-circle";
interface Props {
class?: ClassValue;
info: CategoryInfo;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick?: () => void;
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
subCategoryCreatePosition?: "top" | "bottom" | "none";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
}
let {
class: className,
info,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
subCategoryCreatePosition = "none",
subCategoryMenuIcon,
}: Props = $props();
</script>
<div class={["space-y-1", className]}>
{#snippet subCategoryCreate()}
<IconEntryButton
icon={IconAddCircle}
onclick={onSubCategoryCreateClick}
class="h-12 w-full"
iconClass="text-gray-600"
textClass="text-gray-700"
>
카테고리 추가하기
</IconEntryButton>
{/snippet}
{#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()}
{/if}
<Categories
categories={info.subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}
</div>
@@ -0,0 +1,7 @@
export * from "./Categories.svelte";
export { default as Categories } from "./Categories.svelte";
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
export { default as DirectoryEntry } from "./DirectoryEntry.svelte";
export { default as FileEntry } from "./FileEntry.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as SubCategories } from "./SubCategories.svelte";
+67
View File
@@ -0,0 +1,67 @@
import {
deleteFileCache,
deleteFileThumbnailCache,
storeFileThumbnailCache,
} from "#lib/client/cache/index.ts";
import {
getDecryptedFileUrl,
prepareFileDecryption,
saveDecryptedFile,
} from "#lib/client/service-worker.ts";
import { getAllFileInfos } from "#lib/client/storage/indexed-db/index.ts";
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
import { trpc } from "#trpc/client.ts";
import type { DataKey } from "./types.ts";
interface StreamableFile {
id: number;
isLegacy?: boolean;
dataKey?: DataKey;
contentType: string;
}
// Returns a URL from which the service worker streams the decrypted content of the file
export const getFileStreamUrl = async ({ id, isLegacy, dataKey, contentType }: StreamableFile) => {
if (!dataKey) {
throw new Error("Data key not loaded");
}
await prepareFileDecryption(id, { isLegacy: !!isLegacy, dataKey: dataKey.key, contentType });
return getDecryptedFileUrl(id);
};
export const getFileBlob = async (file: StreamableFile) => {
const res = await fetch(await getFileStreamUrl(file));
if (!res.ok) {
throw new Error("Failed to download the file");
}
return await res.blob();
};
export const saveFile = async (file: StreamableFile & { name: string }) => {
await getFileStreamUrl(file);
saveDecryptedFile(file.id, file.name);
};
export const uploadFileThumbnail = async (fileId: number, thumbnail: Blob, dataKey: DataKey) => {
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
file: fileId,
dekVersion: dataKey.version,
});
await uploadBlob(uploadId, thumbnail, dataKey.key);
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
await storeFileThumbnailCache(fileId, await thumbnail.arrayBuffer());
};
// Deletes local caches of files deleted on other devices
export const cleanupDeletedFileCaches = async () => {
const liveFiles = new Set(await trpc.file.list.query());
const maybeCachedFiles = await getAllFileInfos();
await Promise.all(
maybeCachedFiles
.filter(({ id }) => !liveFiles.has(id))
.flatMap(({ id }) => [deleteFileCache(id), deleteFileThumbnailCache(id)]),
);
};
+151
View File
@@ -0,0 +1,151 @@
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.ts";
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { toDirectoryInfo, toFileInfo, type DirectoryDto } from "./dto.ts";
import { encryptName, generateEntryKeys } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
async fetchFromIndexedDB(id) {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id),
IndexedDB.getFileInfos(id),
]);
if (id === "root") {
return {
id,
exists: true,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
isFavorite: directory.isFavorite,
};
}
},
async fetchFromServer(id) {
try {
const directory = await trpc.directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(directory.subDirectories.map(toDirectoryInfo)),
Promise.all(directory.files.map(toFileInfo)),
directory.metadata && toDirectoryInfo(directory.metadata),
]);
return storeToIndexedDB(
metadata ? { ...metadata, subDirectories, files } : { id: "root", subDirectories, files },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
},
});
const storeToIndexedDB = (info: DirectoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeDirectoryInfo(info);
}
// TODO: Bulk Upsert
info.subDirectories.forEach((subDirectory) => {
void IndexedDB.storeDirectoryInfo(subDirectory);
});
// TODO: Bulk Upsert
info.files.forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
void IndexedDB.deleteDanglingDirectoryInfos(
info.id,
new Set(info.subDirectories.map(({ id }) => id)),
);
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
return { ...info, exists: true as const };
};
// Uses `serverResponse` instead of fetching the directory from the server if given
export const getDirectoryInfo = (id: DirectoryId, options?: { serverResponse?: DirectoryDto }) => {
const serverResponse = options?.serverResponse;
return cache.get(id, {
fetchFromServer:
serverResponse &&
(async (cachedValue) =>
storeToIndexedDB({
subDirectories: [],
files: [],
...cachedValue,
...(await toDirectoryInfo(serverResponse)),
})),
});
};
export const useDirectory = (getId: () => DirectoryId) => {
return useResource(getId, (id) => getDirectoryInfo(id));
};
export const invalidateDirectory = (id: DirectoryId) => {
cache.invalidate(id);
};
export const createDirectory = async (parentId: DirectoryId, name: string) => {
await trpc.directory.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
invalidateDirectory(parentId);
};
export const renameDirectory = async (
directory: { id: number; parentId: DirectoryId; dataKey?: DataKey },
newName: string,
) => {
if (!directory.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.directory.rename.mutate({
id: directory.id,
dekVersion: directory.dataKey.version,
...(await encryptName(newName, directory.dataKey.key)),
});
invalidateDirectory(directory.parentId);
invalidateDirectory(directory.id);
};
export const deleteDirectory = async (directory: { id: number; parentId: DirectoryId }) => {
const { deletedFiles } = await trpc.directory.delete.mutate({ id: directory.id });
cache.evict(directory.id);
invalidateDirectory(directory.parentId);
await Promise.all(
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
);
};
export const setDirectoryFavorite = async (
directory: { id: number; parentId: DirectoryId },
isFavorite: boolean,
) => {
if (isFavorite) {
await trpc.favorites.addDirectory.mutate({ id: directory.id });
} else {
await trpc.favorites.removeDirectory.mutate({ id: directory.id });
}
invalidateDirectory(directory.parentId);
invalidateDirectory(directory.id);
};
+46
View File
@@ -0,0 +1,46 @@
import type { RouterOutputs } from "#trpc/router.server.ts";
import {
decryptCategoryMetadata,
decryptDirectoryMetadata,
decryptFileMetadata,
} from "./metadata.ts";
import type { FileInfo, SubCategoryInfo, SubDirectoryInfo, SummarizedFileInfo } from "./types.ts";
// Shapes of filesystem entries returned by the server
export type DirectoryDto = RouterOutputs["directory"]["get"]["subDirectories"][number];
export type FileDto = RouterOutputs["directory"]["get"]["files"][number];
export type CategoryDto = RouterOutputs["category"]["get"]["subCategories"][number];
export type FileWithCategoriesDto = RouterOutputs["file"]["get"];
export const toDirectoryInfo = async (directory: DirectoryDto): Promise<SubDirectoryInfo> => ({
id: directory.id,
parentId: directory.parent,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory)),
});
export const toFileInfo = async (file: FileDto): Promise<SummarizedFileInfo> => ({
id: file.id,
isLegacy: file.isLegacy,
parentId: file.parent,
contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file)),
});
export const toCategoryInfo = async (category: CategoryDto): Promise<SubCategoryInfo> => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
});
export const toFileWithCategoriesInfo = async ({
categories,
...file
}: FileWithCategoriesDto): Promise<FileInfo> => {
const [fileInfo, categoryInfos] = await Promise.all([
toFileInfo(file),
Promise.all(categories.map(toCategoryInfo)),
]);
return { ...fileInfo, categories: categoryInfos };
};
+196
View File
@@ -0,0 +1,196 @@
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.ts";
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { invalidateDirectory } from "./directory.ts";
import { toFileInfo, toFileWithCategoriesInfo, type FileDto } from "./dto.ts";
import { encryptName } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { DataKey, FileInfo, MaybeFileInfo } from "./types.ts";
// Categories of files stored in IndexedDB, which may have been deleted
const getFileCategoryInfos = async (categoryIds: number[] | undefined) => {
const categories = await Promise.all(
(categoryIds ?? []).map((id) => IndexedDB.getCategoryInfo(id)),
);
return categories
.filter((category) => !!category)
.map(({ id, parentId, name }) => ({ id, parentId, name }));
};
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
if (file) {
return {
id,
exists: true,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: await getFileCategoryInfos(file.categoryIds),
isFavorite: file.isFavorite,
};
}
},
async fetchFromServer(id) {
try {
return storeToIndexedDB(await toFileWithCategoriesInfo(await trpc.file.get.query({ id })));
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
},
async bulkFetchFromIndexedDB(ids) {
const files = (await IndexedDB.bulkGetFileInfos([...ids])).filter((file) => !!file);
return new Map(
await Promise.all(
files.map(
async (file) =>
[
file.id,
{
...file,
exists: true as const,
categories: await getFileCategoryInfos(file.categoryIds),
},
] as const,
),
),
);
},
async bulkFetchFromServer(ids) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(filesRaw.map(toFileWithCategoriesInfo));
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
void IndexedDB.bulkDeleteFileInfos(deletedIds);
return new Map<number, MaybeFileInfo>([
...bulkStoreToIndexedDB(files),
...deletedIds.map((id) => [id, { id, exists: false }] as const),
]);
},
});
const storeToIndexedDB = (info: FileInfo) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
info.categories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return { ...info, exists: true as const };
};
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
// TODO: Bulk Upsert
infos.forEach((info) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
});
// TODO: Bulk Upsert
new Map(
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
).forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
};
// Uses `serverResponse` instead of fetching the file from the server if given
export const getFileInfo = (id: number, options?: { serverResponse?: FileDto }) => {
const serverResponse = options?.serverResponse;
return cache.get(id, {
fetchFromServer:
serverResponse &&
(async (cachedValue) =>
storeToIndexedDB({
categories: [],
...cachedValue,
...(await toFileInfo(serverResponse)),
})),
});
};
export const bulkGetFileInfo = (ids: number[]) => {
return cache.bulkGet(new Set(ids));
};
export const useFile = (getId: () => number) => {
return useResource(getId, (id) => getFileInfo(id));
};
export const invalidateFile = (id: number) => {
cache.invalidate(id);
};
export const renameFile = async (
file: { id: number; parentId: DirectoryId; dataKey?: DataKey },
newName: string,
) => {
if (!file.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.file.rename.mutate({
id: file.id,
dekVersion: file.dataKey.version,
...(await encryptName(newName, file.dataKey.key)),
});
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
export const deleteFile = async (file: { id: number; parentId: DirectoryId }) => {
await trpc.file.delete.mutate({ id: file.id });
cache.evict(file.id);
invalidateDirectory(file.parentId);
await Promise.all([deleteFileCache(file.id), deleteFileThumbnailCache(file.id)]);
};
export const setFileFavorite = async (
file: { id: number; parentId: DirectoryId },
isFavorite: boolean,
) => {
if (isFavorite) {
await trpc.favorites.addFile.mutate({ id: file.id });
} else {
await trpc.favorites.removeFile.mutate({ id: file.id });
}
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
// The most recently created media files among the files stored locally
export const getRecentMediaFileInfos = async (limit: number) => {
const files = await IndexedDB.getAllFileInfos();
return files
.filter(
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
)
.sort(
(a, b) =>
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
)
.slice(0, limit);
};
+6
View File
@@ -0,0 +1,6 @@
export * from "./category.ts";
export * from "./content.ts";
export * from "./directory.ts";
export * from "./file.ts";
export * from "./types.ts";
export * from "./upload.ts";
+83
View File
@@ -0,0 +1,83 @@
import { keyring } from "#lib/client/keys/index.ts";
import {
decryptString,
encryptString,
generateDataKey,
unwrapDataKey,
wrapDataKey,
} from "#lib/shared/crypto/index.ts";
const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => {
const { dataKey } = await unwrapDataKey(
metadata.dek,
keyring.getMasterKey(metadata.mekVersion).key,
);
return dataKey;
};
export const decryptDirectoryMetadata = async (metadata: {
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
}) => {
const dataKey = await unwrapEntryDataKey(metadata);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
};
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const decryptFileMetadata = async (metadata: {
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
}) => {
const dataKey = await unwrapEntryDataKey(metadata);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(metadata.name, metadata.nameIv, dataKey),
metadata.createdAt
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
: undefined,
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
]);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
createdAt,
lastModifiedAt,
};
};
export const decryptCategoryMetadata = decryptDirectoryMetadata;
export const encryptName = async (name: string, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptString(name, dataKey);
return { name: ciphertext, nameIv: iv };
};
// Generates the keys of a new directory or category encrypted with the active master key
export const generateEntryKeys = async (name: string) => {
const masterKey = keyring.activeMasterKey;
const { dataKey, dataKeyVersion } = await generateDataKey();
return {
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion,
...(await encryptName(name, dataKey)),
};
};
@@ -0,0 +1,33 @@
import { untrack } from "svelte";
// Exposes the value of a cache entry identified by a reactive key. Cached values are available
// synchronously, so that views don't flicker while revalidating.
export const useResource = <K, V>(getKey: () => K, load: (key: K) => V | Promise<V>) => {
let current: V | undefined = $state();
$effect.pre(() => {
const key = getKey();
let isStale = false;
const result = untrack(() => load(key));
if (result instanceof Promise) {
result
.then((value) => {
if (!isStale) current = value;
})
.catch(() => {}); // Logged by the cache
} else {
current = result;
}
return () => {
isStale = true;
};
});
return {
get current() {
return current;
},
};
};
+86
View File
@@ -0,0 +1,86 @@
export type DataKey = { key: CryptoKey; version: Date };
export interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite: boolean;
}
export interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite?: undefined;
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo {
id: number;
isLegacy?: boolean;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: FileCategoryInfo[];
isFavorite: boolean;
}
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export interface LocalCategoryInfo {
id: number;
parentId: CategoryId;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
export interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
export const isLocalDirectoryInfo = (
info: MaybeDirectoryInfo,
): info is LocalDirectoryInfo & { exists: true } => info.exists && info.id !== "root";
export const isExistingFileInfo = (info: MaybeFileInfo): info is FileInfo & { exists: true } =>
info.exists;
+19
View File
@@ -0,0 +1,19 @@
import { storeFileThumbnailCache } from "#lib/client/cache/index.ts";
import { uploadFile as doUploadFile } from "#lib/client/transfer/index.ts";
import { invalidateDirectory } from "./directory.ts";
// Uploads the file and caches it with its thumbnail locally. Returns `false` if the upload is canceled.
export const uploadFile = async (
file: File,
parentId: DirectoryId,
onDuplicate: () => Promise<boolean>,
) => {
const res = await doUploadFile(file, parentId, onDuplicate);
if (!res) return false;
invalidateDirectory(parentId);
if (res.thumbnailBuffer) {
void storeFileThumbnailCache(res.fileId, res.thumbnailBuffer);
}
return true;
};
+46
View File
@@ -0,0 +1,46 @@
import {
getDirectoryInfo,
getFileInfo,
isExistingFileInfo,
isLocalDirectoryInfo,
type FileInfo,
type LocalCategoryInfo,
type LocalDirectoryInfo,
} from "#lib/features/filesystem/index.ts";
import { trpc } from "#trpc/client.ts";
export interface SearchFilter {
ancestorId: DirectoryId;
inFavorites: boolean;
categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[];
}
export interface SearchResult {
directories: LocalDirectoryInfo[];
files: FileInfo[];
}
const getCategoryIds = (filter: SearchFilter, type: "include" | "exclude") => {
return filter.categories.filter((category) => category.type === type).map(({ info }) => info.id);
};
export const search = async (filter: SearchFilter) => {
const { directories: directoriesRaw, files: filesRaw } = await trpc.search.search.query({
ancestor: filter.ancestorId,
inFavorites: filter.inFavorites,
includeCategories: getCategoryIds(filter, "include"),
excludeCategories: getCategoryIds(filter, "exclude"),
});
const [directories, files] = await Promise.all([
Promise.all(
directoriesRaw.map((directory) =>
getDirectoryInfo(directory.id, { serverResponse: directory }),
),
),
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
]);
return {
directories: directories.filter(isLocalDirectoryInfo),
files: files.filter(isExistingFileInfo),
} satisfies SearchResult;
};
@@ -0,0 +1,12 @@
<script lang="ts">
import { formatNetworkSpeed } from "#lib/utils/index.ts";
interface Props {
progress?: number;
rate?: number; // Bytes per second
}
let { progress, rate }: Props = $props();
</script>
전송됨 {Math.floor((progress ?? 0) * 100)}% · {formatNetworkSpeed((rate ?? 0) * 8)}
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FileUploadState } from "#lib/client/transfer/index.ts";
import TransferProgress from "./TransferProgress.svelte";
interface Props {
state: FileUploadState;
}
let { state }: Props = $props();
const statusTexts = {
queued: "대기 중",
"encryption-pending": "준비 중",
encrypting: "암호화하는 중",
"upload-pending": "업로드를 기다리는 중",
uploaded: "업로드 완료",
canceled: "",
error: "업로드 실패",
} satisfies Record<Exclude<FileUploadState["status"], "uploading">, string>;
</script>
{#if state.status === "uploading"}
<TransferProgress progress={state.progress} rate={state.rate} />
{:else}
{statusTexts[state.status]}
{/if}
@@ -0,0 +1,2 @@
export { default as TransferProgress } from "./TransferProgress.svelte";
export { default as UploadStatusText } from "./UploadStatusText.svelte";
-11
View File
@@ -1,11 +0,0 @@
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(input);
};
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
return await fetchInternal(input, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
});
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./callApi";
export * from "./gotoStateful";
-4
View File
@@ -1,4 +0,0 @@
export * from "./aes";
export * from "./rsa";
export * from "./sha";
export * from "./util";
-20
View File
@@ -1,20 +0,0 @@
export const digestMessage = async (message: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", message);
};
export const generateHmacSecret = async () => {
return {
hmacSecret: await window.crypto.subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
} satisfies HmacKeyGenParams,
true,
["sign", "verify"],
),
};
};
export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => {
return await window.crypto.subtle.sign("HMAC", hmacSecret, message);
};
-89
View File
@@ -1,89 +0,0 @@
import { writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
import {
directoryInfoStore,
fileInfoStore,
type DirectoryInfo,
type FileInfo,
} from "$lib/stores/file";
const fetchDirectoryInfo = async (
directoryId: "root" | number,
masterKey: CryptoKey,
infoStore: Writable<DirectoryInfo | null>,
) => {
const res = await callGetApi(`/api/directory/${directoryId}`);
if (!res.ok) throw new Error("Failed to fetch directory information");
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
let newInfo: DirectoryInfo;
if (directoryId === "root") {
newInfo = {
id: "root",
subDirectoryIds: subDirectories,
fileIds: files,
};
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
newInfo = {
id: directoryId,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
subDirectoryIds: subDirectories,
fileIds: files,
};
}
infoStore.update(() => newInfo);
};
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(directoryId);
if (!info) {
info = writable(null);
directoryInfoStore.set(directoryId, info);
}
fetchDirectoryInfo(directoryId, masterKey, info);
return info;
};
const fetchFileInfo = async (
fileId: number,
masterKey: CryptoKey,
infoStore: Writable<FileInfo | null>,
) => {
const res = await callGetApi(`/api/file/${fileId}`);
if (!res.ok) throw new Error("Failed to fetch file information");
const metadata: FileInfoResponse = await res.json();
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const newInfo: FileInfo = {
id: fileId,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};
infoStore.update(() => newInfo);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, masterKey, info);
return info;
};
+112
View File
@@ -0,0 +1,112 @@
import { ClientRepo, SessionRepo, db } from "#lib/server/db/index.ts";
import env from "#lib/server/config.ts";
import { verifySessionId } from "#lib/server/crypto.ts";
import { AppError, invalidSessionError } from "#lib/server/errors.ts";
export interface Session {
sessionId: string;
userId: number;
clientId?: number;
}
export interface ClientSession extends Session {
clientId: number;
}
export type SessionPermission =
"any" | "notClient" | "anyClient" | "pendingClient" | "activeClient";
export class AuthenticationError extends Error {
constructor(
public status: 400 | 401,
message: string,
) {
super(message);
this.name = "AuthenticationError";
}
}
export const cookieOptions = {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
} as const;
export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
if (!sessionId) {
throw new AuthenticationError(400, "Invalid session id");
}
const session = await SessionRepo.refreshSession(db, sessionId, ip, userAgent);
if (!session) {
throw new AuthenticationError(401, "Invalid session id");
}
return {
id: sessionId,
userId: session.userId,
clientId: session.clientId ?? undefined,
};
};
const ensureUserClientState = async (
userId: number,
clientId: number | undefined,
state: "pending" | "active",
) => {
if (!clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
}
const userClient = await ClientRepo.getUserClient(db, userId, clientId);
if (!userClient) {
throw invalidSessionError();
} else if (userClient.state !== state) {
throw new AppError("FORBIDDEN", "Forbidden");
}
};
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> {
if (!locals.session) {
throw new AppError("UNAUTHORIZED", "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
switch (requiredPermission) {
case "any":
break;
case "notClient":
if (clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
}
break;
case "anyClient":
if (!clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
}
break;
case "pendingClient":
await ensureUserClientState(userId, clientId, "pending");
break;
case "activeClient":
await ensureUserClientState(userId, clientId, "active");
break;
}
return { sessionId, userId, clientId };
}
+35
View File
@@ -0,0 +1,35 @@
import {
NODE_ENV,
DATABASE_HOST,
DATABASE_PORT,
DATABASE_USER,
DATABASE_PASSWORD,
DATABASE_NAME,
SESSION_SECRET,
SESSION_EXPIRES,
USER_CLIENT_CHALLENGE_EXPIRES,
SESSION_UPGRADE_CHALLENGE_EXPIRES,
LIBRARY_PATH,
THUMBNAILS_PATH,
} from "$app/env/private";
export default {
nodeEnv: NODE_ENV,
database: {
host: DATABASE_HOST,
port: DATABASE_PORT,
user: DATABASE_USER,
password: DATABASE_PASSWORD,
name: DATABASE_NAME,
},
session: {
secret: SESSION_SECRET,
exp: SESSION_EXPIRES,
},
challenge: {
userClientExp: USER_CLIENT_CHALLENGE_EXPIRES,
sessionUpgradeExp: SESSION_UPGRADE_CHALLENGE_EXPIRES,
},
libraryPath: LIBRARY_PATH,
thumbnailsPath: THUMBNAILS_PATH,
};
@@ -56,12 +56,13 @@ export const issueSessionId = async (length: number, secret: string) => {
export const verifySessionId = (sessionIdSigned: string, secret: string) => {
const [sessionIdHex, sessionIdHmac] = sessionIdSigned.split(".");
if (!sessionIdHex || !sessionIdHmac) return;
if (
timingSafeEqual(
Buffer.from(sessionIdHmac, "hex"),
createHmac("sha256", secret).update(Buffer.from(sessionIdHex, "hex")).digest(),
)
) {
const actualHmac = Buffer.from(sessionIdHmac, "hex");
const expectedHmac = createHmac("sha256", secret)
.update(Buffer.from(sessionIdHex, "hex"))
.digest();
// `timingSafeEqual` throws if the lengths differ (e.g. malformed hex)
if (actualHmac.length === expectedHmac.length && timingSafeEqual(actualHmac, expectedHmac)) {
return sessionIdHex;
}
};
-139
View File
@@ -1,139 +0,0 @@
import { SqliteError } from "better-sqlite3";
import { and, or, eq, gt, lte } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { client, userClient, userClientChallenge } from "./schema";
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db.transaction(
async (tx) => {
const clients = await tx
.select({ id: client.id })
.from(client)
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
.limit(1);
if (clients.length !== 0) {
throw new IntegrityError("Public key(s) already registered");
}
const newClients = await tx
.insert(client)
.values({ encPubKey, sigPubKey })
.returning({ id: client.id });
const { id: clientId } = newClients[0]!;
await tx.insert(userClient).values({ userId, clientId });
return clientId;
},
{ behavior: "exclusive" },
);
};
export const getClient = async (clientId: number) => {
const clients = await db.select().from(client).where(eq(client.id, clientId)).limit(1);
return clients[0] ?? null;
};
export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => {
const clients = await db
.select()
.from(client)
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
.limit(1);
return clients[0] ?? null;
};
export const createUserClient = async (userId: number, clientId: number) => {
try {
await db.insert(userClient).values({ userId, clientId });
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("User client already exists");
}
throw e;
}
};
export const getAllUserClients = async (userId: number) => {
return await db.select().from(userClient).where(eq(userClient.userId, userId));
};
export const getUserClient = async (userId: number, clientId: number) => {
const userClients = await db
.select()
.from(userClient)
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.limit(1);
return userClients[0] ?? null;
};
export const getUserClientWithDetails = async (userId: number, clientId: number) => {
const userClients = await db
.select()
.from(userClient)
.innerJoin(client, eq(userClient.clientId, client.id))
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.limit(1);
return userClients[0] ?? null;
};
export const setUserClientStateToPending = async (userId: number, clientId: number) => {
await db
.update(userClient)
.set({ state: "pending" })
.where(
and(
eq(userClient.userId, userId),
eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"),
),
);
};
export const setUserClientStateToActive = async (userId: number, clientId: number) => {
await db
.update(userClient)
.set({ state: "active" })
.where(
and(
eq(userClient.userId, userId),
eq(userClient.clientId, clientId),
eq(userClient.state, "pending"),
),
);
};
export const registerUserClientChallenge = async (
userId: number,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
await db.insert(userClientChallenge).values({
userId,
clientId,
answer,
allowedIp,
expiresAt,
});
};
export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
const challenges = await db
.delete(userClientChallenge)
.where(
and(
eq(userClientChallenge.userId, userId),
eq(userClientChallenge.answer, answer),
eq(userClientChallenge.allowedIp, ip),
gt(userClientChallenge.expiresAt, new Date()),
),
)
.returning({ clientId: userClientChallenge.clientId });
return challenges[0] ?? null;
};
export const cleanupExpiredUserClientChallenges = async () => {
await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date()));
};
+22
View File
@@ -0,0 +1,22 @@
import { PostgresDialect } from "kysely";
import pg from "pg";
export interface DatabaseConfig {
host?: string;
port?: number;
user?: string;
password?: string;
name?: string;
}
export const createDialect = (config: DatabaseConfig) => {
return new PostgresDialect({
pool: new pg.Pool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.name,
}),
});
};
-15
View File
@@ -1,15 +0,0 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import env from "$lib/server/loadenv";
const client = new Database(env.databaseUrl);
const db = drizzle(client);
export const migrateDB = () => {
if (process.env.NODE_ENV === "production") {
migrate(db, { migrationsFolder: "./drizzle" });
}
};
export default db;
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more