Code
Search code…
- 폴더·여러 항목 다운로드는 고르지 않고 끊겨도 이어받는 방식으로 바로 받음(Chrome·Edge 폴더로 저장, Firefox 이어받는 ZIP, Safari·iOS만 일반 ZIP)
- 공유 페이지의 'ZIP 다운로드' 버튼과 ZIP 안내 창을 없애고 '전체 다운로드' 하나만 둠, 목록의 하위 폴더 다운로드도 같은 방식
- 앱의 'ZIP / 폴더로 저장' 선택 창 제거
- 링크 설정에서 만료 기간을 바꿀 수 있음(그대로·1일·7일·30일·90일·무제한·날짜 지정)
- 삭제 확인 문구가 실제 동작을 따름: 휴지통(#recycle)이 있으면 휴지통으로 옮긴다고 안내, /list가 recycle, /delete가 recycled 개수를 알려 줌
- 삭제처럼 위험한 확인 창은 '취소'에 포커스, 이름이 겹칠 때 기본 선택은 '둘 다 유지'
- 휴대폰 선택 막대는 옆으로 밀지 않고 3열로 줄바꿈, 선택 중에는 앱 설치 안내를 숨김
- 휴대폰 공유 페이지 상단에 사이트 이름 표시
- 다크 모드 파란·빨간 버튼 대비를 4.5:1 이상으로(--accent-fill, --danger-fill), 검색창 안내 글씨와 배지 글씨 대비 개선
- 터치 화면에서 체크박스 20px, 작은 버튼 36px, 링크·정렬 버튼의 누르는 영역 확대
- 링크와 검색 지우기 버튼에 키보드 포커스 표시, 🔒 이모지를 선 아이콘으로 교체
- 확인 창 문장은 단어 중간에서 끊지 않음, 링크 설정 창을 공유 링크 만들기 창과 같은 너비로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Synology용 웹 파일 서버
Synology NAS에서 Docker로 돌리는 파일 서버입니다. 브라우저용 파일 관리 화면과 WebDAV를 함께 제공하고, 로그인은 DSM 계정을 그대로 씁니다. Cloudflare 같은 프록시 뒤에서 도메인과 443 포트 하나로 운영하는 것을 전제로 만들었습니다.
- 의존성 없는 Node.js 서버 (npm 패키지 0개, 영상 변환에만 ffmpeg 사용)
- 브라우저: 업로드·다운로드·미리보기·검색·공유 링크
- Windows 탐색기: WebDAV로 네트워크 드라이브 연결
목차
기능
파일 관리
| 기능 | 설명 |
|---|---|
| 업로드 | 16MB 조각으로 나눠 보내서 프록시의 요청 크기 제한(예: Cloudflare 100MB)과 상관없이 큰 파일도 올라갑니다. 끊기면 이어서 올립니다. 폴더째, 끌어다 놓기도 됩니다. |
| 선택 | 클릭으로 여러 개, Shift+클릭으로 범위, Ctrl+A로 전체 선택. 동작 버튼은 화면 아래에 떠 있습니다. |
| 이동·복사 | 대상 폴더를 고르거나, 항목을 폴더 위로 끌어다 놓습니다(Ctrl을 누르면 복사). 서버의 백그라운드 작업이라 공유 폴더 간 대용량 이동도 시간 제한에 걸리지 않습니다. |
| 이동·복사 취소 | 진행 중인 작업을 멈춥니다. 이미 옮기거나 복사한 항목은 그대로 둡니다. 공유 폴더 간 이동은 파일 하나씩 복사한 뒤 지우므로 중간에 멈춰도 사라지는 파일이 없습니다. |
| 검색 | 현재 폴더와 하위 폴더에서 이름으로 찾습니다. 결과에서도 다운로드·이동·삭제·공유가 됩니다. |
| 격자 보기 | 사진·동영상을 썸네일로 봅니다. Synology Photos가 @eaDir에 만들어 둔 썸네일을 그대로 쓰고, 썸네일이 없는 작은 사진(3MB 이하)은 원본을 보여 줍니다. |
| 텍스트 편집 | 미리보기에서 바로 고쳐 저장합니다(Ctrl+S). 줄바꿈 형식과 BOM을 유지하고, 다른 곳에서 바뀌었으면 덮어쓸지 묻습니다. |
키보드: Esc 선택 해제 · F2 이름 바꾸기 · Delete 삭제
공유 폴더 자체(최상위 폴더)는 마운트 지점이라 삭제·이동·이름 바꾸기·덮어쓰기를 할 수 없고, 최상위에 새 파일이나 폴더를 만들 수도 없습니다. 공유하기, 복사, 다운로드는 됩니다.
다운로드
- 파일 하나는 그대로 받습니다.
- 폴더나 여러 항목은 따로 고르지 않고, Cloudflare가 연결을 끊어도 알아서 이어받는 방식으로 받습니다.
브라우저에 따라 방식이 다릅니다.
- 폴더로 저장 (Chrome·Edge): 고른 위치에 폴더 구조 그대로 저장합니다. 연결이 끊기거나 30초 멈추면 알아서 이어받고, 같은 이름·크기의 파일은 건너뜁니다.
- 이어받는 ZIP (Firefox 등): 페이지가 ZIP을 직접 받아 서비스 워커(
stream-sw.js)로 브라우저 다운로드에 넘깁니다. 끊기거나 멈추면 페이지가 끊긴 지점부터 다시 이어서, 브라우저에는 한 번도 끊기지 않은 다운로드로 보입니다. 다 받을 때까지 페이지를 열어 둬야 합니다. - ZIP (Safari·iOS): 위의 두 방식을 못 쓰는 브라우저만 브라우저가 직접 받는 ZIP(무압축, ZIP64)을 받습니다. 같은 내용이면 같은 ZIP이 나오므로 다운로드 목록에서 "다시 시작"하면 끊긴 지점부터 이어받습니다.
- 공유 페이지에는 전체 다운로드 버튼 하나만 있고, 목록의 폴더 다운로드 버튼도 같은 방식으로 받습니다.
미리보기
사진, 영상, 음악, PDF, 텍스트를 브라우저 안에서 엽니다. 방향키나 스와이프로 같은 폴더의 다음 파일로 넘어갑니다.
영상 변환: 브라우저가 못 여는 영상은 서버의 ffmpeg가 MP4로 바꿔 스트리밍합니다.
| 원본 | 처리 |
|---|---|
| 일반 H.264(8비트 4:2:0), HEVC Main / Main 10 | 그대로 재생하거나, 컨테이너만 바꿈(.ts, .mkv 등) |
| Xvid(MPEG-4 Part 2), MPEG-2, 10비트 H.264, HEVC 4:4:4 등 | 720p H.264로 변환 |
음성 트랙이 여러 개면 영상 아래에서 고릅니다. 기본은 파일이 기본으로 지정한 트랙입니다.
자막
- 같은 폴더에 영상과 이름이 같은 자막 파일이 있으면 자동으로 표시합니다.
.srt.vtt.smi.ass.ssa를 읽고,영상.ko.srt같은 이름도 됩니다. - .mkv 안의 텍스트 자막(ASS·SRT)도 함께 나옵니다. PGS 같은 그림 자막은 표시하지 못합니다.
- "자막" 버튼에서 자막 선택, 크기, 배경, 싱크(±0.5초)를 조절합니다.
공유 링크
로그인 없이 파일이나 폴더를 받을 수 있는 링크(/_app/s/<토큰>)를 만듭니다.
- 기간: 1일 · 7일 · 30일 · 90일 · 무제한 · 날짜 지정
- 비밀번호: 만들 때 걸 수 있고, 나중에 바꾸거나 없앨 수 있습니다.
- 다운로드 횟수 제한: 정한 횟수만큼 받으면 새 다운로드는 막힙니다. 이미 시작한 다운로드는 하루 동안 이어받을 수 있고, 그 뒤에는 링크가 열리지 않습니다. 미리보기와 이어받기는 횟수에 넣지 않습니다.
- 잠시 중지: 링크를 지우지 않고 잠시 열리지 않게 합니다.
- 받는 사람 올리기: 폴더 링크에서 켜면 받는 사람도 그 폴더에 파일을 올릴 수 있습니다. 같은 이름이 있으면
이름 (1)로 저장하고, 있던 파일을 덮어쓰거나 지우지는 못합니다. - 받는 쪽: 파일 다운로드, 폴더 탐색, ZIP 또는 폴더째 다운로드, 미리보기
- 기록: 링크마다 조회 수와 다운로드 수, 각 기록(시각, IP, 브라우저, 받은 항목, 올린 파일)을 봅니다. 같은 IP가 10분 안에 다시 연 것은 조회 한 번으로 세고, 이어받기 요청은 다운로드로 세지 않습니다.
- 링크 미리보기: 메신저에 붙이면 항목 이름이 나옵니다. 비밀번호를 건 링크는 이름을 숨깁니다.
링크는 경로를 가리킵니다. 이 서버(웹 화면이나 WebDAV)에서 옮기거나 이름을 바꾸면 링크가 따라가지만,
SMB나 File Station처럼 다른 곳에서 옮기면 끊어집니다. 만든 사람이 ALLOWED_USERS에서 빠지면 그 사람의
링크도 열리지 않습니다.
로그인
- DSM 계정: DSM Web API로 아이디·비밀번호·OTP를 확인합니다.
- 패스키: DSM Secure SignIn에 등록한 패스키를 그대로 씁니다. 서버는 DSM이 저장한 공개키를
읽기 전용으로 마운트해 서명을 검증합니다. PIN이나 생체 인증으로 본인 확인을 해야 하고, 서명 횟수를
세는 보안 키는 그 수가 늘어나는지도 확인합니다(복제된 키 감지). 패스키 로그인은 DSM에서 계정을
비활성화했는지 알 수 없으므로, 막아야 할 계정은
ALLOWED_USERS에서 빼세요. - 로컬 계정: DSM 없이 쓸 때를 위한 계정(
AUTH_MODE=local또는both)
브라우저 로그인은 서버를 업데이트하거나 다시 시작해도 유지됩니다(state/app.db, 토큰은 해시로만 저장).
구조
브라우저 / 탐색기 ── (Cloudflare 등) ── DSM 리버스 프록시 :443
└─ http://127.0.0.1:<포트> ── 컨테이너
├─ / WebDAV, 웹 화면
└─ /_app/ API, 정적 파일, 공유 링크
공유 폴더는 컨테이너의 /data/<공유 이름>에 마운트하고, URL도 같은 경로를 씁니다
(https://<도메인>/<공유 이름>/...). 설정과 기록은 /state에 저장합니다.
server/ HTTP 서버, WebDAV, API, 인증, ZIP, 영상 변환
public/ 웹 화면 (빌드 과정 없는 ES 모듈, public/app/에 기능별 모듈)
test/ 가짜 DSM과 E2E 테스트, test/unit/에 단위 테스트
/state에 생기는 것:
| 파일 | 내용 |
|---|---|
app.db |
브라우저 로그인, ZIP 이어받기용 CRC 캐시, 패스키 서명 횟수 (SQLite) |
shares.json, share-secret, share-logs/ |
공유 링크와 링크별 기록 |
activity.log |
누가 무엇을 했는지: 로그인, 삭제, 이동, 업로드, 저장, 공유 링크 변경 (JSON 한 줄씩, 20MB마다 .1로 넘김) |
uploads/ |
끝나지 않은 브라우저 업로드 |
users.json |
로컬 계정 |
빠른 시작
1. 설정 파일 만들기
cp .env.example .env
mkdir -p state
.env에서 최소한 SITE_TITLE, ALLOWED_USERS, 패스키를 쓴다면 PASSKEY_RP_ID / PASSKEY_ORIGINS를 채웁니다.
2. 공유 폴더 마운트
compose.yaml의 volumes에 보여 줄 공유 폴더를 넣습니다.
volumes:
- ./state:/state
- /usr/syno/etc/packages/SecureSignIn/preference:/dsm-fido:ro # 패스키를 쓸 때
- /volume1/photo:/data/photo
- /volume1/video:/data/video
새로 만드는 파일의 소유자는 PUID / PGID로 정합니다. DSM에서 id <사용자>로 확인할 수 있습니다.
3. 실행
compose.yaml의 image를 쓸 이미지로 맞춘 뒤 받아서 실행합니다. 특정 버전에 고정하려면 .env에
IMAGE_TAG=1.0.0처럼 적습니다.
sudo docker compose pull
sudo docker compose up -d
sudo docker logs -f <컨테이너 이름>
업데이트도 같은 두 줄(pull, up -d)입니다. 이미지를 직접 만들려면 아래 이미지 빌드를 보세요.
포트는 127.0.0.1에만 열어서 리버스 프록시를 거쳐야만 들어오게 합니다.
설정
.env에 적습니다.
기본
| 변수 | 기본값 | 설명 |
|---|---|---|
SITE_TITLE |
File Server | 화면에 보이는 제목 |
AUTH_MODE |
dsm | dsm, local, both(로컬 먼저, 다음 DSM) |
DSM_URL |
http://host.docker.internal:5000 | 컨테이너에서 본 DSM 주소 |
PUID / PGID |
(없음) | 새 파일·폴더의 소유자 |
접근 제한
| 변수 | 기본값 | 설명 |
|---|---|---|
ALLOWED_USERS |
(비움) | 로그인할 수 있는 사용자(쉼표로 구분). 비우면 DSM이 허용하는 모든 사용자 |
USER_ROOTS |
(비움) | 사용자별로 보이는 공유 폴더. 예: alice=photo,video;*=photo (*는 나머지 사용자) |
WEBDAV_SKIP_2FA |
true | WebDAV는 OTP를 보낼 수 없으므로, 2단계 인증 계정도 비밀번호만으로 WebDAV 허용. 웹 로그인은 항상 OTP를 요구 |
HIDDEN_NAMES |
@eaDir,#recycle,#snapshot,@tmp |
목록에서 숨기고 접근도 막을 이름 |
로그인
| 변수 | 기본값 | 설명 |
|---|---|---|
PASSKEY_RP_ID |
(비움) | DSM에서 패스키를 등록한 도메인. 이 사이트는 그 도메인이거나 하위 도메인이어야 함 |
PASSKEY_ORIGINS |
(비움) | 패스키 로그인을 받을 주소. 비우면 RP ID의 모든 https 하위 도메인. 이 사이트 주소 하나만 적는 것을 권장 |
SESSION_TTL_HOURS |
12 | 브라우저 로그인 유지 시간 |
BASIC_AUTH_CACHE_SECONDS |
600 | WebDAV 인증 결과 캐시 시간. DSM에서 비밀번호를 바꿔도 이 시간 동안은 옛 비밀번호가 통함 |
MAX_LOGIN_FAILURES |
10 | 이만큼 실패하면 그 IP를 차단(IPv6는 /64 단위) |
MAX_ACCOUNT_FAILURES |
30 | 한 계정에 서로 다른 틀린 비밀번호(또는 OTP)가 이만큼 들어오면 IP와 상관없이 그 계정을 차단. 같은 옛 비밀번호를 계속 보내는 탐색기는 한 번으로 셈. 차단 중에도 패스키 로그인은 됨 |
LOGIN_BLOCK_MINUTES |
15 | 차단 시간 |
PASSKEY_REQUIRE_UV |
true | 패스키 로그인에 PIN·생체 인증을 요구 |
TRUST_PROXY |
true | 프록시 헤더에서 방문자 IP를 읽음(아래 리버스 프록시 참고) |
CLOUDFLARE_IPS |
(내장 목록) | Cloudflare IP 대역(쉼표로 구분한 CIDR). 비우면 2026-10 기준 내장 목록 |
COOKIE_SECURE |
auto | 세션 쿠키의 Secure 속성. auto는 HTTPS 요청일 때만 |
업로드·삭제
| 변수 | 기본값 | 설명 |
|---|---|---|
CHUNK_SIZE_MB |
16 | 업로드 조각 크기. 프록시의 요청 크기 제한보다 충분히 작게 |
UPLOAD_EXPIRE_HOURS |
48 | 끝나지 않은 업로드의 임시 파일을 지우기까지의 시간 |
USE_RECYCLE |
true | 공유 폴더에 #recycle이 있으면 삭제·덮어쓰기 대상을 그쪽으로 옮김(웹 업로드, WebDAV 저장·복사·이동, 이동·복사 작업의 덮어쓰기 포함) |
로컬 계정
sudo docker compose exec -it ftp node server/adduser.js <사용자> # 추가 / 비밀번호 변경
sudo docker compose exec ftp node server/adduser.js --list
sudo docker compose exec ftp node server/adduser.js --delete <사용자>
리버스 프록시
DSM 제어판 → 로그인 포털 → 고급 → 리버스 프록시에서 규칙을 하나 만듭니다.
| 항목 | 값 |
|---|---|
| 소스 | HTTPS · <도메인> · 443 |
| 대상 | HTTP · localhost · compose.yaml에서 연 포트 |
- 고급 설정의 시간 제한(연결·보내기·읽기)은 300초 정도로 늘려 두는 것이 좋습니다.
- 방문자 IP: DSM이 넣는
X-Real-IP가 Cloudflare IP일 때만CF-Connecting-IP와X-Forwarded-For(Cloudflare가 붙인 항목)를 믿습니다. 프록시를 거치지 않고 들어온 요청은 헤더로 IP를 바꿀 수 없습니다. - 모든 응답에
Cache-Control: private, no-store를 붙여 CDN이 파일을 캐시하지 않게 합니다.
Windows 탐색기 연결
네트워크 드라이브 연결에서 폴더에 https://<도메인>/을 넣고 DSM 계정으로 로그인합니다.
(\\<도메인>@SSL\DavWWWRoot 형식도 됩니다.)
알아 둘 점:
-
Cloudflare 뒤에서는 탐색기로 파일 하나당 100MB까지만 업로드됩니다. 큰 파일은 브라우저로 올리세요.
-
Windows는 기본적으로 50MB가 넘는 파일의 다운로드를 막습니다. 관리자 PowerShell에서 한 번 실행하면 약 4GB까지 받을 수 있습니다.
Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Services\WebClient\Parameters FileSizeLimitInBytes 0xFFFFFFFF -Type DWord Restart-Service WebClient
보안
- 권한: 다른 컨테이너가 만든 파일도 관리할 수 있도록 root로 돌지만, 파일 관련 권한 4개(
CHOWN,DAC_OVERRIDE,DAC_READ_SEARCH,FOWNER)만 남기고 모두 버립니다(no-new-privileges). 컨테이너의 파일 시스템은 읽기 전용(read_only,/tmp만 tmpfs)이고 프로세스 수도 제한합니다. - 로그인한 사용자는 마운트한 모든 파일을 지울 수 있습니다. DSM의 공유 폴더 권한은 적용되지 않으니
ALLOWED_USERS와USER_ROOTS로 범위를 정하세요. - 경로:
..과 숨김 이름을 막고, 심볼릭 링크가 공유 폴더(또는 공유 링크로 연 폴더) 밖을 가리키면 거부합니다. - 활성 콘텐츠: HTML, SVG처럼 브라우저에서 실행될 수 있는 파일은 항상 다운로드로 내려갑니다.
웹 화면에는 CSP를 적용하고, 탭에서 여는 파일에는
CSP: sandbox를 붙입니다(PDF는 브라우저 뷰어가 sandbox에서 열리지 않아 제외). - 로그인 제한: IP별 차단에 더해 계정별로 서로 다른 틀린 비밀번호를 셉니다. 여러 IP로 나눠 시도해도
MAX_ACCOUNT_FAILURES에서 막힙니다. 패스키 요청도 IP당 20개까지만 받습니다. - 공유 링크 방문자: 영상 변환과 ZIP의 동시 실행 수를 로그인한 사용자와 따로 제한해서, 링크를 가진 사람이 서버를 다 차지하지 못하게 합니다. 링크 밖을 가리키는 심볼릭 링크는 목록에도 보이지 않습니다.
- 활동 기록: 로그인(성공·실패), 삭제, 이동, 업로드, 저장, 공유 링크 변경, 링크로 올린 파일을
state/activity.log와 컨테이너 로그에 남깁니다. - CSRF: API의 변경 요청에는
X-Requested-With헤더가 필요하고, 세션 쿠키는SameSite=Lax입니다. - DSM 자동 차단: DSM은 로그인 실패를 컨테이너 IP 기준으로 셉니다. 공격 때문에 DSM이 컨테이너를
차단하면 아무도 로그인할 수 없게 되므로 이 서버가 IP별로 먼저 차단합니다. 그래도 DSM 로그에
"IP blocked"가 보이면 제어판 → 보안 → 계정 → 자동 차단의 허용 목록에 Docker 대역(
172.16.0.0/12)을 넣으세요.
개발과 테스트
Node.js 26 이상이 필요합니다. 문법 검사와 단위 테스트는 Docker 없이 돌아갑니다.
npm run check # 모든 .js 문법 검사
npm run test:unit # 경로·ZIP·이동/덮어쓰기·IP 판별 단위 테스트
E2E 테스트는 가짜 DSM과 서버를 Docker로 띄웁니다. 테스트가 파일을 만들고 지우므로 매번 빈 데이터로 시작합니다.
docker compose -f test/compose.test.yaml down
rm -rf test/tmp && mkdir -p "test/tmp/data/share1/#recycle" test/tmp/data/share2 test/tmp/state test/tmp/fido
printf 'hi\n' > test/tmp/data/share2/readme.txt
docker compose -f test/compose.test.yaml up -d --build
node test/smoke.js
테스트는 WebDAV, 업로드, 인증, ZIP, 공유 링크(횟수 제한, 중지, 받는 사람 올리기), 영상 변환, 심볼릭 링크 차단, 공유 폴더 보호, 덮어쓰기 시 휴지통 보관, 작업 취소, 재시작 후 로그인 유지 등을 확인합니다.
이미지 빌드
릴리스 태그를 기준으로 linux/amd64 이미지를 만들어 레지스트리에 올립니다.
VERSION=1.0.0
IMAGE=<레지스트리>/<계정>/webdav
docker build --platform linux/amd64 \
--label org.opencontainers.image.version=$VERSION \
--label org.opencontainers.image.revision=$(git rev-parse HEAD) \
-t $IMAGE:$VERSION -t $IMAGE:latest .
docker push $IMAGE:$VERSION
docker push $IMAGE:latest