178 Commits

Author SHA1 Message Date
static
90ac5ba4c3 Merge pull request #15 from kmc7468/dev
v0.6.0
2025-12-27 14:22:26 +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
dfffa004ac Merge pull request #13 from kmc7468/dev
v0.5.1
2025-07-12 19:56:12 +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
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +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
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +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
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +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
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
8a620fac78 누락된 throw 추가 2025-01-13 03:44:09 +09:00
static
b8c7cda4d5 사소한 리팩토링 2025-01-13 03:33:01 +09:00
static
6a64bb45f2 마지막 접속 IP와 User Agent가 빈 값인 경우 DB에 해당 정보를 기록하지 않도록 수정 2025-01-13 03:17:54 +09:00
static
68a764bf28 DB 마이그레이션 스크립트 재생성 2025-01-13 02:54:28 +09:00
static
8bb4d70fa5 비밀번호 변경 페이지 구현 2025-01-13 02:53:32 +09:00
static
299787537e /api/auth/changePassword, /api/user, /api/user/changeNickname Endpoint 구현 2025-01-13 01:57:07 +09:00
static
bd1e9cf54f Merge pull request #4 from kmc7468/add-hmac-secret-key
파일 중복 검사 시스템 도입
2025-01-13 01:19:32 +09:00
static
e887fcf137 DB 마이그레이션 스크립트 재생성 2025-01-13 00:48:08 +09:00
static
5c7dc58f03 프론트엔드에서의 파일 업로드 전 중복 검사 구현 2025-01-13 00:46:35 +09:00
static
59c8523e25 암호 키 생성 및 등록시 HSK도 함께 생성 및 등록하도록 변경 2025-01-12 21:52:41 +09:00
static
805d7df182 /api/hsk/list, /api/hsk/register/initial Endpoint 구현 2025-01-12 20:26:48 +09:00
static
004e41b0cf MEK 등록시 로그를 남기도록 변경 2025-01-12 19:22:21 +09:00
static
f8115f4f2e 파일/디렉터리 생성/이름 변경시 로그를 남기도록 변경 2025-01-12 19:02:21 +09:00
static
aebbc6d0c0 Merge pull request #3 from kmc7468/switch-to-session-auth
세션 기반 인증으로 마이그레이션
2025-01-12 09:17:47 +09:00
static
85ebb529ba 프론트엔드에서 세션 ID 기반 인증 대응 및 DB 마이그레이션 스크립트 재생성 2025-01-12 08:31:11 +09:00
static
be8587694e 암호 키 등록 챌린지 처리 방식을 세션 업그레이드 챌린지 처리 방식과 동일하게 변경 2025-01-12 07:59:49 +09:00
static
1a86c8d9e0 백엔드에서 JWT가 아닌 세션 ID 기반으로 인증하도록 변경 2025-01-12 07:28:38 +09:00
static
0bdf990dae DB에 동시적으로 접근하더라도 데이터 무결성이 깨지지 않도록 DB 접근 코드 수정 2025-01-11 03:55:19 +09:00
static
045eb69487 Node.js, pnpm 및 기타 의존성 버전 업데이트
- Node.js 18에서 Node.js 22로 업데이트하였습니다.
- pnpm 8에서 pnpm 9으로 업데이트하였습니다.
- 기타 의존성은 메이저 버전이 바뀌지 않는 선에서 최신 버전으로 업데이트하였습니다.
2025-01-09 20:29:49 +09:00
256 changed files with 11882 additions and 7163 deletions

View File

@@ -1,5 +1,6 @@
.git .git
node_modules node_modules
/Makefile
# Output # Output
.output .output
@@ -10,13 +11,15 @@ node_modules
/build /build
/data /data
/library /library
/thumbnails
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# VSCode # Editors
/.vscode /.vscode
/.idea
# Env # Env
.env .env
@@ -27,6 +30,3 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# SQLite
*.db

View File

@@ -1,10 +1,14 @@
# Required environment variables # Required environment variables
JWT_SECRET= DATABASE_PASSWORD=
SESSION_SECRET=
# Optional environment variables # Optional environment variables
DATABASE_URL= DATABASE_HOST=
JWT_ACCESS_TOKEN_EXPIRES= DATABASE_PORT=
JWT_REFRESH_TOKEN_EXPIRES= DATABASE_USER=
DATABASE_NAME=
SESSION_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES=
TOKEN_UPGRADE_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH= LIBRARY_PATH=
THUMBNAILS_PATH=

7
.gitignore vendored
View File

@@ -9,13 +9,15 @@ node_modules
/build /build
/data /data
/library /library
/thumbnails
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# VSCode # Editors
/.vscode /.vscode
/.idea
# Env # Env
.env .env
@@ -26,6 +28,3 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# SQLite
*.db

View File

@@ -3,8 +3,5 @@ package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Output
/drizzle
# Documents # Documents
*.md *.md

View File

@@ -1,8 +1,8 @@
# Base Image # Base Image
FROM node:18-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@8 RUN npm install -g pnpm@10
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
# Build Stage # Build Stage
@@ -10,8 +10,9 @@ FROM base AS build
RUN pnpm fetch RUN pnpm fetch
COPY . . COPY . .
RUN pnpm install --offline RUN pnpm install --offline && \
RUN pnpm build pnpm build && \
sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
# Deploy Stage # Deploy Stage
FROM base FROM base
@@ -21,9 +22,7 @@ COPY package.json .
RUN pnpm install --offline --prod RUN pnpm install --offline --prod
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
COPY drizzle ./drizzle
EXPOSE 3000 EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"] CMD ["node", "./build/index.js"]

View File

@@ -23,19 +23,19 @@ vim .env # 아래를 참고하여 환경 변수를 설정해 주세요.
docker compose up --build -d docker compose up --build -d
``` ```
모든 데이터는 `./data` 디렉터리에 저장될 거예요. 모든 데이터는 `./data` 디렉터리에 아래에 저장될 거예요.
### Environment Variables ### Environment Variables
필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요. 필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요.
|이름|필수|기본값|설명| |이름|필수|기본값|설명|
|-:|:-:|:-:|:-| |:-|:-:|:-:|:-|
|`JWT_SECRET`|Y||JWT의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.| |`DATABASE_PASSWORD`|Y||데이터베이스에 접근하기 위해 필요한 비밀번호예요. 안전한 값으로 설정해 주세요.|
|`JWT_ACCESS_TOKEN_EXPIRES`||`5m`|Access Token의 유효 시간이에요.| |`SESSION_SECRET`|Y||Session ID의 서명에 사용되는 비밀번호예요. 안전한 값으로 설정해 주세요.|
|`JWT_REFRESH_TOKEN_EXPIRES`||`14d`|Refresh Token의 유효 시간이에요.| |`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.| |`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|`TOKEN_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.| |`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.| |`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.|
|`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.| |`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.|
|`PORT`||`80`|ArkVault 서버의 포트예요.| |`PORT`||`80`|ArkVault 서버의 포트예요.|

15
docker-compose.dev.yaml Normal file
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:

View File

@@ -2,21 +2,42 @@ services:
server: server:
build: . build: .
restart: unless-stopped restart: unless-stopped
depends_on:
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes: volumes:
- ./data:/app/data - ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails
environment: environment:
# ArkVault # ArkVault
- DATABASE_URL=/app/data/database.sqlite - DATABASE_HOST=database
- JWT_SECRET=${JWT_SECRET:?} # Required - DATABASE_USER=arkvault
- JWT_ACCESS_TOKEN_EXPIRES - DATABASE_PASSWORD=${DATABASE_PASSWORD:?} # Required
- JWT_REFRESH_TOKEN_EXPIRES - SESSION_SECRET=${SESSION_SECRET:?} # Required
- SESSION_EXPIRES
- USER_CLIENT_CHALLENGE_EXPIRES - USER_CLIENT_CHALLENGE_EXPIRES
- TOKEN_UPGRADE_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library - LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails
# SvelteKit # SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-} - XFF_DEPTH=${TRUST_PROXY:-}
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
ports: ports:
- ${PORT:-80}:3000 - ${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

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",
});

View File

@@ -1,119 +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,
`challenge` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
`is_used` integer DEFAULT false 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
);
--> statement-breakpoint
CREATE TABLE `directory` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`created_at` integer 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 `file` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`path` text NOT NULL,
`parent_id` integer,
`created_at` integer NOT NULL,
`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,
`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
);
--> 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,
`created_by` integer NOT NULL,
`created_at` 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,
FOREIGN KEY (`created_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `refresh_token` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer,
`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
);
--> statement-breakpoint
CREATE TABLE `token_upgrade_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`refresh_token_id` text NOT NULL,
`client_id` integer NOT NULL,
`challenge` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
`is_used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`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
);
--> 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_challenge_unique` ON `user_client_challenge` (`challenge`);--> 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 `refresh_token_user_id_client_id_unique` ON `refresh_token` (`user_id`,`client_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);

View File

@@ -1,874 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "929c6bca-d0c0-4899-afc6-a0a498226f28",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"client": {
"name": "client",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"encryption_public_key": {
"name": "encryption_public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"signature_public_key": {
"name": "signature_public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"client_encryption_public_key_unique": {
"name": "client_encryption_public_key_unique",
"columns": [
"encryption_public_key"
],
"isUnique": true
},
"client_signature_public_key_unique": {
"name": "client_signature_public_key_unique",
"columns": [
"signature_public_key"
],
"isUnique": true
},
"client_encryption_public_key_signature_public_key_unique": {
"name": "client_encryption_public_key_signature_public_key_unique",
"columns": [
"encryption_public_key",
"signature_public_key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user_client": {
"name": "user_client",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'challenging'"
}
},
"indexes": {},
"foreignKeys": {
"user_client_user_id_user_id_fk": {
"name": "user_client_user_id_user_id_fk",
"tableFrom": "user_client",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"user_client_client_id_client_id_fk": {
"name": "user_client_client_id_client_id_fk",
"tableFrom": "user_client",
"tableTo": "client",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_client_user_id_client_id_pk": {
"columns": [
"client_id",
"user_id"
],
"name": "user_client_user_id_client_id_pk"
}
},
"uniqueConstraints": {}
},
"user_client_challenge": {
"name": "user_client_challenge",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"allowed_ip": {
"name": "allowed_ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_client_challenge_challenge_unique": {
"name": "user_client_challenge_challenge_unique",
"columns": [
"challenge"
],
"isUnique": true
}
},
"foreignKeys": {
"user_client_challenge_user_id_user_id_fk": {
"name": "user_client_challenge_user_id_user_id_fk",
"tableFrom": "user_client_challenge",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"user_client_challenge_client_id_client_id_fk": {
"name": "user_client_challenge_client_id_client_id_fk",
"tableFrom": "user_client_challenge",
"tableTo": "client",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"directory": {
"name": "directory",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"master_encryption_key_version": {
"name": "master_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_data_encryption_key": {
"name": "encrypted_data_encryption_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"data_encryption_key_version": {
"name": "data_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_name": {
"name": "encrypted_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"directory_encrypted_data_encryption_key_unique": {
"name": "directory_encrypted_data_encryption_key_unique",
"columns": [
"encrypted_data_encryption_key"
],
"isUnique": true
}
},
"foreignKeys": {
"directory_user_id_user_id_fk": {
"name": "directory_user_id_user_id_fk",
"tableFrom": "directory",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"directory_parent_id_directory_id_fk": {
"name": "directory_parent_id_directory_id_fk",
"tableFrom": "directory",
"tableTo": "directory",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"directory_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
"name": "directory_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
"tableFrom": "directory",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"master_encryption_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"file": {
"name": "file",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"master_encryption_key_version": {
"name": "master_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_data_encryption_key": {
"name": "encrypted_data_encryption_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"data_encryption_key_version": {
"name": "data_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_content_iv": {
"name": "encrypted_content_iv",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_name": {
"name": "encrypted_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"file_path_unique": {
"name": "file_path_unique",
"columns": [
"path"
],
"isUnique": true
},
"file_encrypted_data_encryption_key_unique": {
"name": "file_encrypted_data_encryption_key_unique",
"columns": [
"encrypted_data_encryption_key"
],
"isUnique": true
}
},
"foreignKeys": {
"file_parent_id_directory_id_fk": {
"name": "file_parent_id_directory_id_fk",
"tableFrom": "file",
"tableTo": "directory",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"file_user_id_user_id_fk": {
"name": "file_user_id_user_id_fk",
"tableFrom": "file",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"file_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
"name": "file_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
"tableFrom": "file",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"master_encryption_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"client_master_encryption_key": {
"name": "client_master_encryption_key",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_key_signature": {
"name": "encrypted_key_signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"client_master_encryption_key_user_id_user_id_fk": {
"name": "client_master_encryption_key_user_id_user_id_fk",
"tableFrom": "client_master_encryption_key",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"client_master_encryption_key_client_id_client_id_fk": {
"name": "client_master_encryption_key_client_id_client_id_fk",
"tableFrom": "client_master_encryption_key",
"tableTo": "client",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"client_master_encryption_key_user_id_version_master_encryption_key_user_id_version_fk": {
"name": "client_master_encryption_key_user_id_version_master_encryption_key_user_id_version_fk",
"tableFrom": "client_master_encryption_key",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"client_master_encryption_key_user_id_client_id_version_pk": {
"columns": [
"client_id",
"user_id",
"version"
],
"name": "client_master_encryption_key_user_id_client_id_version_pk"
}
},
"uniqueConstraints": {}
},
"master_encryption_key": {
"name": "master_encryption_key",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retired_at": {
"name": "retired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"master_encryption_key_user_id_user_id_fk": {
"name": "master_encryption_key_user_id_user_id_fk",
"tableFrom": "master_encryption_key",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"master_encryption_key_created_by_client_id_fk": {
"name": "master_encryption_key_created_by_client_id_fk",
"tableFrom": "master_encryption_key",
"tableTo": "client",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"master_encryption_key_user_id_version_pk": {
"columns": [
"user_id",
"version"
],
"name": "master_encryption_key_user_id_version_pk"
}
},
"uniqueConstraints": {}
},
"refresh_token": {
"name": "refresh_token",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"refresh_token_user_id_client_id_unique": {
"name": "refresh_token_user_id_client_id_unique",
"columns": [
"user_id",
"client_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_token_user_id_user_id_fk": {
"name": "refresh_token_user_id_user_id_fk",
"tableFrom": "refresh_token",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"refresh_token_client_id_client_id_fk": {
"name": "refresh_token_client_id_client_id_fk",
"tableFrom": "refresh_token",
"tableTo": "client",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"token_upgrade_challenge": {
"name": "token_upgrade_challenge",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"refresh_token_id": {
"name": "refresh_token_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"allowed_ip": {
"name": "allowed_ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"token_upgrade_challenge_challenge_unique": {
"name": "token_upgrade_challenge_challenge_unique",
"columns": [
"challenge"
],
"isUnique": true
}
},
"foreignKeys": {
"token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": {
"name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk",
"tableFrom": "token_upgrade_challenge",
"tableTo": "refresh_token",
"columnsFrom": [
"refresh_token_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"token_upgrade_challenge_client_id_client_id_fk": {
"name": "token_upgrade_challenge_client_id_client_id_fk",
"tableFrom": "token_upgrade_challenge",
"tableTo": "client",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1736170919561,
"tag": "0000_handy_captain_marvel",
"breakpoints": true
}
]
}

View File

@@ -1,21 +1,24 @@
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat"; import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte"; import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss"; import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals"; import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint"; import ts from "typescript-eslint";
import { fileURLToPath } from "url";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs["flat/recommended"], ...svelte.configs.recommended,
...tailwind.configs["flat/recommended"], ...tailwind.configs["flat/recommended"],
prettier, prettier,
...svelte.configs["flat/prettier"], ...svelte.configs.prettier,
{ {
languageOptions: { languageOptions: {
globals: { globals: {
@@ -23,13 +26,18 @@ export default ts.config(
...globals.node, ...globals.node,
}, },
}, },
rules: {
"no-undef": "off",
},
}, },
{ {
files: ["**/*.svelte"], files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser, parser: ts.parser,
svelteConfig,
}, },
}, },
}, },

18
kysely.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "kysely-ctl";
import { Pool } from "pg";
export default defineConfig({
dialect: "pg",
dialectConfig: {
pool: new Pool({
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT ? parseInt(process.env.DATABASE_PORT) : undefined,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
}),
},
migrations: {
migrationFolder: "./src/lib/server/db/migrations",
},
});

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.1.0", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -11,52 +11,62 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"db:push": "drizzle-kit push", "db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"db:generate": "drizzle-kit generate", "db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "drizzle-kit migrate", "db:migrate": "kysely migrate"
"db:studio": "drizzle-kit studio"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.3", "@eslint/compat": "^2.0.0",
"@iconify-json/material-symbols": "^1.2.12", "@iconify-json/material-symbols": "^1.2.50",
"@sveltejs/adapter-node": "^5.2.9", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/better-sqlite3": "^7.6.11", "@tanstack/svelte-virtual": "^3.13.13",
"@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/jsonwebtoken": "^9.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7", "@types/node-schedule": "^2.1.8",
"autoprefixer": "^10.4.20", "@types/pg": "^8.16.0",
"dexie": "^4.0.10", "autoprefixer": "^10.4.23",
"drizzle-kit": "^0.22.0", "axios": "^1.13.2",
"eslint": "^9.7.0", "dexie": "^4.2.1",
"eslint-config-prettier": "^9.1.0", "eslint": "^9.39.2",
"eslint-plugin-svelte": "^2.36.0", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-svelte": "^3.13.1",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.33.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^15.0.0", "globals": "^16.5.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"mime": "^4.0.6", "kysely-ctl": "^0.19.0",
"prettier": "^3.3.2", "lru-cache": "^11.2.4",
"prettier-plugin-svelte": "^3.2.6", "mime": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.5", "p-limit": "^7.2.0",
"svelte": "^5.0.0", "prettier": "^3.7.4",
"svelte-check": "^4.0.0", "prettier-plugin-svelte": "^3.4.1",
"tailwindcss": "^3.4.9", "prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "^5.0.0", "svelte": "^5.46.1",
"typescript-eslint": "^8.0.0", "svelte-check": "^4.3.5",
"unplugin-icons": "^0.22.0", "tailwindcss": "^3.4.19",
"vite": "^5.4.11" "typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
}, },
"dependencies": { "dependencies": {
"argon2": "^0.41.1", "@fastify/busboy": "^3.2.0",
"better-sqlite3": "^11.1.2", "@trpc/server": "^11.8.1",
"drizzle-orm": "^0.33.0", "argon2": "^0.44.0",
"jsonwebtoken": "^9.0.2", "kysely": "^0.28.9",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"uuid": "^11.0.3", "pg": "^8.16.3",
"zod": "^3.24.1" "superjson": "^2.2.6",
"uuid": "^13.0.0",
"zod": "^4.2.1"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^10.0.0"
} }
} }

5568
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

14
src/app.d.ts vendored
View File

@@ -5,11 +5,15 @@ import "unplugin-icons/types/svelte";
declare global { declare global {
namespace App { namespace App {
// interface Error {} interface Locals {
// interface Locals {} ip: string;
// interface PageData {} userAgent: string;
// interface PageState {} session?: {
// interface Platform {} id: string;
userId: number;
clientId?: number;
};
}
} }
} }

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ko">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />

View File

@@ -1,6 +1,17 @@
import type { ClientInit } from "@sveltejs/kit"; import type { ClientInit } from "@sveltejs/kit";
import { getClientKey, getMasterKeys } from "$lib/indexedDB"; import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { clientKeyStore, masterKeyStore } from "$lib/stores"; import { prepareFileCache } from "$lib/modules/file";
import { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
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.");
}
};
const prepareClientKeyStore = async () => { const prepareClientKeyStore = async () => {
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([ const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
@@ -21,6 +32,22 @@ const prepareMasterKeyStore = async () => {
} }
}; };
export const init: ClientInit = async () => { const prepareHmacSecretStore = async () => {
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore()]); const hmacSecrets = await getHmacSecrets();
if (hmacSecrets.length > 0) {
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
}
};
export const init: ClientInit = async () => {
await Promise.all([
requestPersistentStorage(),
prepareFileCache(),
prepareClientKeyStore(),
prepareMasterKeyStore(),
prepareHmacSecretStore(),
prepareOpfs(),
]);
cleanupDanglingInfos(); // Intended
}; };

View File

@@ -1,34 +1,22 @@
import { redirect, type ServerInit, type Handle } from "@sveltejs/kit"; import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import schedule from "node-schedule"; import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/drizzle"; import { migrateDB } from "$lib/server/db/kysely";
import { import {
cleanupExpiredRefreshTokens, cleanupExpiredSessions,
cleanupExpiredTokenUpgradeChallenges, cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/token"; } from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
export const init: ServerInit = () => { export const init: ServerInit = async () => {
migrateDB(); await migrateDB();
schedule.scheduleJob("0 * * * *", () => { schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges(); cleanupExpiredUserClientChallenges();
cleanupExpiredRefreshTokens(); cleanupExpiredSessions();
cleanupExpiredTokenUpgradeChallenges(); cleanupExpiredSessionUpgradeChallenges();
}); });
}; };
export const handle: Handle = async ({ event, resolve }) => { export const handle = sequence(setAgentInfo, authenticate);
if (["/api", "/auth"].some((path) => event.url.pathname.startsWith(path))) {
return await resolve(event);
}
const accessToken = event.cookies.get("accessToken");
if (accessToken) {
return await resolve(event);
} else {
redirect(
302,
"/auth/login?redirect=" + encodeURIComponent(event.url.pathname + event.url.search),
);
}
};

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}

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}

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>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import { fade, fly } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/atoms";
interface Props {
children: Snippet;
class?: ClassValue;
isOpen: boolean;
onclose?: () => void;
}
let { children, class: className, isOpen = $bindable(), onclose }: Props = $props();
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={onclose || (() => (isOpen = false))}
class="fixed inset-0 z-10 flex items-end justify-center"
>
<div
class="absolute inset-0 bg-black bg-opacity-50"
transition:fade|global={{ duration: 100 }}
></div>
<AdaptiveDiv class="z-10 w-full">
<div
onclick={(e) => e.stopPropagation()}
class="flex max-h-[70vh] min-h-[30vh] flex-col rounded-t-2xl bg-white"
transition:fly|global={{ y: 100, duration: 200 }}
>
<div class={["flex-grow overflow-y-auto", className]}>
{@render children()}
</div>
</div>
</AdaptiveDiv>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import { fade } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/atoms";
interface Props {
children: Snippet;
class?: ClassValue;
isOpen: boolean;
onclose?: () => void;
}
let { children, class: className, isOpen = $bindable(), onclose }: Props = $props();
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={onclose || (() => (isOpen = false))}
class="fixed inset-0 z-10 bg-black bg-opacity-50"
transition:fade|global={{ duration: 100 }}
>
<AdaptiveDiv class="flex h-full items-center justify-center px-4">
<div onclick={(e) => e.stopPropagation()} class={["rounded-2xl bg-white p-4", className]}>
{@render children()}
</div>
</AdaptiveDiv>
</div>
{/if}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
interface Props {
actionButtonClass?: ClassValue;
actionButtonIcon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet;
class?: ClassValue;
onActionButtonClick?: () => void;
onclick?: () => void;
}
let {
actionButtonIcon: ActionButtonIcon,
actionButtonClass: actionButtonClassName,
children,
class: className,
onActionButtonClick,
onclick,
}: Props = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id="container"
onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl", className]}
>
<div id="children" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-grow overflow-x-hidden">
{@render children()}
</div>
{#if ActionButtonIcon}
<button
id="action-button"
onclick={(e) => {
e.stopPropagation();
if (onActionButtonClick) {
setTimeout(onActionButtonClick, 100);
}
}}
class={["flex-shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
>
<ActionButtonIcon />
</button>
{/if}
</div>
</div>
<style>
#container:active:not(:has(#action-button:active)) {
@apply bg-gray-100;
}
#children:active:not(:has(#action-button:active)) {
@apply scale-95;
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
color?: "primary" | "gray";
onclick?: () => void;
}
let { children, class: className, color = "primary", onclick }: Props = $props();
let bgColor = $derived(
{
primary: "bg-primary-600 active:bg-primary-500",
gray: "bg-gray-300 active:bg-gray-400",
}[color],
);
let textColor = $derived(
{
primary: "text-white",
gray: "text-gray-800",
}[color],
);
</script>
<button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={[
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
bgColor,
textColor,
className,
]}
>
{@render children()}
</button>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import IconChevronRight from "~icons/material-symbols/chevron-right";
interface Props {
children: Snippet;
class?: ClassValue;
onclick?: () => void;
}
let { children, class: className, onclick }: Props = $props();
</script>
<button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl active:bg-gray-100", className]}
>
<div class="flex h-full items-center gap-x-4 p-2 transition active:scale-95">
<div class="flex-grow">
{@render children()}
</div>
<IconChevronRight class="flex-shrink-0 text-xl text-gray-800" />
</div>
</button>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props {
info: Writable<FileInfo | null>;
onclick?: (file: FileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail: string | undefined = $state();
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<button
onclick={() => onclick?.($info)}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#if thumbnail}
<img src={thumbnail} alt={$info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
</button>
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { AdaptiveDiv } from "$lib/components/atoms";
interface Props {
class: ClassValue;
icon: Component<SvelteHTMLElements["svg"]>;
onclick?: () => void;
}
let { class: className, icon: Icon, onclick }: Props = $props();
</script>
<div class="pointer-events-none fixed inset-0">
<AdaptiveDiv class="relative h-full">
<button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={[
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 text-xl shadow-lg transition active:scale-95 active:bg-gray-400",
className,
]}
>
<Icon />
</button>
</AdaptiveDiv>
</div>

View File

@@ -10,14 +10,10 @@
</script> </script>
<button <button
onclick={() => { onclick={onclick && (() => setTimeout(onclick, 100))}
setTimeout(() => {
onclick?.();
}, 100);
}}
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100" class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
> >
<div class="h-full w-full p-1 transition active:scale-95"> <div class="h-full p-1 transition active:scale-95">
{@render children?.()} {@render children()}
</div> </div>
</button> </button>

View File

@@ -1,4 +1,6 @@
export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
export { default as Button } from "./Button.svelte"; export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte"; export { default as EntryButton } from "./EntryButton.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte"; export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte"; export { default as TextButton } from "./TextButton.svelte";

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
}
let { children, class: className }: Props = $props();
</script>
<div class={["mx-auto max-w-screen-md", className]}>
{@render children()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
}
let { children, class: className }: Props = $props();
</script>
<div class={["sticky bottom-0 bg-white pb-4", className]}>
{@render children()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
}
let { children, class: className }: Props = $props();
</script>
<div class={["flex flex-grow flex-col justify-between px-4", className]}>
{@render children()}
</div>

View File

@@ -1,3 +1,3 @@
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte"; export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
export { default as BottomDiv } from "./BottomDiv.svelte"; export { default as BottomDiv } from "./BottomDiv.svelte";
export { default as TitleDiv } from "./TitleDiv.svelte"; export { default as FullscreenDiv } from "./FullscreenDiv.svelte";

View File

@@ -1,3 +1,5 @@
export { default as BottomSheet } from "./BottomSheet.svelte"; export { default as BottomSheet } from "./BottomSheet.svelte";
export * from "./buttons";
export * from "./divs";
export * from "./inputs";
export { default as Modal } from "./Modal.svelte"; export { default as Modal } from "./Modal.svelte";
export { default as TopBar } from "./TopBar.svelte";

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from "svelte";
import IconCheckCircle from "~icons/material-symbols/check-circle";
import IconCheckCircleOutline from "~icons/material-symbols/check-circle-outline";
interface Props {
checked?: boolean;
children: Snippet;
}
let { checked = $bindable(false), children }: Props = $props();
</script>
<label class="flex items-center gap-x-1">
<input bind:checked type="checkbox" class="hidden" />
{@render children()}
{#if checked}
<IconCheckCircle class="text-primary-600" />
{:else}
<IconCheckCircleOutline class="text-gray-300" />
{/if}
</label>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
placeholder: string;
type?: "text" | "password";
value?: string;
}
let { class: className, placeholder, type = "text", value = $bindable("") }: Props = $props();
</script>
<div class={className}>
<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="pointer-events-none 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>
</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>

View File

@@ -1 +1,2 @@
export { default as CheckBox } from "./CheckBox.svelte";
export { default as TextInput } from "./TextInput.svelte"; export { default as TextInput } from "./TextInput.svelte";

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 rounded-xl font-medium"
>
<div class="h-full w-full p-3 transition active:scale-95">
{@render children?.()}
</div>
</button>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
interface Props {
icon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet;
}
let { icon: Icon, children }: Props = $props();
</script>
<div>
<div class="box-content flex min-h-[10vh] items-center pt-4">
{#if Icon}
<Icon class="text-5xl text-gray-600" />
{/if}
</div>
{@render children?.()}
</div>

View File

@@ -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>

View File

@@ -0,0 +1,57 @@
<script module lang="ts">
export type ConfirmHandler = () => void | Promise<void> | boolean | Promise<boolean>;
</script>
<script lang="ts">
import type { Snippet } from "svelte";
import { Button, Modal } from "$lib/components/atoms";
interface Props {
cancelText?: string;
children: Snippet;
confirmText: string;
isOpen: boolean;
onbeforeclose?: () => void;
oncancel?: () => void;
onConfirmClick: ConfirmHandler;
title: string;
}
let {
cancelText = "닫기",
children,
confirmText,
isOpen = $bindable(),
onbeforeclose,
oncancel,
onConfirmClick,
title,
}: Props = $props();
const closeModal = () => {
onbeforeclose?.();
isOpen = false;
};
const cancelAction = () => {
oncancel?.();
closeModal();
};
const confirmAction = async () => {
if ((await onConfirmClick()) !== false) {
closeModal();
}
};
</script>
<Modal bind:isOpen onclose={cancelAction} class="space-y-4">
<div class="flex flex-col gap-y-2 break-keep">
<p class="text-xl font-bold">{title}</p>
{@render children()}
</div>
<div class="flex gap-x-2">
<Button color="gray" onclick={cancelAction} class="flex-1">{cancelText}</Button>
<Button onclick={confirmAction} class="flex-1">{confirmText}</Button>
</div>
</Modal>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { untrack, type Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
sortBy?: SortBy;
}
let {
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
$effect(() => {
categoriesWithName = categories.map((category) => ({
name: get(category)?.name,
info: category,
}));
const sort = () => {
sortEntries(categoriesWithName, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = categoriesWithName.map((category) =>
category.info.subscribe((value) => {
if (category.name === value?.name) return;
category.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as { info }}
<Category
{info}
menuIcon={categoryMenuIcon}
onclick={onCategoryClick}
onMenuClick={onCategoryMenuClick}
/>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
}
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onMenuClick!({ id, dataKey, dataKeyVersion, name });
};
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,2 @@
export { default } from "./Categories.svelte";
export * from "./service";

View File

@@ -0,0 +1,6 @@
export interface SelectedCategory {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { EntryButton } from "$lib/components/atoms";
import { IconLabel } from "$lib/components/molecules";
interface Props {
children: Snippet;
class?: ClassValue;
icon: Component<SvelteHTMLElements["svg"]>;
iconClass?: ClassValue;
onclick?: () => void;
textClass?: ClassValue;
}
let {
children,
class: className,
icon,
iconClass: iconClassName,
onclick,
textClass: textClassName,
}: Props = $props();
</script>
<EntryButton {onclick} class={className}>
<IconLabel {icon} class="h-full" iconClass={iconClassName} textClass={textClassName}>
{@render children()}
</IconLabel>
</EntryButton>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
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";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
}
let {
class: className,
info,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
subCategoryCreatePosition = "bottom",
subCategoryMenuIcon,
}: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</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}
{#key info}
<Categories
categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{/key}
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { TitleLabel } from "$lib/components/molecules";
interface Props {
children?: Snippet;
childrenClass?: ClassValue;
class?: ClassValue;
description?: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
title: Snippet;
titleClass?: ClassValue;
}
let {
children,
childrenClass: childrenClassName,
class: className,
description,
icon,
title,
titleClass: titleClassName,
}: Props = $props();
</script>
<div class={["space-y-4 py-4", className]}>
<div class="space-y-2 break-keep">
<TitleLabel {icon} textClass={titleClassName}>
{@render title()}
</TitleLabel>
{#if description}
<p>
{@render description()}
</p>
{/if}
</div>
{#if children}
<div class={childrenClassName}>
{@render children()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import IconArrowBack from "~icons/material-symbols/arrow-back";
interface Props {
children?: Snippet;
class?: ClassValue;
onBackClick?: () => void;
title?: string;
}
let { children, class: className, onBackClick, title }: Props = $props();
</script>
<div
class={[
"sticky top-0 z-10 flex items-center justify-between gap-x-2 px-2 py-3 backdrop-blur-2xl",
className,
]}
>
<button
onclick={onBackClick || (() => history.back())}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
>
<IconArrowBack class="text-2xl" />
</button>
{#if title}
<p class="flex-grow truncate text-center text-lg font-semibold">{title}</p>
{/if}
<div class="w-[2.3rem] flex-shrink-0">
{#if children}
{@render children()}
{/if}
</div>
</div>

View File

@@ -0,0 +1,9 @@
export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories";
export { default as Categories } from "./Categories";
export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte";
export { default as TitledDiv } from "./TitledDiv.svelte";
export { default as TopBar } from "./TopBar.svelte";

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
import { IconLabel } from "$lib/components/molecules";
import IconCategory from "~icons/material-symbols/category";
interface Props {
class?: ClassValue;
name: string;
subtext?: string;
textClass?: ClassValue;
}
let { class: className, name, subtext, textClass: textClassName }: Props = $props();
</script>
{#snippet subtextSnippet()}
{subtext}
{/snippet}
<IconLabel
icon={IconCategory}
subtext={subtext ? subtextSnippet : undefined}
class={className}
textClass={textClassName}
>
{name}
</IconLabel>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
import { IconLabel } from "$lib/components/molecules";
import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
import IconDraft from "~icons/material-symbols/draft";
interface Props {
class?: ClassValue;
name: string;
subtext?: string;
textClass?: ClassValue;
thumbnail?: string;
type: "directory" | "parent-directory" | "file";
}
let {
class: className,
name,
subtext,
textClass: textClassName,
thumbnail,
type,
}: Props = $props();
</script>
{#snippet iconSnippet()}
<div class="flex h-10 w-10 items-center justify-center text-xl">
{#if thumbnail}
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
{:else if type === "directory"}
<IconFolder />
{:else if type === "parent-directory"}
<IconDriveFolderUpload class="text-yellow-500" />
{:else}
<IconDraft class="text-blue-400" />
{/if}
</div>
{/snippet}
{#snippet subtextSnippet()}
{subtext}
{/snippet}
<IconLabel
{iconSnippet}
subtext={subtext ? subtextSnippet : undefined}
class={className}
textClass={textClassName}
>
{name}
</IconLabel>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
icon?: Component<SvelteHTMLElements["svg"]>;
iconClass?: ClassValue;
iconSnippet?: Snippet;
subtext?: Snippet;
textClass?: ClassValue;
}
let {
children,
class: className,
icon: Icon,
iconClass: iconClassName,
iconSnippet,
subtext,
textClass: textClassName,
}: Props = $props();
</script>
<div class={["flex items-center gap-x-4", className]}>
{#if iconSnippet}
<div class={["flex-shrink-0", iconClassName]}>
{@render iconSnippet()}
</div>
{:else if Icon}
<div class={["flex-shrink-0 text-lg", iconClassName]}>
<Icon />
</div>
{/if}
<div class="flex flex-grow flex-col overflow-x-hidden text-left">
<p class={["truncate font-medium", textClassName]}>
{@render children()}
</p>
{#if subtext}
<p class="truncate text-xs text-gray-800">
{@render subtext()}
</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
icon?: Component<SvelteHTMLElements["svg"]>;
textClass?: ClassValue;
}
let { children, class: className, icon: Icon, textClass: textClassName }: Props = $props();
</script>
<div class={className}>
<div class="flex min-h-[10vh] items-center">
{#if Icon}
<Icon class="text-5xl text-gray-600" />
{/if}
</div>
<p class={["text-3xl font-bold", textClassName]}>
{@render children()}
</p>
</div>

View File

@@ -0,0 +1,4 @@
export { default as CategoryLabel } from "./CategoryLabel.svelte";
export { default as DirectoryEntryLabel } from "./DirectoryEntryLabel.svelte";
export { default as IconLabel } from "./IconLabel.svelte";
export { default as TitleLabel } from "./TitleLabel.svelte";

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte";
import type { SelectedFile } from "./service";
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: CategoryInfo;
onFileClick: (file: SelectedFile) => void;
onFileRemoveClick: (file: SelectedFile) => void;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
}
let {
info,
onFileClick,
onFileRemoveClick,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(),
}: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
$effect(() => {
files =
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: get(info)?.name,
info,
isRecursive,
};
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<div class="space-y-4">
<div class="space-y-4 bg-white p-4">
{#if info.id !== "root"}
<p class="text-lg font-bold text-gray-800">하위 카테고리</p>
{/if}
<SubCategories
{info}
{onSubCategoryClick}
{onSubCategoryCreateClick}
{onSubCategoryMenuClick}
subCategoryMenuIcon={IconMoreVert}
/>
</div>
{#if info.id !== "root"}
<div class="space-y-4 bg-white p-4">
<div class="flex items-center justify-between">
<p class="text-lg font-bold text-gray-800">파일</p>
<CheckBox bind:checked={isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
</div>
<div class="space-y-1">
{#key info}
{#each files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/>
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,2 @@
export { default } from "./Category.svelte";
export * from "./service";

View File

@@ -0,0 +1,8 @@
export { requestFileThumbnailDownload } from "$lib/services/file";
export interface SelectedFile {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props {
files: Writable<FileInfo | null>[];
onFileClick?: (file: FileInfo) => void;
}
let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; key: string; label: string }
| { type: "items"; key: string; items: FileEntry[] };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
let listElement: HTMLDivElement | undefined = $state();
const virtualizer = createWindowVirtualizer({
count: 0,
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$effect(() => {
filesWithDate = files.map((file) => {
const info = get(file);
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!),
});
newRows.push({
type: "items",
key: `items-${date}`,
items: entries,
});
}
rows = newRows;
$virtualizer.setOptions({ count: rows.length });
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<div bind:this={listElement} class="relative flex flex-grow flex-col">
<div style="height: {$virtualizer.getTotalSize()}px;">
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
{@const row = rows[virtualRow.index]!}
<div
use:measureRow
data-index={virtualRow.index}
class="absolute left-0 top-0 w-full"
style="transform: translateY({virtualRow.start}px);"
>
{#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p>
{:else}
<div class="grid grid-cols-4 gap-1 pb-4">
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,4 @@
export * from "./Category";
export { default as Category } from "./Category";
export { default as Gallery } from "./Gallery.svelte";
export * from "./modals";

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { TextInputModal } from "$lib/components/organisms";
interface Props {
isOpen: boolean;
onCreateClick: (name: string) => Promise<boolean>;
}
let { isOpen = $bindable(), onCreateClick }: Props = $props();
</script>
<TextInputModal
bind:isOpen
title="새 카테고리"
placeholder="카테고리 이름"
submitText="만들기"
onSubmitClick={onCreateClick}
/>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
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>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { TextInputModal } from "$lib/components/organisms";
interface Props {
isOpen: boolean;
onbeforeclose?: () => void;
onRenameClick: (newName: string) => Promise<boolean>;
originalName: string | undefined;
}
let { isOpen = $bindable(), onbeforeclose, onRenameClick, originalName }: Props = $props();
</script>
<TextInputModal
bind:isOpen
{onbeforeclose}
title="이름 바꾸기"
placeholder="이름"
defaultValue={originalName}
submitText="바꾸기"
onSubmitClick={onRenameClick}
/>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { TextInput } from "$lib/components/atoms";
import { ActionModal, type ConfirmHandler } from "$lib/components/molecules";
interface Props {
defaultValue?: string;
isOpen: boolean;
onbeforeclose?: () => void;
onSubmitClick: (value: string) => ReturnType<ConfirmHandler>;
placeholder: string;
submitText: string;
title: string;
}
let {
defaultValue = "",
isOpen = $bindable(),
onbeforeclose,
onSubmitClick,
placeholder,
submitText,
title,
}: Props = $props();
let value = $state("");
$effect.pre(() => {
if (isOpen) {
value = defaultValue;
}
});
</script>
<ActionModal
bind:isOpen
{onbeforeclose}
{title}
confirmText={submitText}
onConfirmClick={() => onSubmitClick(value)}
>
<TextInput bind:value {placeholder} class="mb-3" />
</ActionModal>

View File

@@ -0,0 +1,4 @@
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
export { default as ForceLoginModal } from "./ForceLoginModal.svelte";
export { default as RenameModal } from "./RenameModal.svelte";
export { default as TextInputModal } from "./TextInputModal.svelte";

View File

@@ -1,35 +0,0 @@
export const refreshToken = async (fetchInternal = fetch) => {
return await fetchInternal("/api/auth/refreshToken", { method: "POST" });
};
const callApi = async (input: RequestInfo, init?: RequestInit, fetchInternal = fetch) => {
let res = await fetchInternal(input, init);
if (res.status === 401) {
res = await refreshToken();
if (!res.ok) {
return res;
}
res = await fetchInternal(input, init);
}
return res;
};
export const callGetApi = async (input: RequestInfo, fetchInternal?: typeof fetch) => {
return await callApi(input, undefined, fetchInternal);
};
export const callPostApi = async <T>(
input: RequestInfo,
payload?: T,
fetchInternal?: typeof fetch,
) => {
return await callApi(
input,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
},
fetchInternal,
);
};

View File

@@ -1,2 +0,0 @@
export * from "./callApi";
export * from "./gotoStateful";

View File

@@ -0,0 +1,28 @@
import { Dexie, type EntityTable } from "dexie";
export interface FileCacheIndex {
fileId: number;
cachedAt: Date;
lastRetrievedAt: Date;
size: number;
}
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
fileCache: EntityTable<FileCacheIndex, "fileId">;
};
cacheIndex.version(1).stores({
fileCache: "fileId",
});
export const getFileCacheIndex = async () => {
return await cacheIndex.fileCache.toArray();
};
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
await cacheIndex.fileCache.put(fileCacheIndex);
};
export const deleteFileCacheIndex = async (fileId: number) => {
await cacheIndex.fileCache.delete(fileId);
};

View File

@@ -0,0 +1,148 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
name: string;
}
interface FileInfo {
id: number;
parentId: DirectoryId;
name: string;
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryId = "root" | number;
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;
});
});
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.put(directoryInfo);
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
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 storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
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.put(categoryInfo);
};
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 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(),
]);
};

View File

@@ -0,0 +1,3 @@
export * from "./cacheIndex";
export * from "./filesystem";
export * from "./keyStore";

View File

@@ -7,22 +7,28 @@ interface ClientKey {
key: CryptoKey; key: CryptoKey;
} }
type MasterKeyState = "active" | "retired";
interface MasterKey { interface MasterKey {
version: number; version: number;
state: MasterKeyState; state: "active" | "retired";
key: CryptoKey; key: CryptoKey;
} }
interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
const keyStore = new Dexie("keyStore") as Dexie & { const keyStore = new Dexie("keyStore") as Dexie & {
clientKey: EntityTable<ClientKey, "usage">; clientKey: EntityTable<ClientKey, "usage">;
masterKey: EntityTable<MasterKey, "version">; masterKey: EntityTable<MasterKey, "version">;
hmacSecret: EntityTable<HmacSecret, "version">;
}; };
keyStore.version(1).stores({ keyStore.version(1).stores({
clientKey: "usage", clientKey: "usage",
masterKey: "version", masterKey: "version",
hmacSecret: "version",
}); });
export const getClientKey = async (usage: ClientKeyUsage) => { export const getClientKey = async (usage: ClientKeyUsage) => {
@@ -62,3 +68,14 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
} }
await keyStore.masterKey.bulkPut(keys); await keyStore.masterKey.bulkPut(keys);
}; };
export const getHmacSecrets = async () => {
return await keyStore.hmacSecret.toArray();
};
export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => secret.extractable)) {
throw new Error("Hmac secrets must be nonextractable");
}
await keyStore.hmacSecret.bulkPut(secrets);
};

View File

@@ -55,6 +55,27 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
}; };
}; };
export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
};
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
return {
hmacSecret: await window.crypto.subtle.unwrapKey(
"raw",
decodeFromBase64(hmacSecretWrapped),
masterKey,
"AES-KW",
{
name: "HMAC",
hash: "SHA-256",
} satisfies HmacImportParams,
false, // Nonextractable
["sign", "verify"],
),
};
};
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12)); const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt( const ciphertext = await window.crypto.subtle.encrypt(

View File

@@ -46,6 +46,56 @@ export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
return encodeToBase64((await exportRSAKey(key)).key); return encodeToBase64((await exportRSAKey(key)).key);
}; };
export const importEncryptionKeyPairFromBase64 = async (
encryptKeyBase64: string,
decryptKeyBase64: string,
) => {
const algorithm: RsaHashedImportParams = {
name: "RSA-OAEP",
hash: "SHA-256",
};
const encryptKey = await window.crypto.subtle.importKey(
"spki",
decodeFromBase64(encryptKeyBase64),
algorithm,
true,
["encrypt", "wrapKey"],
);
const decryptKey = await window.crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(decryptKeyBase64),
algorithm,
true,
["decrypt", "unwrapKey"],
);
return { encryptKey, decryptKey };
};
export const importSigningKeyPairFromBase64 = async (
signKeyBase64: string,
verifyKeyBase64: string,
) => {
const algorithm: RsaHashedImportParams = {
name: "RSA-PSS",
hash: "SHA-256",
};
const signKey = await window.crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(signKeyBase64),
algorithm,
true,
["sign"],
);
const verifyKey = await window.crypto.subtle.importKey(
"spki",
decodeFromBase64(verifyKeyBase64),
algorithm,
true,
["verify"],
);
return { signKey, verifyKey };
};
export const makeRSAKeyNonextractable = async (key: CryptoKey) => { export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { key: exportedKey, format } = await exportRSAKey(key); const { key: exportedKey, format } = await exportRSAKey(key);
return await window.crypto.subtle.importKey( return await window.crypto.subtle.importKey(
@@ -95,7 +145,7 @@ export const unwrapMasterKey = async (
}; };
}; };
export const signMessage = async (message: BufferSource, signKey: CryptoKey) => { export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
return await window.crypto.subtle.sign( return await window.crypto.subtle.sign(
{ {
name: "RSA-PSS", name: "RSA-PSS",
@@ -106,7 +156,7 @@ export const signMessage = async (message: BufferSource, signKey: CryptoKey) =>
); );
}; };
export const verifySignature = async ( export const verifySignatureRSA = async (
message: BufferSource, message: BufferSource,
signature: BufferSource, signature: BufferSource,
verifyKey: CryptoKey, verifyKey: CryptoKey,
@@ -131,7 +181,7 @@ export const signMasterKeyWrapped = async (
version: masterKeyVersion, version: masterKeyVersion,
key: masterKeyWrapped, key: masterKeyWrapped,
}); });
return encodeToBase64(await signMessage(encodeString(serialized), signKey)); return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
}; };
export const verifyMasterKeyWrapped = async ( export const verifyMasterKeyWrapped = async (
@@ -144,7 +194,7 @@ export const verifyMasterKeyWrapped = async (
version: masterKeyVersion, version: masterKeyVersion,
key: masterKeyWrapped, key: masterKeyWrapped,
}); });
return await verifySignature( return await verifySignatureRSA(
encodeString(serialized), encodeString(serialized),
decodeFromBase64(masterKeyWrappedSig), decodeFromBase64(masterKeyWrappedSig),
verifyKey, verifyKey,

View File

@@ -1,3 +1,20 @@
export const digestMessage = async (message: BufferSource) => { export const digestMessage = async (message: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", message); 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);
};

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;
};

View File

@@ -0,0 +1,80 @@
import { LRUCache } from "lru-cache";
import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex,
deleteFileCacheIndex,
type FileCacheIndex,
} from "$lib/indexedDB";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
const fileCacheIndex = new Map<number, FileCacheIndex>();
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) {
fileCacheIndex.set(cache.fileId, cache);
}
};
export const getFileCacheIndex = () => {
return Array.from(fileCacheIndex.values());
};
export const getFileCache = async (fileId: number) => {
const cacheIndex = fileCacheIndex.get(fileId);
if (!cacheIndex) return null;
cacheIndex.lastRetrievedAt = new Date();
storeFileCacheIndex(cacheIndex); // Intended
return await readFile(`/cache/${fileId}`);
};
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
const now = new Date();
await writeFile(`/cache/${fileId}`, fileBuffer);
const cacheIndex: FileCacheIndex = {
fileId,
cachedAt: now,
lastRetrievedAt: now,
size: fileBuffer.byteLength,
};
fileCacheIndex.set(fileId, cacheIndex);
await storeFileCacheIndex(cacheIndex);
};
export const deleteFileCache = async (fileId: number) => {
if (!fileCacheIndex.has(fileId)) return;
fileCacheIndex.delete(fileId);
await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId);
};
export const getFileThumbnailCache = async (fileId: number) => {
const thumbnail = loadedThumbnails.get(fileId);
if (thumbnail) return thumbnail;
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (!thumbnailBuffer) return null;
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
loadedThumbnails.set(fileId, thumbnailUrl);
return thumbnailUrl;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory("/thumbnail/file");
};

View File

@@ -0,0 +1,84 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { decryptData } from "$lib/modules/crypto";
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
const requestFileDownload = limitFunction(
async (status: Writable<FileDownloadStatus>, id: number) => {
status.update((value) => {
value.status = "downloading";
return value;
});
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const fileEncrypted: ArrayBuffer = res.data;
status.update((value) => {
value.status = "decryption-pending";
return value;
});
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
status: Writable<FileDownloadStatus>,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
status.update((value) => {
value.status = "decrypting";
return value;
});
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
status.update((value) => {
value.status = "decrypted";
value.result = fileBuffer;
return value;
});
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
const status = writable<FileDownloadStatus>({
id,
status: "download-pending",
});
fileDownloadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
return await decryptFile(
status,
await requestFileDownload(status, id),
fileEncryptedIv,
dataKey,
);
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -0,0 +1,3 @@
export * from "./cache";
export * from "./download";
export * from "./upload";

View File

@@ -0,0 +1,264 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
encryptData,
encryptString,
digestMessage,
signMessageHmac,
} from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
} from "$lib/server/schemas";
import {
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { trpc } from "$trpc/client";
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, 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");
};
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);
if (!year || !month || !day || !hour || !minute || !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 encryptFile = limitFunction(
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file);
let createdAt;
if (fileType.startsWith("image/")) {
createdAt = extractExifDateTime(fileBuffer);
}
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const fileEncryptedHash = encodeToBase64(await digestMessage(fileEncrypted.ciphertext));
const nameEncrypted = await encryptString(file.name, dataKey);
const createdAtEncrypted =
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
const thumbnail = await generateThumbnail(fileBuffer, fileType);
const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
status.update((value) => {
value.status = "upload-pending";
return value;
});
return {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
};
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => {
value.status = "uploading";
return value;
});
const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const { file }: FileUploadResponse = res.data;
if (thumbnailForm) {
try {
await axios.post(`/api/file/${file}/thumbnail/upload`, thumbnailForm);
} catch (e) {
// TODO
console.error(e);
}
}
status.update((value) => {
value.status = "uploaded";
return value;
});
return { fileId: file };
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
status: "encryption-pending",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined;
}
const {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey);
const form = new FormData();
form.set(
"metadata",
JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash);
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnail.iv,
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -0,0 +1,370 @@
import { TRPCClientError } from "@trpc/client";
import { get, writable, type Writable } from "svelte/store";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({
id,
parentId: directory.parentId,
name: directory.name,
subDirectoryIds,
fileIds,
});
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().directory.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
parentId: metadata!.parent,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
let metadata;
try {
metadata = await trpc().file.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
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, info, masterKey); // Intended
return info;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
) => {
if (get(info)) return;
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
info.set({ id, subCategoryIds });
} else {
if (!category) return;
info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().category.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories } = data;
if (id === "root") {
info.set({ id, subCategoryIds: subCategories });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
let files;
try {
files = await trpc().category.files.query({ id, recurse: true });
} catch {
throw new Error("Failed to fetch category files");
}
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
const fetchCategoryInfo = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchCategoryInfoFromIndexedDB(id, info);
await fetchCategoryInfoFromServer(id, info, masterKey);
};
export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = categoryInfoStore.get(categoryId);
if (!info) {
info = writable(null);
categoryInfoStore.set(categoryId, info);
}
fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info;
};
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

65
src/lib/modules/key.ts Normal file
View File

@@ -0,0 +1,65 @@
import { z } from "zod";
import { storeClientKey } from "$lib/indexedDB";
import type { ClientKeys } from "$lib/stores";
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;
};
export const storeClientKeys = async (clientKeys: ClientKeys) => {
await Promise.all([
storeClientKey(clientKeys.encryptKey, "encrypt"),
storeClientKey(clientKeys.decryptKey, "decrypt"),
storeClientKey(clientKeys.signKey, "sign"),
storeClientKey(clientKeys.verifyKey, "verify"),
]);
};

97
src/lib/modules/opfs.ts Normal file
View File

@@ -0,0 +1,97 @@
let rootHandle: FileSystemDirectoryHandle | null = null;
export const prepareOpfs = async () => {
rootHandle = await navigator.storage.getDirectory();
};
const getFileHandle = async (path: string, create = true) => {
if (!rootHandle) {
throw new Error("OPFS not prepared");
} else 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 = rootHandle;
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 readFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null;
const file = await fileHandle.getFile();
return await file.arrayBuffer();
};
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();
}
};
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 (!rootHandle) {
throw new Error("OPFS not prepared");
} else 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 = rootHandle;
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 });
};

View File

@@ -0,0 +1,127 @@
import { encodeToBase64 } from "$lib/modules/crypto";
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 generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video");
video.onloadedmetadata = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
});
video.currentTime = Math.min(time, video.duration);
};
video.onerror = reject;
video.muted = true;
video.playsInline = true;
video.src = videoUrl;
});
};
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
let url;
try {
if (fileType.startsWith("image/")) {
const fileBlob = new Blob([fileBuffer], { type: fileType });
url = URL.createObjectURL(fileBlob);
try {
return await generateImageThumbnail(url);
} catch {
URL.revokeObjectURL(url);
url = undefined;
if (fileType === "image/heic") {
const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
);
return await generateImageThumbnail(url);
} else {
return null;
}
}
} else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
return await generateVideoThumbnail(url);
}
return null;
} catch {
return null;
} finally {
if (url) {
URL.revokeObjectURL(url);
}
}
};
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
};

View File

@@ -0,0 +1,147 @@
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category {
id: number;
parentId: CategoryId;
userId: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export type NewCategory = Omit<Category, "id">;
export const registerCategory = async (params: NewCategory) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
const { categoryId } = await trx
.insertInto("category")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
encrypted_name: params.encName,
})
.returning("id as categoryId")
.executeTakeFirstOrThrow();
await trx
.insertInto("category_log")
.values({
category_id: categoryId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
});
};
export const getAllCategoriesByParent = async (userId: number, parentId: CategoryId) => {
let query = db.selectFrom("category").selectAll().where("user_id", "=", userId);
query =
parentId === "root"
? query.where("parent_id", "is", null)
: query.where("parent_id", "=", parentId);
const categories = await query.execute();
return categories.map(
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
userId: category.user_id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies Category,
);
};
export const getCategory = async (userId: number, categoryId: number) => {
const category = await db
.selectFrom("category")
.selectAll()
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.limit(1)
.executeTakeFirst();
return category
? ({
id: category.id,
parentId: category.parent_id ?? "root",
userId: category.user_id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
} satisfies Category)
: null;
};
export const setCategoryEncName = async (
userId: number,
categoryId: number,
dekVersion: Date,
encName: Ciphertext,
) => {
await db.transaction().execute(async (trx) => {
const category = await trx
.selectFrom("category")
.select("data_encryption_key_version")
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!category) {
throw new IntegrityError("Category not found");
} else if (category.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await trx
.updateTable("category")
.set({ encrypted_name: encName })
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("category_log")
.values({
category_id: categoryId,
timestamp: new Date(),
action: "rename",
new_name: encName,
})
.execute();
});
};
export const unregisterCategory = async (userId: number, categoryId: number) => {
const res = await db
.deleteFrom("category")
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (res.numDeletedRows === 0n) {
throw new IntegrityError("Category not found");
}
};

View File

@@ -1,102 +1,157 @@
import { and, or, eq, gt, lte, count } from "drizzle-orm"; import pg from "pg";
import db from "./drizzle"; import { IntegrityError } from "./error";
import { client, userClient, userClientChallenge } from "./schema"; import db from "./kysely";
import type { UserClientState } from "./schema";
interface Client {
id: number;
encPubKey: string;
sigPubKey: string;
}
interface UserClient {
userId: number;
clientId: number;
state: UserClientState;
}
interface UserClientWithDetails extends UserClient {
encPubKey: string;
sigPubKey: string;
}
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db.transaction(async (tx) => { return await db
const clients = await tx .transaction()
.select() .setIsolationLevel("serializable")
.from(client) .execute(async (trx) => {
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey))); const client = await trx
if (clients.length > 0) { .selectFrom("client")
throw new Error("Already used public key(s)"); .where((eb) =>
} eb.or([
eb("encryption_public_key", "=", encPubKey),
eb("encryption_public_key", "=", sigPubKey),
eb("signature_public_key", "=", encPubKey),
eb("signature_public_key", "=", sigPubKey),
]),
)
.limit(1)
.executeTakeFirst();
if (client) {
throw new IntegrityError("Public key(s) already registered");
}
const insertRes = await tx const { clientId } = await trx
.insert(client) .insertInto("client")
.values({ encPubKey, sigPubKey }) .values({ encryption_public_key: encPubKey, signature_public_key: sigPubKey })
.returning({ id: client.id }); .returning("id as clientId")
const { id: clientId } = insertRes[0]!; .executeTakeFirstOrThrow();
await tx.insert(userClient).values({ userId, clientId }); await trx
.insertInto("user_client")
return clientId; .values({ user_id: userId, client_id: clientId })
}); .execute();
return { id: clientId };
});
}; };
export const getClient = async (clientId: number) => { export const getClient = async (clientId: number) => {
const clients = await db.select().from(client).where(eq(client.id, clientId)).execute(); const client = await db
return clients[0] ?? null; .selectFrom("client")
.selectAll()
.where("id", "=", clientId)
.limit(1)
.executeTakeFirst();
return client
? ({
id: client.id,
encPubKey: client.encryption_public_key,
sigPubKey: client.signature_public_key,
} satisfies Client)
: null;
}; };
export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => { export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => {
const clients = await db const client = await db
.select() .selectFrom("client")
.from(client) .selectAll()
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey))) .where("encryption_public_key", "=", encPubKey)
.execute(); .where("signature_public_key", "=", sigPubKey)
return clients[0] ?? null; .limit(1)
}; .executeTakeFirst();
return client
export const countClientByPubKey = async (pubKey: string) => { ? ({
const clients = await db id: client.id,
.select({ count: count() }) encPubKey: client.encryption_public_key,
.from(client) sigPubKey: client.signature_public_key,
.where(or(eq(client.encPubKey, pubKey), eq(client.encPubKey, pubKey))); } satisfies Client)
return clients[0]?.count ?? 0; : null;
}; };
export const createUserClient = async (userId: number, clientId: number) => { export const createUserClient = async (userId: number, clientId: number) => {
await db.insert(userClient).values({ userId, clientId }).execute(); try {
}; await db.insertInto("user_client").values({ user_id: userId, client_id: clientId }).execute();
} catch (e) {
export const getAllUserClients = async (userId: number) => { if (e instanceof pg.DatabaseError && e.code === "23505") {
return await db.select().from(userClient).where(eq(userClient.userId, userId)).execute(); throw new IntegrityError("User client already exists");
}
throw e;
}
}; };
export const getUserClient = async (userId: number, clientId: number) => { export const getUserClient = async (userId: number, clientId: number) => {
const userClients = await db const userClient = await db
.select() .selectFrom("user_client")
.from(userClient) .selectAll()
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) .where("user_id", "=", userId)
.execute(); .where("client_id", "=", clientId)
return userClients[0] ?? null; .limit(1)
.executeTakeFirst();
return userClient
? ({
userId: userClient.user_id,
clientId: userClient.client_id,
state: userClient.state,
} satisfies UserClient)
: null;
}; };
export const getUserClientWithDetails = async (userId: number, clientId: number) => { export const getUserClientWithDetails = async (userId: number, clientId: number) => {
const userClients = await db const userClient = await db
.select() .selectFrom("user_client")
.from(userClient) .innerJoin("client", "user_client.client_id", "client.id")
.innerJoin(client, eq(userClient.clientId, client.id)) .selectAll()
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) .where("user_id", "=", userId)
.execute(); .where("client_id", "=", clientId)
return userClients[0] ?? null; .limit(1)
.executeTakeFirst();
return userClient
? ({
userId: userClient.user_id,
clientId: userClient.client_id,
state: userClient.state,
encPubKey: userClient.encryption_public_key,
sigPubKey: userClient.signature_public_key,
} satisfies UserClientWithDetails)
: null;
}; };
export const setUserClientStateToPending = async (userId: number, clientId: number) => { export const setUserClientStateToPending = async (userId: number, clientId: number) => {
await db await db
.update(userClient) .updateTable("user_client")
.set({ state: "pending" }) .set({ state: "pending" })
.where( .where("user_id", "=", userId)
and( .where("client_id", "=", clientId)
eq(userClient.userId, userId), .where("state", "=", "challenging")
eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"),
),
)
.execute(); .execute();
}; };
export const setUserClientStateToActive = async (userId: number, clientId: number) => { export const setUserClientStateToActive = async (userId: number, clientId: number) => {
await db await db
.update(userClient) .updateTable("user_client")
.set({ state: "active" }) .set({ state: "active" })
.where( .where("user_id", "=", userId)
and( .where("client_id", "=", clientId)
eq(userClient.userId, userId), .where("state", "=", "pending")
eq(userClient.clientId, clientId),
eq(userClient.state, "pending"),
),
)
.execute(); .execute();
}; };
@@ -107,45 +162,36 @@ export const registerUserClientChallenge = async (
allowedIp: string, allowedIp: string,
expiresAt: Date, expiresAt: Date,
) => { ) => {
await db const { id } = await db
.insert(userClientChallenge) .insertInto("user_client_challenge")
.values({ .values({
userId, user_id: userId,
clientId, client_id: clientId,
answer, answer,
allowedIp, allowed_ip: allowedIp,
expiresAt, expires_at: expiresAt,
}) })
.execute(); .returning("id")
.executeTakeFirstOrThrow();
return { id };
}; };
export const getUserClientChallenge = async (answer: string, ip: string) => { export const consumeUserClientChallenge = async (
const challenges = await db challengeId: number,
.select() userId: number,
.from(userClientChallenge) ip: string,
.where( ) => {
and( const challenge = await db
eq(userClientChallenge.answer, answer), .deleteFrom("user_client_challenge")
eq(userClientChallenge.allowedIp, ip), .where("id", "=", challengeId)
gt(userClientChallenge.expiresAt, new Date()), .where("user_id", "=", userId)
eq(userClientChallenge.isUsed, false), .where("allowed_ip", "=", ip)
), .where("expires_at", ">", new Date())
) .returning(["client_id", "answer"])
.execute(); .executeTakeFirst();
return challenges[0] ?? null; return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null;
};
export const markUserClientChallengeAsUsed = async (id: number) => {
await db
.update(userClientChallenge)
.set({ isUsed: true })
.where(eq(userClientChallenge.id, id))
.execute();
}; };
export const cleanupExpiredUserClientChallenges = async () => { export const cleanupExpiredUserClientChallenges = async () => {
await db await db.deleteFrom("user_client_challenge").where("expires_at", "<=", new Date()).execute();
.delete(userClientChallenge)
.where(lte(userClientChallenge.expiresAt, new Date()))
.execute();
}; };

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;

View File

@@ -0,0 +1,30 @@
type IntegrityErrorMessages =
// Category
| "Category not found"
// Challenge
| "Challenge already registered"
// Client
| "Public key(s) already registered"
| "User client already exists"
// File
| "Directory not found"
| "File not found"
| "File not found in category"
| "File already added to category"
| "Invalid DEK version"
// HSK
| "HSK already registered"
| "Inactive HSK version"
// MEK
| "MEK already registered"
| "Inactive MEK version"
// Session
| "Session not found"
| "Session already exists";
export class IntegrityError extends Error {
constructor(public message: IntegrityErrorMessages) {
super(message);
this.name = "IntegrityError";
}
}

View File

@@ -1,194 +1,506 @@
import { and, eq, isNull } from "drizzle-orm"; import { sql, type NotNull } from "kysely";
import db from "./drizzle"; import pg from "pg";
import { directory, file, mek } from "./schema"; import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
type DirectoryId = "root" | number; export type DirectoryId = "root" | number;
export interface NewDirectoryParams { interface Directory {
userId: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number;
mekVersion: number; mekVersion: number;
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
encName: string; encName: Ciphertext;
encNameIv: string;
} }
export interface NewFileParams { export type NewDirectory = Omit<Directory, "id">;
path: string;
interface File {
id: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number; userId: number;
path: string;
mekVersion: number; mekVersion: number;
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
hskVersion: number | null;
contentHmac: string | null;
contentType: string; contentType: string;
encContentIv: string; encContentIv: string;
encName: string; encContentHash: string;
encNameIv: string; encName: Ciphertext;
encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext;
} }
export const registerNewDirectory = async (params: NewDirectoryParams) => { export type NewFile = Omit<File, "id">;
return await db.transaction(async (tx) => {
const meks = await tx export const registerDirectory = async (params: NewDirectory) => {
.select() await db.transaction().execute(async (trx) => {
.from(mek) const mek = await trx
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))); .selectFrom("master_encryption_key")
if (meks[0]?.version !== params.mekVersion) { .select("version")
throw new Error("Invalid MEK version"); .where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
} }
const now = new Date(); const { directoryId } = await trx
await tx.insert(directory).values({ .insertInto("directory")
createdAt: now, .values({
parentId: params.parentId === "root" ? null : params.parentId, parent_id: params.parentId !== "root" ? params.parentId : null,
userId: params.userId, user_id: params.userId,
mekVersion: params.mekVersion, master_encryption_key_version: params.mekVersion,
encDek: params.encDek, encrypted_data_encryption_key: params.encDek,
dekVersion: params.dekVersion, data_encryption_key_version: params.dekVersion,
encName: { ciphertext: params.encName, iv: params.encNameIv }, encrypted_name: params.encName,
}); })
.returning("id as directoryId")
.executeTakeFirstOrThrow();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
}); });
}; };
export const getAllDirectoriesByParent = async (userId: number, directoryId: DirectoryId) => { export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
return await db let query = db.selectFrom("directory").selectAll().where("user_id", "=", userId);
.select() query =
.from(directory) parentId === "root"
.where( ? query.where("parent_id", "is", null)
and( : query.where("parent_id", "=", parentId);
eq(directory.userId, userId), const directories = await query.execute();
directoryId === "root" ? isNull(directory.parentId) : eq(directory.parentId, directoryId), return directories.map(
), (directory) =>
) ({
.execute(); id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
}) satisfies Directory,
);
}; };
export const getDirectory = async (userId: number, directoryId: number) => { export const getDirectory = async (userId: number, directoryId: number) => {
const res = await db const directory = await db
.select() .selectFrom("directory")
.from(directory) .selectAll()
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId))) .where("id", "=", directoryId)
.execute(); .where("user_id", "=", userId)
return res[0] ?? null; .limit(1)
.executeTakeFirst();
return directory
? ({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
} satisfies Directory)
: null;
}; };
export const setDirectoryEncName = async ( export const setDirectoryEncName = async (
userId: number, userId: number,
directoryId: number, directoryId: number,
dekVersion: Date, dekVersion: Date,
encName: string, encName: Ciphertext,
encNameIv: string,
) => { ) => {
const res = await db await db.transaction().execute(async (trx) => {
.update(directory) const directory = await trx
.set({ encName: { ciphertext: encName, iv: encNameIv } }) .selectFrom("directory")
.where( .select("data_encryption_key_version")
and( .where("id", "=", directoryId)
eq(directory.userId, userId), .where("user_id", "=", userId)
eq(directory.id, directoryId), .limit(1)
eq(directory.dekVersion, dekVersion), .forUpdate()
), .executeTakeFirst();
) if (!directory) {
.execute(); throw new IntegrityError("Directory not found");
return res.changes > 0; } else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
}; throw new IntegrityError("Invalid DEK version");
}
export const unregisterDirectory = async (userId: number, directoryId: number) => { await trx
return await db.transaction(async (tx) => { .updateTable("directory")
const getFilePaths = async (parentId: number) => { .set({ encrypted_name: encName })
const files = await tx .where("id", "=", directoryId)
.select({ path: file.path }) .where("user_id", "=", userId)
.from(file) .execute();
.where(and(eq(file.userId, userId), eq(file.parentId, parentId))); await trx
return files.map(({ path }) => path); .insertInto("directory_log")
}; .values({
const unregisterSubDirectoriesRecursively = async (directoryId: number): Promise<string[]> => { directory_id: directoryId,
const subDirectories = await tx timestamp: new Date(),
.select({ id: directory.id }) action: "rename",
.from(directory) new_name: encName,
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId))); })
const subDirectoryFilePaths = await Promise.all( .execute();
subDirectories.map(async ({ id }) => await unregisterSubDirectoriesRecursively(id)),
);
const filePaths = await getFilePaths(directoryId);
await tx.delete(file).where(eq(file.parentId, directoryId));
await tx.delete(directory).where(eq(directory.id, directoryId));
return filePaths.concat(...subDirectoryFilePaths);
};
return await unregisterSubDirectoriesRecursively(directoryId);
}); });
}; };
export const registerNewFile = async (params: NewFileParams) => { export const unregisterDirectory = async (userId: number, directoryId: number) => {
await db.transaction(async (tx) => { return await db
const meks = await tx .transaction()
.select() .setIsolationLevel("repeatable read") // TODO: Sufficient?
.from(mek) .execute(async (trx) => {
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))); const unregisterFiles = async (parentId: number) => {
if (meks[0]?.version !== params.mekVersion) { const files = await trx
throw new Error("Invalid MEK version"); .selectFrom("file")
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
.where("file.parent_id", "=", parentId)
.where("file.user_id", "=", userId)
.forUpdate("file")
.execute();
await trx
.deleteFrom("file")
.where("parent_id", "=", parentId)
.where("user_id", "=", userId)
.execute();
return files;
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory")
.select("id")
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
const deleteRes = await trx
.deleteFrom("directory")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found");
}
return files.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
});
};
export const registerFile = async (params: NewFile) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments");
}
return await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
} }
const now = new Date(); if (params.hskVersion) {
await tx.insert(file).values({ const hsk = await trx
path: params.path, .selectFrom("hmac_secret_key")
parentId: params.parentId === "root" ? null : params.parentId, .select("version")
createdAt: now, .where("user_id", "=", params.userId)
userId: params.userId, .where("state", "=", "active")
mekVersion: params.mekVersion, .limit(1)
contentType: params.contentType, .forUpdate()
encDek: params.encDek, .executeTakeFirst();
dekVersion: params.dekVersion, if (hsk?.version !== params.hskVersion) {
encContentIv: params.encContentIv, throw new IntegrityError("Inactive HSK version");
encName: { ciphertext: params.encName, iv: params.encNameIv }, }
}); }
const { fileId } = await trx
.insertInto("file")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
path: params.path,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
hmac_secret_key_version: params.hskVersion,
content_hmac: params.contentHmac,
content_type: params.contentType,
encrypted_content_iv: params.encContentIv,
encrypted_content_hash: params.encContentHash,
encrypted_name: params.encName,
encrypted_created_at: params.encCreatedAt,
encrypted_last_modified_at: params.encLastModifiedAt,
})
.returning("id as fileId")
.executeTakeFirstOrThrow();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
return { id: fileId };
}); });
}; };
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => { export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
return await db let query = db.selectFrom("file").selectAll().where("user_id", "=", userId);
.select() query =
.from(file) parentId === "root"
.where( ? query.where("parent_id", "is", null)
and( : query.where("parent_id", "=", parentId);
eq(file.userId, userId), const files = await query.execute();
parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId), return files.map(
), (file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
}) satisfies File,
);
};
export const getAllFilesByCategory = async (
userId: number,
categoryId: number,
recurse: boolean,
) => {
const files = await db
.withRecursive("cte", (db) =>
db
.selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id")
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.where("id", "=", categoryId)
.$if(recurse, (qb) =>
qb.unionAll((db) =>
db
.selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id")
.innerJoin("cte", "category.parent_id", "cte.id")
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
),
),
) )
.selectFrom("cte")
.select(["file_id", "depth"])
.distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id")
.orderBy("depth")
.execute(); .execute();
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
};
export const getAllFileIds = async (userId: number) => {
const files = await db.selectFrom("file").select("id").where("user_id", "=", userId).execute();
return files.map(({ id }) => id);
};
export const getAllFileIdsByContentHmac = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
const files = await db
.selectFrom("file")
.select("id")
.where("user_id", "=", userId)
.where("hmac_secret_key_version", "=", hskVersion)
.where("content_hmac", "=", contentHmac)
.execute();
return files.map(({ id }) => id);
}; };
export const getFile = async (userId: number, fileId: number) => { export const getFile = async (userId: number, fileId: number) => {
const res = await db const file = await db
.select() .selectFrom("file")
.from(file) .selectAll()
.where(and(eq(file.userId, userId), eq(file.id, fileId))) .where("id", "=", fileId)
.execute(); .where("user_id", "=", userId)
return res[0] ?? null; .limit(1)
.executeTakeFirst();
return file
? ({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
} satisfies File)
: null;
}; };
export const setFileEncName = async ( export const setFileEncName = async (
userId: number, userId: number,
fileId: number, fileId: number,
dekVersion: Date, dekVersion: Date,
encName: string, encName: Ciphertext,
encNameIv: string,
) => { ) => {
const res = await db await db.transaction().execute(async (trx) => {
.update(file) const file = await trx
.set({ encName: { ciphertext: encName, iv: encNameIv } }) .selectFrom("file")
.where(and(eq(file.userId, userId), eq(file.id, fileId), eq(file.dekVersion, dekVersion))) .select("data_encryption_key_version")
.execute(); .where("id", "=", fileId)
return res.changes > 0; .where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await trx
.updateTable("file")
.set({ encrypted_name: encName })
.where("id", "=", fileId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "rename",
new_name: encName,
})
.execute();
});
}; };
export const unregisterFile = async (userId: number, fileId: number) => { export const unregisterFile = async (userId: number, fileId: number) => {
const res = await db return await db.transaction().execute(async (trx) => {
.delete(file) const file = await trx
.where(and(eq(file.userId, userId), eq(file.id, fileId))) .selectFrom("file")
.returning({ path: file.path }) .leftJoin("thumbnail", "file.id", "thumbnail.file_id")
.execute(); .select(["file.path", "thumbnail.path as thumbnailPath"])
return res[0]?.path ?? null; .where("file.id", "=", fileId)
.where("file.user_id", "=", userId)
.forUpdate("file")
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
}
await trx.deleteFrom("file").where("id", "=", fileId).execute();
return file;
});
};
export const addFileToCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("file_category")
.values({ file_id: fileId, category_id: categoryId })
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "add-to-category",
category_id: categoryId,
})
.execute();
} catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") {
throw new IntegrityError("File already added to category");
}
throw e;
}
});
};
export const getAllFileCategories = async (fileId: number) => {
const categories = await db
.selectFrom("file_category")
.select("category_id")
.where("file_id", "=", fileId)
.execute();
return categories.map(({ category_id }) => ({ id: category_id }));
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => {
const res = await trx
.deleteFrom("file_category")
.where("file_id", "=", fileId)
.where("category_id", "=", categoryId)
.executeTakeFirst();
if (res.numDeletedRows === 0n) {
throw new IntegrityError("File not found in category");
}
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "remove-from-category",
category_id: categoryId,
})
.execute();
});
}; };

68
src/lib/server/db/hsk.ts Normal file
View File

@@ -0,0 +1,68 @@
import pg from "pg";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { HskState } from "./schema";
interface Hsk {
userId: number;
version: number;
state: HskState;
mekVersion: number;
encHsk: string;
}
export const registerInitialHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
await db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("hmac_secret_key")
.values({
user_id: userId,
version: 1,
state: "active",
master_encryption_key_version: mekVersion,
encrypted_key: encHsk,
})
.execute();
await trx
.insertInto("hmac_secret_key_log")
.values({
user_id: userId,
hmac_secret_key_version: 1,
timestamp: new Date(),
action: "create",
action_by: createdBy,
})
.execute();
} catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") {
throw new IntegrityError("HSK already registered");
}
throw e;
}
});
};
export const getAllValidHsks = async (userId: number) => {
const hsks = await db
.selectFrom("hmac_secret_key")
.selectAll()
.where("user_id", "=", userId)
.where("state", "=", "active")
.execute();
return hsks.map(
({ user_id, version, state, master_encryption_key_version, encrypted_key }) =>
({
userId: user_id,
version,
state: state as "active",
mekVersion: master_encryption_key_version,
encHsk: encrypted_key,
}) satisfies Hsk,
);
};

View File

@@ -0,0 +1,10 @@
export * as CategoryRepo from "./category";
export * as ClientRepo from "./client";
export * as FileRepo from "./file";
export * as HskRepo from "./hsk";
export * as MediaRepo from "./media";
export * as MekRepo from "./mek";
export * as SessionRepo from "./session";
export * as UserRepo from "./user";
export * from "./error";

View File

@@ -0,0 +1,47 @@
import { Kysely, PostgresDialect, Migrator } from "kysely";
import pg from "pg";
import env from "$lib/server/loadenv";
import migrations from "./migrations";
import type { Database } from "./schema";
const dialect = new PostgresDialect({
pool: new pg.Pool({
host: env.database.host,
port: env.database.port,
user: env.database.user,
password: env.database.password,
database: env.database.name,
}),
});
const db = new Kysely<Database>({ dialect });
export const migrateDB = async () => {
if (env.nodeEnv !== "production") return;
const migrator = new Migrator({
db,
provider: {
async getMigrations() {
return migrations;
},
},
});
const { error, results } = await migrator.migrateToLatest();
if (error) {
const migration = results?.find(({ status }) => status === "Error");
if (migration) {
console.error(`Migration "${migration.migrationName}" failed.`);
}
console.error(error);
process.exit(1);
}
if (results?.length === 0) {
console.log("Database is up-to-date.");
} else {
console.log("Database migration completed.");
}
};
export default db;

110
src/lib/server/db/media.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { NotNull } from "kysely";
import { IntegrityError } from "./error";
import db from "./kysely";
interface Thumbnail {
id: number;
path: string;
updatedAt: Date;
encContentIv: string;
}
interface FileThumbnail extends Thumbnail {
fileId: number;
}
export const updateFileThumbnail = async (
userId: number,
fileId: number,
dekVersion: Date,
path: string,
encContentIv: string,
) => {
return await db.transaction().execute(async (trx) => {
const file = await trx
.selectFrom("file")
.select("data_encryption_key_version")
.where("id", "=", fileId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
const thumbnail = await trx
.selectFrom("thumbnail")
.select("path as oldPath")
.where("file_id", "=", fileId)
.limit(1)
.forUpdate()
.executeTakeFirst();
const now = new Date();
await trx
.insertInto("thumbnail")
.values({
file_id: fileId,
path,
updated_at: now,
encrypted_content_iv: encContentIv,
})
.onConflict((oc) =>
oc.column("file_id").doUpdateSet({
path,
updated_at: now,
encrypted_content_iv: encContentIv,
}),
)
.execute();
return thumbnail?.oldPath ?? null;
});
};
export const getFileThumbnail = async (userId: number, fileId: number) => {
const thumbnail = await db
.selectFrom("thumbnail")
.innerJoin("file", "thumbnail.file_id", "file.id")
.selectAll("thumbnail")
.where("file.id", "=", fileId)
.where("file.user_id", "=", userId)
.$narrowType<{ file_id: NotNull }>()
.limit(1)
.executeTakeFirst();
return thumbnail
? ({
id: thumbnail.id,
fileId: thumbnail.file_id,
path: thumbnail.path,
encContentIv: thumbnail.encrypted_content_iv,
updatedAt: thumbnail.updated_at,
} satisfies FileThumbnail)
: null;
};
export const getMissingFileThumbnails = async (userId: number, limit: number = 100) => {
const files = await db
.selectFrom("file")
.select("id")
.where("user_id", "=", userId)
.where((eb) =>
eb.or([eb("content_type", "like", "image/%"), eb("content_type", "like", "video/%")]),
)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("thumbnail")
.select("thumbnail.id")
.whereRef("thumbnail.file_id", "=", "file.id")
.limit(1),
),
),
)
.limit(limit)
.execute();
return files.map(({ id }) => id);
};

Some files were not shown because too many files have changed in this diff Show More