├── .eslintrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── codeql-config.yml
├── dependabot.yml
└── workflows
│ ├── codeql.yml
│ ├── eslint.yml
│ └── npmPublish.yml
├── .gitignore
├── .node-version
├── .npmignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── SECURITY.md
├── package-lock.json
├── package.json
├── src
├── certs.ts
├── handlers
│ ├── copy.ts
│ ├── delete.ts
│ ├── get.ts
│ ├── head.ts
│ ├── lock.ts
│ ├── mkcol.ts
│ ├── move.ts
│ ├── options.ts
│ ├── propfind.ts
│ ├── proppatch.ts
│ ├── put.ts
│ └── unlock.ts
├── index.ts
├── logger.ts
├── middlewares
│ ├── auth.ts
│ ├── body.ts
│ └── errors.ts
├── responses.ts
├── semaphore.ts
└── utils.ts
├── tsconfig.json
└── types.d.ts
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": ["@typescript-eslint"],
5 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
6 | "rules": {
7 | "eqeqeq": 2,
8 | "quotes": ["error", "double"],
9 | "no-mixed-spaces-and-tabs": 0,
10 | "no-duplicate-imports": "error"
11 | },
12 | "env": {
13 | "browser": true,
14 | "node": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **OS (please complete the following information):**
27 |
28 | - OS: [e.g. Windows, macOS, Ubuntu]
29 | - Hardware configuration [e.g. CPU, RAM, DISK]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/codeql-config.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL Configuration"
2 | paths:
3 | - src/
4 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: ["main"]
17 | pull_request:
18 | branches: ["main"]
19 | types: [opened, synchronize, reopened]
20 | schedule:
21 | - cron: "23 10 * * 4"
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | # Runner size impacts CodeQL analysis time. To learn more, please see:
27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
28 | # - https://gh.io/supported-runners-and-hardware-resources
29 | # - https://gh.io/using-larger-runners
30 | # Consider using larger runners for possible analysis time improvements.
31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
32 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
33 | permissions:
34 | # required for all workflows
35 | security-events: write
36 |
37 | # only required for workflows in private repositories
38 | actions: read
39 | contents: read
40 |
41 | strategy:
42 | fail-fast: false
43 | matrix:
44 | language: ["javascript-typescript"]
45 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
46 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
47 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
48 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
49 |
50 | steps:
51 | - name: Checkout repository
52 | uses: actions/checkout@v4
53 |
54 | # Initializes the CodeQL tools for scanning.
55 | - name: Initialize CodeQL
56 | uses: github/codeql-action/init@v3
57 | with:
58 | languages: ${{ matrix.language }}
59 | config-file: .github/codeql-config.yml
60 | # If you wish to specify custom queries, you can do so here or in a config file.
61 | # By default, queries listed here will override any specified in a config file.
62 | # Prefix the list here with "+" to use these queries and those in the config file.
63 |
64 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
65 | # queries: security-extended,security-and-quality
66 |
67 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
68 | # If this step fails, then you should remove it and run the build manually (see below)
69 | - name: Autobuild
70 | uses: github/codeql-action/autobuild@v3
71 |
72 | # ℹ️ Command-line programs to run using the OS shell.
73 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
74 |
75 | # If the Autobuild fails above, remove it and uncomment the following three lines.
76 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
77 |
78 | # - run: |
79 | # echo "Run, Build Application using script"
80 | # ./location_of_script_within_repo/buildscript.sh
81 |
82 | - name: Perform CodeQL Analysis
83 | uses: github/codeql-action/analyze@v3
84 | with:
85 | category: "/language:${{matrix.language}}"
86 |
--------------------------------------------------------------------------------
/.github/workflows/eslint.yml:
--------------------------------------------------------------------------------
1 | # This workflow uses actions that are not certified by GitHub.
2 | # They are provided by a third-party and are governed by
3 | # separate terms of service, privacy policy, and support
4 | # documentation.
5 | # ESLint is a tool for identifying and reporting on patterns
6 | # found in ECMAScript/JavaScript code.
7 | # More details at https://github.com/eslint/eslint
8 | # and https://eslint.org
9 |
10 | name: ESLint
11 |
12 | on:
13 | push:
14 | branches: ["main"]
15 | pull_request:
16 | # The branches below must be a subset of the branches above
17 | branches: ["main"]
18 | types: [opened, synchronize, reopened]
19 | schedule:
20 | - cron: "40 15 * * 0"
21 |
22 | jobs:
23 | eslint:
24 | name: Run eslint scanning
25 | runs-on: ubuntu-latest
26 | permissions:
27 | contents: read
28 | security-events: write
29 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
30 | steps:
31 | - name: Checkout code
32 | uses: actions/checkout@v3
33 |
34 | - name: Install ESLint
35 | run: |
36 | npm install eslint@8.10.0
37 | npm install @microsoft/eslint-formatter-sarif@2.1.7
38 |
39 | - name: Run ESLint
40 | run: npx eslint src/**/*
41 | --config .eslintrc
42 | --ext .js,.jsx,.ts,.tsx
43 | --format @microsoft/eslint-formatter-sarif
44 | --output-file eslint-results.sarif
45 | continue-on-error: true
46 |
47 | - name: Upload analysis results to GitHub
48 | uses: github/codeql-action/upload-sarif@v2
49 | with:
50 | sarif_file: eslint-results.sarif
51 | wait-for-processing: true
52 |
--------------------------------------------------------------------------------
/.github/workflows/npmPublish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to npm
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | # Allow one concurrent deployment
8 | concurrency:
9 | group: "npm"
10 | cancel-in-progress: true
11 |
12 | env:
13 | # 7 GiB by default on GitHub, setting to 6 GiB
14 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
15 | NODE_OPTIONS: --max-old-space-size=6144
16 |
17 | jobs:
18 | build:
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v4
23 | - name: Set up Node
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: 20
27 | cache: "npm"
28 | - name: Install dependencies
29 | run: npm install --package-lock-only && npm ci
30 | - name: Build
31 | run: npm run build
32 | - name: Configure npm for publishing
33 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_PUBLISH_TOKEN }}" > ~/.npmrc
34 | - name: Publish
35 | run: npm publish --access public
36 | env:
37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
38 | - name: Cleanup
39 | run: rm -rf ~/.npmrc
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | dev/dev.config.json
3 | .DS_Store
4 | *encrypted
5 | *decrypted
6 | dev/**/*
7 | dev/*
8 | dev
9 | dist
10 | .yalc
11 | yalc.lock
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 20
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | src/test/**/*
3 | dev/**/*
4 | docs/**/*
5 | .github/**/*
6 | .vscode/**/*
7 | dev/*
8 | dev
9 | bin
10 | src
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 4,
4 | "semi": false,
5 | "singleQuote": false,
6 | "useTabs": true,
7 | "jsxSingleQuote": false,
8 | "bracketSpacing": true,
9 | "bracketSameLine": false,
10 | "arrowParens": "avoid",
11 | "singleAttributePerLine": true,
12 | "printWidth": 140,
13 | "endOfLine": "lf"
14 | }
15 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.eol": "\n",
3 | "editor.formatOnPaste": false,
4 | "editor.formatOnSave": true,
5 | "editor.formatOnType": false,
6 | "editor.defaultFormatter": "esbenp.prettier-vscode",
7 | "editor.codeActionsOnSave": {
8 | "source.fixAll.eslint": "never",
9 | "source.fixAll.format": "never"
10 | },
11 | "typescript.tsdk": "node_modules\\typescript\\lib"
12 | }
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Filen WebDAV
4 |
5 |
6 | A package to start a WebDAV server for a single or multiple Filen accounts.
7 |
8 |
9 |
10 |
11 |
12 |     
13 |
14 | ### Installation
15 |
16 | 1. Install using NPM
17 |
18 | ```sh
19 | npm install @filen/webdav@latest
20 | ```
21 |
22 | 2. Initialize the server (standalone mode, single user)
23 |
24 | ```typescript
25 | // Standalone mode, single user
26 |
27 | import { FilenSDK } from "@filen/sdk"
28 | import path from "path"
29 | import os from "os"
30 | import { WebDAVServer } from "@filen/webdav"
31 |
32 | // Initialize a SDK instance (optional)
33 | const filen = new FilenSDK({
34 | metadataCache: true,
35 | connectToSocket: true,
36 | tmpPath: path.join(os.tmpdir(), "filen-sdk")
37 | })
38 |
39 | await filen.login({
40 | email: "your@email.com",
41 | password: "supersecret123",
42 | twoFactorCode: "123456"
43 | })
44 |
45 | const hostname = "127.0.0.1"
46 | const port = 1900
47 | const https = false
48 | const server = new WebDAVServer({
49 | hostname,
50 | port,
51 | https,
52 | user: {
53 | username: "admin",
54 | password: "admin",
55 | sdk: filen
56 | },
57 | authMode: "basic" | "digest"
58 | })
59 |
60 | await server.start()
61 |
62 | console.log(
63 | `WebDAV server started on ${https ? "https" : "http"}://${hostname === "127.0.0.1" ? "local.webdav.filen.io" : hostname}:${port}`
64 | )
65 | ```
66 |
67 | 3. Initialize the server (proxy mode)
68 |
69 | When in proxy mode, the server acts as a local WebDAV gateway for multiple Filen accounts. It accepts Filen login credentials formatted as follows (without the double backticks):
70 |
71 | ```
72 | Username: "youremail@example.com"
73 | Password: "password=yoursecretpassword&twoFactorAuthentication="
74 |
75 | // You can also leave out the "&twoFactorAuthentication=" part if your account is not protected by 2FA.
76 | ```
77 |
78 | Useful for everyone who wants to host a single WebDAV server for multiple accounts/users. Everything still runs client side, keeping the zero-knowledge end-to-end encryption intact.
79 |
80 | ```typescript
81 | // Proxy mode, multi user
82 |
83 | import { WebDAVServer } from "@filen/webdav"
84 |
85 | const hostname = "127.0.0.1"
86 | const port = 1900
87 | const https = false
88 | const server = new WebDAVServer({
89 | hostname,
90 | port,
91 | https,
92 | // Omit the user object
93 | authMode: "basic" // Only basic auth is supported in proxy mode
94 | })
95 |
96 | await server.start()
97 |
98 | console.log(
99 | `WebDAV server started on ${https ? "https" : "http"}://${hostname === "127.0.0.1" ? "local.webdav.filen.io" : hostname}:${port}`
100 | )
101 | ```
102 |
103 | 4. Initialize the server (cluster mode)
104 |
105 | ```typescript
106 | import { FilenSDK } from "@filen/sdk"
107 | import path from "path"
108 | import os from "os"
109 | import { WebDAVServerCluster } from "@filen/webdav"
110 |
111 | // Initialize a SDK instance (optional)
112 | const filen = new FilenSDK({
113 | metadataCache: true,
114 | connectToSocket: true,
115 | tmpPath: path.join(os.tmpdir(), "filen-sdk")
116 | })
117 |
118 | await filen.login({
119 | email: "your@email.com",
120 | password: "supersecret123",
121 | twoFactorCode: "123456"
122 | })
123 |
124 | const hostname = "127.0.0.1"
125 | const port = 1900
126 | const https = false
127 | const server = new WebDAVServerCluster({
128 | hostname,
129 | port,
130 | https,
131 | user: {
132 | username: "admin",
133 | password: "admin",
134 | sdk: filen
135 | },
136 | authMode: "basic" | "digest",
137 | threads: 16 // Number of threads to spawn. Defaults to CPU core count if omitted.
138 | })
139 |
140 | await server.start()
141 |
142 | console.log(
143 | `WebDAV server cluster started on ${https ? "https" : "http"}://${
144 | hostname === "127.0.0.1" ? "local.webdav.filen.io" : hostname
145 | }:${port}`
146 | )
147 | ```
148 |
149 | 5. Access the server
150 |
151 | ```sh
152 | // MacOS
153 | mount_webdav -S -v 'Filen' http://${hostname}:${port} /mnt/filen
154 | ```
155 |
156 | ## License
157 |
158 | Distributed under the AGPL-3.0 License. See [LICENSE](https://github.com/FilenCloudDienste/filen-webdav/blob/main/LICENSE.md) for more information.
159 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | The latest release version of filen-webdav is currently being supported with security updates
6 |
7 | ## Reporting a Vulnerability
8 |
9 | Security is very important to us. If you have discovered a security issue with filen-webdav,
10 | please read our responsible disclosure guidelines and contact us at [https://support.filen.io](https://support.filen.io).
11 | Your report should include:
12 |
13 | - Product version
14 | - A vulnerability description
15 | - Reproduction steps
16 |
17 | A member of the development team will confirm the vulnerability, determine its impact, and develop a fix.
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@filen/webdav",
3 | "version": "0.2.67",
4 | "description": "Filen WebDAV",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "private": false,
8 | "scripts": {
9 | "test": "jest --forceExit ./__tests__",
10 | "lint": "eslint src/**/* --ext .js,.jsx,.ts,.tsx",
11 | "emitTypes": "tsc --emitDeclarationOnly",
12 | "tsc": "tsc --build",
13 | "clear": "rimraf ./dist",
14 | "build": "npm run clear && npm run lint && npm run tsc",
15 | "dev": "tsx ./dev/index.ts",
16 | "yalc": "npm run build && yalc push",
17 | "install:filen": "npm install @filen/sdk@latest"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/FilenCloudDienste/filen-webdav.git"
22 | },
23 | "keywords": [
24 | "filen"
25 | ],
26 | "engines": {
27 | "node": ">=20"
28 | },
29 | "author": "Filen",
30 | "license": "AGPLv3",
31 | "bugs": {
32 | "url": "https://github.com/FilenCloudDienste/filen-webdav/issues"
33 | },
34 | "homepage": "https://filen.io",
35 | "devDependencies": {
36 | "@jest/globals": "^29.7.0",
37 | "@types/express": "^4.17.21",
38 | "@types/fs-extra": "^11.0.4",
39 | "@types/lodash": "^4.14.202",
40 | "@types/mime-types": "^2.1.4",
41 | "@types/picomatch": "^3.0.1",
42 | "@types/uuid": "^10.0.0",
43 | "@types/write-file-atomic": "^4.0.3",
44 | "@types/xml2js": "^0.4.14",
45 | "@typescript-eslint/eslint-plugin": "^6.20.0",
46 | "@typescript-eslint/parser": "^6.20.0",
47 | "cross-env": "^7.0.3",
48 | "eslint": "^8.56.0",
49 | "jest": "^29.7.0",
50 | "rimraf": "^6.0.1",
51 | "ts-node": "^10.9.2",
52 | "tsx": "^4.11.0",
53 | "typescript": "^5.3.3"
54 | },
55 | "dependencies": {
56 | "@filen/sdk": "^0.2.2",
57 | "body-parser": "^1.20.2",
58 | "express": "^4.19.2",
59 | "express-rate-limit": "^7.4.0",
60 | "fs-extra": "^11.2.0",
61 | "js-xxhash": "^4.0.0",
62 | "mime-types": "^2.1.35",
63 | "node-cache": "^5.1.2",
64 | "picomatch": "^4.0.2",
65 | "pino": "^9.4.0",
66 | "rotating-file-stream": "^3.2.3",
67 | "selfsigned": "^2.4.1",
68 | "uuid": "^10.0.0",
69 | "write-file-atomic": "^5.0.1",
70 | "xml-js-builder": "^1.0.3",
71 | "xml2js": "^0.6.2"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/certs.ts:
--------------------------------------------------------------------------------
1 | import selfsigned from "selfsigned"
2 | import fs from "fs-extra"
3 | import pathModule from "path"
4 | import { platformConfigPath } from "./utils"
5 | import writeFileAtomic from "write-file-atomic"
6 |
7 | /**
8 | * Certs
9 | *
10 | * @export
11 | * @class Certs
12 | * @typedef {Certs}
13 | */
14 | export class Certs {
15 | public static dirPath = platformConfigPath()
16 | public static certPath = pathModule.join(this.dirPath, "cert")
17 | public static privateKeyPath = pathModule.join(this.dirPath, "privateKey")
18 | public static expiryPath = pathModule.join(this.dirPath, "expiry")
19 |
20 | /**
21 | * Get or generate the self signed SSL certificate.
22 | *
23 | * @public
24 | * @static
25 | * @async
26 | * @returns {Promise<{ cert: Buffer; privateKey: Buffer }>}
27 | */
28 | public static async get(): Promise<{ cert: Buffer; privateKey: Buffer }> {
29 | await fs.ensureDir(this.dirPath)
30 |
31 | const now = Date.now()
32 | const [certExists, privateKeyExists, expiryExists] = await Promise.all([
33 | fs.exists(this.certPath),
34 | fs.exists(this.privateKeyPath),
35 | fs.exists(this.expiryPath)
36 | ])
37 |
38 | if (certExists && privateKeyExists && expiryExists) {
39 | const expires = parseInt(await fs.readFile(this.expiryPath, "utf8"))
40 |
41 | if (now > expires) {
42 | return {
43 | cert: await fs.readFile(this.certPath),
44 | privateKey: await fs.readFile(this.privateKeyPath)
45 | }
46 | }
47 | }
48 |
49 | const generated = selfsigned.generate(
50 | [
51 | {
52 | name: "commonName",
53 | value: "local.webdav.filen.io"
54 | }
55 | ],
56 | {
57 | days: 365,
58 | algorithm: "sha256",
59 | keySize: 2048
60 | }
61 | )
62 |
63 | await Promise.all([
64 | writeFileAtomic(this.certPath, generated.cert, "utf-8"),
65 | writeFileAtomic(this.privateKeyPath, generated.private, "utf-8"),
66 | writeFileAtomic(this.expiryPath, (now + 86400 * 360).toString(), "utf-8")
67 | ])
68 |
69 | return {
70 | cert: Buffer.from(generated.cert, "utf-8"),
71 | privateKey: Buffer.from(generated.private, "utf-8")
72 | }
73 | }
74 | }
75 |
76 | export default Certs
77 |
--------------------------------------------------------------------------------
/src/handlers/copy.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import Responses from "../responses"
4 | import { removeLastSlash, pathToTempDiskFileId } from "../utils"
5 | import pathModule from "path"
6 | import fs from "fs-extra"
7 |
8 | /**
9 | * Copy
10 | *
11 | * @export
12 | * @class Copy
13 | * @typedef {Copy}
14 | */
15 | export class Copy {
16 | /**
17 | * Creates an instance of Copy.
18 | *
19 | * @constructor
20 | * @public
21 | * @param {Server} server
22 | */
23 | public constructor(private readonly server: Server) {
24 | this.handle = this.handle.bind(this)
25 | }
26 |
27 | /**
28 | * Copy a resource to the destination defined in the destination header. Overwrite if needed.
29 | *
30 | * @public
31 | * @async
32 | * @param {Request} req
33 | * @param {Response} res
34 | * @returns {Promise}
35 | */
36 | public async handle(req: Request, res: Response): Promise {
37 | try {
38 | const destinationHeader = req.headers["destination"]
39 | const overwrite = req.headers["overwrite"] === "T"
40 |
41 | if (
42 | typeof destinationHeader !== "string" ||
43 | !destinationHeader.includes(req.hostname) ||
44 | !destinationHeader.includes(req.protocol)
45 | ) {
46 | await Responses.badRequest(res)
47 |
48 | return
49 | }
50 |
51 | let url: URL | null
52 |
53 | try {
54 | url = new URL(destinationHeader)
55 | } catch {
56 | await Responses.badRequest(res)
57 |
58 | return
59 | }
60 |
61 | if (!url) {
62 | await Responses.badRequest(res)
63 |
64 | return
65 | }
66 |
67 | const destination = decodeURIComponent(url.pathname)
68 |
69 | if (destination.startsWith("..") || destination.startsWith("./") || destination.startsWith("../")) {
70 | await Responses.forbidden(res)
71 |
72 | return
73 | }
74 |
75 | const [resource, destinationResource] = await Promise.all([
76 | this.server.urlToResource(req),
77 | this.server.pathToResource(req, removeLastSlash(destination))
78 | ])
79 |
80 | if (!resource) {
81 | await Responses.notFound(res, req.url)
82 |
83 | return
84 | }
85 |
86 | if (resource.path === destination) {
87 | await Responses.created(res)
88 |
89 | return
90 | }
91 |
92 | if (!overwrite && destinationResource) {
93 | await Responses.alreadyExists(res)
94 |
95 | return
96 | }
97 |
98 | const sdk = this.server.getSDKForUser(req.username)
99 |
100 | if (!sdk) {
101 | await Responses.notAuthorized(res)
102 |
103 | return
104 | }
105 |
106 | if (resource.isVirtual) {
107 | if (overwrite && destinationResource) {
108 | if (destinationResource.tempDiskId) {
109 | await fs.rm(pathModule.join(this.server.tempDiskPath, destinationResource.tempDiskId), {
110 | force: true,
111 | maxRetries: 60 * 10,
112 | recursive: true,
113 | retryDelay: 100
114 | })
115 | }
116 |
117 | if (!destinationResource.isVirtual) {
118 | await sdk.fs().unlink({
119 | path: destinationResource.path,
120 | permanent: true
121 | })
122 | }
123 |
124 | this.server.getVirtualFilesForUser(req.username)[destination] = {
125 | ...resource,
126 | url: destination,
127 | path: destination,
128 | name: pathModule.posix.basename(destination)
129 | }
130 |
131 | await Responses.noContent(res)
132 |
133 | return
134 | }
135 |
136 | this.server.getVirtualFilesForUser(req.username)[destination] = {
137 | ...resource,
138 | url: destination,
139 | path: destination,
140 | name: pathModule.posix.basename(destination)
141 | }
142 |
143 | await Responses.created(res)
144 |
145 | return
146 | }
147 |
148 | if (resource.tempDiskId) {
149 | const destinationTempDiskFileId = pathToTempDiskFileId(destination, req.username)
150 |
151 | if (overwrite && destinationResource) {
152 | if (destinationResource.tempDiskId) {
153 | await fs.rm(pathModule.join(this.server.tempDiskPath, destinationResource.tempDiskId), {
154 | force: true,
155 | maxRetries: 60 * 10,
156 | recursive: true,
157 | retryDelay: 100
158 | })
159 | }
160 |
161 | if (!destinationResource.isVirtual) {
162 | await sdk.fs().unlink({
163 | path: destinationResource.path,
164 | permanent: true
165 | })
166 | }
167 |
168 | await fs.copy(
169 | pathModule.join(this.server.tempDiskPath, resource.tempDiskId),
170 | pathModule.join(this.server.tempDiskPath, destinationTempDiskFileId)
171 | )
172 |
173 | this.server.getTempDiskFilesForUser(req.username)[destination] = {
174 | ...resource,
175 | url: destination,
176 | path: destination,
177 | name: pathModule.posix.basename(destination),
178 | tempDiskId: destinationTempDiskFileId
179 | }
180 |
181 | await Responses.noContent(res)
182 |
183 | return
184 | }
185 |
186 | await fs.copy(
187 | pathModule.join(this.server.tempDiskPath, resource.tempDiskId),
188 | pathModule.join(this.server.tempDiskPath, destinationTempDiskFileId)
189 | )
190 |
191 | this.server.getTempDiskFilesForUser(req.username)[destination] = {
192 | ...resource,
193 | url: destination,
194 | path: destination,
195 | name: pathModule.posix.basename(destination),
196 | tempDiskId: destinationTempDiskFileId
197 | }
198 |
199 | await Responses.created(res)
200 |
201 | return
202 | }
203 |
204 | if (overwrite && destinationResource) {
205 | await sdk.fs().unlink({
206 | path: destinationResource.path,
207 | permanent: false
208 | })
209 |
210 | await sdk.fs().cp({
211 | from: resource.path,
212 | to: destination
213 | })
214 |
215 | await Responses.noContent(res)
216 |
217 | return
218 | }
219 |
220 | await sdk.fs().cp({
221 | from: resource.path,
222 | to: destination
223 | })
224 |
225 | await Responses.created(res)
226 | } catch (e) {
227 | this.server.logger.log("error", e, "copy")
228 | this.server.logger.log("error", e)
229 |
230 | Responses.internalError(res).catch(() => {})
231 | }
232 | }
233 | }
234 |
235 | export default Copy
236 |
--------------------------------------------------------------------------------
/src/handlers/delete.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import Responses from "../responses"
4 | import fs from "fs-extra"
5 | import pathModule from "path"
6 |
7 | /**
8 | * Delete
9 | *
10 | * @export
11 | * @class Delete
12 | * @typedef {Delete}
13 | */
14 | export class Delete {
15 | /**
16 | * Creates an instance of Delete.
17 | *
18 | * @constructor
19 | * @public
20 | * @param {Server} server
21 | */
22 | public constructor(private readonly server: Server) {
23 | this.handle = this.handle.bind(this)
24 | }
25 |
26 | /**
27 | * Delete a file or a directory.
28 | *
29 | * @public
30 | * @async
31 | * @param {Request} req
32 | * @param {Response} res
33 | * @returns {Promise}
34 | */
35 | public async handle(req: Request, res: Response): Promise {
36 | try {
37 | const resource = await this.server.urlToResource(req)
38 |
39 | if (!resource) {
40 | await Responses.notFound(res, req.url)
41 |
42 | return
43 | }
44 |
45 | if (resource.isVirtual) {
46 | delete this.server.getVirtualFilesForUser(req.username)[resource.path]
47 |
48 | await Responses.ok(res)
49 |
50 | return
51 | }
52 |
53 | if (resource.tempDiskId) {
54 | await fs.rm(pathModule.join(this.server.tempDiskPath, resource.tempDiskId), {
55 | force: true,
56 | maxRetries: 60 * 10,
57 | recursive: true,
58 | retryDelay: 100
59 | })
60 |
61 | delete this.server.getTempDiskFilesForUser(req.username)[resource.path]
62 |
63 | await Responses.ok(res)
64 |
65 | return
66 | }
67 |
68 | const sdk = this.server.getSDKForUser(req.username)
69 |
70 | if (!sdk) {
71 | await Responses.notAuthorized(res)
72 |
73 | return
74 | }
75 |
76 | await sdk.fs().unlink({
77 | path: resource.path,
78 | permanent: false
79 | })
80 |
81 | await Responses.ok(res)
82 | } catch (e) {
83 | this.server.logger.log("error", e, "delete")
84 | this.server.logger.log("error", e)
85 |
86 | Responses.internalError(res).catch(() => {})
87 | }
88 | }
89 | }
90 |
91 | export default Delete
92 |
--------------------------------------------------------------------------------
/src/handlers/get.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import mimeTypes from "mime-types"
4 | import { Readable, pipeline } from "stream"
5 | import { type ReadableStream as ReadableStreamWebType } from "stream/web"
6 | import Responses from "../responses"
7 | import { parseByteRange } from "../utils"
8 | import fs from "fs-extra"
9 | import pathModule from "path"
10 | import { promisify } from "util"
11 |
12 | const pipelineAsync = promisify(pipeline)
13 |
14 | /**
15 | * Get
16 | *
17 | * @export
18 | * @class Get
19 | * @typedef {Get}
20 | */
21 | export class Get {
22 | /**
23 | * Creates an instance of Get.
24 | *
25 | * @constructor
26 | * @public
27 | * @param {Server} server
28 | */
29 | public constructor(private readonly server: Server) {
30 | this.handle = this.handle.bind(this)
31 | }
32 |
33 | /**
34 | * Download the requested file as a readStream.
35 | *
36 | * @public
37 | * @async
38 | * @param {Request} req
39 | * @param {Response} res
40 | * @returns {Promise}
41 | */
42 | public async handle(req: Request, res: Response): Promise {
43 | try {
44 | const resource = await this.server.urlToResource(req)
45 |
46 | if (!resource || resource.type === "directory") {
47 | await Responses.notFound(res, req.url)
48 |
49 | return
50 | }
51 |
52 | if (resource.isVirtual) {
53 | res.status(200)
54 | res.set("Content-Type", resource.mime)
55 | res.set("Content-Length", "0")
56 |
57 | Readable.from([]).pipe(res)
58 |
59 | return
60 | }
61 |
62 | const sdk = this.server.getSDKForUser(req.username)
63 |
64 | if (!sdk) {
65 | await Responses.notAuthorized(res)
66 |
67 | return
68 | }
69 |
70 | const mimeType = mimeTypes.lookup(resource.name) || "application/octet-stream"
71 | const totalLength = resource.size
72 | const range = req.headers.range || req.headers["content-range"]
73 | let start = 0
74 | let end = totalLength - 1
75 |
76 | if (range) {
77 | const parsedRange = parseByteRange(range, totalLength)
78 |
79 | if (!parsedRange) {
80 | await Responses.badRequest(res)
81 |
82 | return
83 | }
84 |
85 | start = parsedRange.start
86 | end = parsedRange.end
87 |
88 | res.status(206)
89 | res.set("Content-Range", `bytes ${start}-${end}/${totalLength}`)
90 | res.set("Content-Length", (end - start + 1).toString())
91 | } else {
92 | res.status(200)
93 | res.set("Content-Length", resource.size.toString())
94 | }
95 |
96 | res.set("Content-Type", mimeType)
97 | res.set("Accept-Ranges", "bytes")
98 |
99 | if (resource.tempDiskId) {
100 | await pipelineAsync(
101 | fs.createReadStream(pathModule.join(this.server.tempDiskPath, resource.tempDiskId), {
102 | autoClose: true,
103 | flags: "r",
104 | start,
105 | end
106 | }),
107 | res
108 | )
109 | } else {
110 | const stream = sdk.cloud().downloadFileToReadableStream({
111 | uuid: resource.uuid,
112 | bucket: resource.bucket,
113 | region: resource.region,
114 | version: resource.version,
115 | key: resource.key,
116 | size: resource.size,
117 | chunks: resource.chunks,
118 | start,
119 | end
120 | })
121 |
122 | const nodeStream = Readable.fromWeb(stream as unknown as ReadableStreamWebType)
123 |
124 | const cleanup = () => {
125 | try {
126 | stream.cancel().catch(() => {})
127 |
128 | if (!nodeStream.closed && !nodeStream.destroyed) {
129 | nodeStream.destroy()
130 | }
131 | } catch {
132 | // Noop
133 | }
134 | }
135 |
136 | res.once("close", () => {
137 | cleanup()
138 | })
139 |
140 | res.once("error", () => {
141 | cleanup()
142 | })
143 |
144 | res.once("finish", () => {
145 | cleanup()
146 | })
147 |
148 | req.once("close", () => {
149 | cleanup()
150 | })
151 |
152 | req.once("error", () => {
153 | cleanup()
154 | })
155 |
156 | nodeStream.once("error", () => {
157 | cleanup()
158 | })
159 |
160 | nodeStream.pipe(res)
161 | }
162 | } catch (e) {
163 | this.server.logger.log("error", e, "get")
164 | this.server.logger.log("error", e)
165 |
166 | Responses.internalError(res).catch(() => {})
167 | }
168 | }
169 | }
170 |
171 | export default Get
172 |
--------------------------------------------------------------------------------
/src/handlers/head.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import mimeTypes from "mime-types"
4 | import Responses from "../responses"
5 | import { parseByteRange } from "../utils"
6 |
7 | /**
8 | * Head
9 | *
10 | * @export
11 | * @class Head
12 | * @typedef {Head}
13 | */
14 | export class Head {
15 | /**
16 | * Creates an instance of Head.
17 | *
18 | * @constructor
19 | * @public
20 | * @param {Server} server
21 | */
22 | public constructor(private readonly server: Server) {
23 | this.handle = this.handle.bind(this)
24 | }
25 |
26 | /**
27 | * Head a file.
28 | *
29 | * @public
30 | * @async
31 | * @param {Request} req
32 | * @param {Response} res
33 | * @returns {Promise}
34 | */
35 | public async handle(req: Request, res: Response): Promise {
36 | try {
37 | const resource = await this.server.urlToResource(req)
38 |
39 | if (!resource) {
40 | await Responses.notFound(res, req.url)
41 |
42 | return
43 | }
44 |
45 | if (resource.type === "directory") {
46 | await Responses.forbidden(res)
47 |
48 | return
49 | }
50 |
51 | const mimeType = mimeTypes.lookup(resource.name) || "application/octet-stream"
52 | const totalLength = resource.size
53 | const range = req.headers.range || req.headers["content-range"]
54 | let start = 0
55 | let end = totalLength - 1
56 |
57 | if (range) {
58 | const parsedRange = parseByteRange(range, totalLength)
59 |
60 | if (!parsedRange) {
61 | res.status(400).end()
62 |
63 | return
64 | }
65 |
66 | start = parsedRange.start
67 | end = parsedRange.end
68 |
69 | res.status(206)
70 | res.set("Content-Range", `bytes ${start}-${end}/${totalLength}`)
71 | res.set("Content-Length", (end - start + 1).toString())
72 | } else {
73 | res.status(200)
74 | res.set("Content-Length", resource.size.toString())
75 | }
76 |
77 | res.set("Content-Type", mimeType)
78 | res.set("Accept-Ranges", "bytes")
79 |
80 | await new Promise(resolve => {
81 | res.end(() => {
82 | resolve()
83 | })
84 | })
85 | } catch (e) {
86 | this.server.logger.log("error", e, "head")
87 | this.server.logger.log("error", e)
88 |
89 | Responses.internalError(res).catch(() => {})
90 | }
91 | }
92 | }
93 |
94 | export default Head
95 |
--------------------------------------------------------------------------------
/src/handlers/lock.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import Responses from "../responses"
3 | import type Server from ".."
4 |
5 | /**
6 | * Lock
7 | *
8 | * @export
9 | * @class Lock
10 | * @typedef {Lock}
11 | */
12 | export class Lock {
13 | /**
14 | * Creates an instance of Lock.
15 | *
16 | * @constructor
17 | * @public
18 | * @param {Server} server
19 | */
20 | public constructor(private readonly server: Server) {
21 | this.handle = this.handle.bind(this)
22 | }
23 |
24 | /**
25 | * Handle locking. Not implemented (needed) right now.
26 | *
27 | * @public
28 | * @async
29 | * @param {Request} _
30 | * @param {Response} res
31 | * @returns {Promise}
32 | */
33 | public async handle(_: Request, res: Response): Promise {
34 | try {
35 | await Responses.notImplemented(res)
36 | } catch (e) {
37 | this.server.logger.log("error", e, "lock")
38 | this.server.logger.log("error", e)
39 |
40 | Responses.internalError(res).catch(() => {})
41 | }
42 | }
43 | }
44 |
45 | export default Lock
46 |
--------------------------------------------------------------------------------
/src/handlers/mkcol.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import Responses from "../responses"
4 |
5 | /**
6 | * Mkcol
7 | *
8 | * @export
9 | * @class Mkcol
10 | * @typedef {Mkcol}
11 | */
12 | export class Mkcol {
13 | /**
14 | * Creates an instance of Mkcol.
15 | *
16 | * @constructor
17 | * @public
18 | * @param {Server} server
19 | */
20 | public constructor(private readonly server: Server) {
21 | this.handle = this.handle.bind(this)
22 | }
23 |
24 | /**
25 | * Create a directory at the requested URL.
26 | *
27 | * @public
28 | * @async
29 | * @param {Request} req
30 | * @param {Response} res
31 | * @returns {Promise}
32 | */
33 | public async handle(req: Request, res: Response): Promise {
34 | try {
35 | const path = decodeURIComponent(req.url.endsWith("/") ? req.url.slice(0, req.url.length - 1) : req.url)
36 | const sdk = this.server.getSDKForUser(req.username)
37 |
38 | if (!sdk) {
39 | await Responses.notAuthorized(res)
40 |
41 | return
42 | }
43 |
44 | // The SDK handles checking if a directory with the same name and parent already exists
45 | await sdk.fs().mkdir({ path })
46 |
47 | const resource = await this.server.urlToResource(req)
48 |
49 | if (!resource || resource.type !== "directory") {
50 | await Responses.notFound(res, req.url)
51 |
52 | return
53 | }
54 |
55 | await Responses.created(res)
56 | } catch (e) {
57 | this.server.logger.log("error", e, "mkcol")
58 | this.server.logger.log("error", e)
59 |
60 | Responses.internalError(res).catch(() => {})
61 | }
62 | }
63 | }
64 |
65 | export default Mkcol
66 |
--------------------------------------------------------------------------------
/src/handlers/move.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import Responses from "../responses"
4 | import { removeLastSlash, pathToTempDiskFileId } from "../utils"
5 | import pathModule from "path"
6 | import fs from "fs-extra"
7 |
8 | /**
9 | * Move
10 | *
11 | * @export
12 | * @class Move
13 | * @typedef {Move}
14 | */
15 | export class Move {
16 | /**
17 | * Creates an instance of Move.
18 | *
19 | * @constructor
20 | * @public
21 | * @param {Server} server
22 | */
23 | public constructor(private readonly server: Server) {
24 | this.handle = this.handle.bind(this)
25 | }
26 |
27 | /**
28 | * Move a file or a directory to the destination chosen in the header.
29 | *
30 | * @public
31 | * @async
32 | * @param {Request} req
33 | * @param {Response} res
34 | * @returns {Promise}
35 | */
36 | public async handle(req: Request, res: Response): Promise {
37 | try {
38 | const destinationHeader = req.headers["destination"]
39 | const overwrite = req.headers["overwrite"] === "T"
40 |
41 | if (
42 | typeof destinationHeader !== "string" ||
43 | !destinationHeader.includes(req.hostname) ||
44 | !destinationHeader.includes(req.protocol)
45 | ) {
46 | await Responses.badRequest(res)
47 |
48 | return
49 | }
50 |
51 | let url: URL | null
52 |
53 | try {
54 | url = new URL(destinationHeader)
55 | } catch {
56 | await Responses.badRequest(res)
57 |
58 | return
59 | }
60 |
61 | if (!url) {
62 | await Responses.badRequest(res)
63 |
64 | return
65 | }
66 |
67 | const destination = decodeURIComponent(url.pathname)
68 |
69 | if (destination.startsWith("..") || destination.startsWith("./") || destination.startsWith("../")) {
70 | await Responses.forbidden(res)
71 |
72 | return
73 | }
74 |
75 | const [resource, destinationResource] = await Promise.all([
76 | this.server.urlToResource(req),
77 | this.server.pathToResource(req, removeLastSlash(destination))
78 | ])
79 |
80 | if (!resource) {
81 | await Responses.notFound(res, req.url)
82 |
83 | return
84 | }
85 |
86 | if (resource.path === destination) {
87 | await Responses.created(res)
88 |
89 | return
90 | }
91 |
92 | if (!overwrite && destinationResource) {
93 | await Responses.alreadyExists(res)
94 |
95 | return
96 | }
97 |
98 | const sdk = this.server.getSDKForUser(req.username)
99 |
100 | if (!sdk) {
101 | await Responses.notAuthorized(res)
102 |
103 | return
104 | }
105 |
106 | if (resource.isVirtual) {
107 | if (overwrite && destinationResource) {
108 | if (destinationResource.tempDiskId) {
109 | await fs.rm(pathModule.join(this.server.tempDiskPath, destinationResource.tempDiskId), {
110 | force: true,
111 | maxRetries: 60 * 10,
112 | recursive: true,
113 | retryDelay: 100
114 | })
115 | }
116 |
117 | if (!destinationResource.isVirtual) {
118 | await sdk.fs().unlink({
119 | path: destinationResource.path,
120 | permanent: true
121 | })
122 | }
123 |
124 | this.server.getVirtualFilesForUser(req.username)[destination] = {
125 | ...resource,
126 | url: destination,
127 | path: destination,
128 | name: pathModule.posix.basename(destination)
129 | }
130 |
131 | delete this.server.getVirtualFilesForUser(req.username)[resource.path]
132 |
133 | await Responses.noContent(res)
134 |
135 | return
136 | }
137 |
138 | this.server.getVirtualFilesForUser(req.username)[destination] = {
139 | ...resource,
140 | url: destination,
141 | path: destination,
142 | name: pathModule.posix.basename(destination)
143 | }
144 |
145 | delete this.server.getVirtualFilesForUser(req.username)[resource.path]
146 |
147 | await Responses.created(res)
148 |
149 | return
150 | }
151 |
152 | if (resource.tempDiskId) {
153 | const destinationTempDiskFileId = pathToTempDiskFileId(destination, req.username)
154 |
155 | if (overwrite && destinationResource) {
156 | if (destinationResource.tempDiskId) {
157 | await fs.rm(pathModule.join(this.server.tempDiskPath, destinationResource.tempDiskId), {
158 | force: true,
159 | maxRetries: 60 * 10,
160 | recursive: true,
161 | retryDelay: 100
162 | })
163 | }
164 |
165 | if (!destinationResource.isVirtual) {
166 | await sdk.fs().unlink({
167 | path: destinationResource.path,
168 | permanent: true
169 | })
170 | }
171 |
172 | await fs.rename(
173 | pathModule.join(this.server.tempDiskPath, resource.tempDiskId),
174 | pathModule.join(this.server.tempDiskPath, destinationTempDiskFileId)
175 | )
176 |
177 | this.server.getTempDiskFilesForUser(req.username)[destination] = {
178 | ...resource,
179 | url: destination,
180 | path: destination,
181 | name: pathModule.posix.basename(destination),
182 | tempDiskId: destinationTempDiskFileId
183 | }
184 |
185 | delete this.server.getTempDiskFilesForUser(req.username)[resource.path]
186 |
187 | await Responses.noContent(res)
188 |
189 | return
190 | }
191 |
192 | await fs.rename(
193 | pathModule.join(this.server.tempDiskPath, resource.tempDiskId),
194 | pathModule.join(this.server.tempDiskPath, destinationTempDiskFileId)
195 | )
196 |
197 | this.server.getTempDiskFilesForUser(req.username)[destination] = {
198 | ...resource,
199 | url: destination,
200 | path: destination,
201 | name: pathModule.posix.basename(destination),
202 | tempDiskId: destinationTempDiskFileId
203 | }
204 |
205 | delete this.server.getTempDiskFilesForUser(req.username)[resource.path]
206 |
207 | await Responses.created(res)
208 |
209 | return
210 | }
211 |
212 | if (overwrite && destinationResource) {
213 | await sdk.fs().unlink({
214 | path: destinationResource.path,
215 | permanent: false
216 | })
217 |
218 | await sdk.fs().rename({
219 | from: resource.path,
220 | to: destination
221 | })
222 |
223 | await Responses.noContent(res)
224 |
225 | return
226 | }
227 |
228 | await sdk.fs().rename({
229 | from: resource.path,
230 | to: destination
231 | })
232 |
233 | await Responses.created(res)
234 | } catch (e) {
235 | this.server.logger.log("error", e, "move")
236 | this.server.logger.log("error", e)
237 |
238 | Responses.internalError(res).catch(() => {})
239 | }
240 | }
241 | }
242 |
243 | export default Move
244 |
--------------------------------------------------------------------------------
/src/handlers/options.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import Responses from "../responses"
3 | import type Server from ".."
4 |
5 | /**
6 | * Options
7 | *
8 | * @export
9 | * @class Options
10 | * @typedef {Options}
11 | */
12 | export class Options {
13 | /**
14 | * Creates an instance of Options.
15 | *
16 | * @constructor
17 | * @public
18 | * @param {Server} server
19 | */
20 | public constructor(private readonly server: Server) {
21 | this.handle = this.handle.bind(this)
22 | }
23 |
24 | /**
25 | * Options
26 | *
27 | * @public
28 | * @async
29 | * @param {Request} _
30 | * @param {Response} res
31 | * @returns {Promise}
32 | */
33 | public async handle(_: Request, res: Response): Promise {
34 | try {
35 | await Responses.ok(res)
36 | } catch (e) {
37 | this.server.logger.log("error", e, "options")
38 | this.server.logger.log("error", e)
39 |
40 | Responses.internalError(res).catch(() => {})
41 | }
42 | }
43 | }
44 |
45 | export default Options
46 |
--------------------------------------------------------------------------------
/src/handlers/propfind.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import Responses from "../responses"
4 | import pathModule from "path"
5 | import { promiseAllChunked } from "../utils"
6 | import { type StatFS, type FilenSDK } from "@filen/sdk"
7 |
8 | /**
9 | * Propfind
10 | *
11 | * @export
12 | * @class Propfind
13 | * @typedef {Propfind}
14 | */
15 | export class Propfind {
16 | /**
17 | * Creates an instance of Propfind.
18 | *
19 | * @constructor
20 | * @public
21 | * @param {Server} server
22 | */
23 | public constructor(private readonly server: Server) {
24 | this.handle = this.handle.bind(this)
25 | }
26 |
27 | public async statfs(req: Request, sdk: FilenSDK): Promise {
28 | const cache = this.server.getCacheForUser(req.username)
29 | const get = cache.get("statfs")
30 |
31 | if (get) {
32 | return get
33 | }
34 |
35 | const stat = await sdk.fs().statfs()
36 |
37 | cache.set("statfs", stat, 60)
38 |
39 | return stat
40 | }
41 |
42 | /**
43 | * List a file or a directory and it's children.
44 | *
45 | * @public
46 | * @async
47 | * @param {Request} req
48 | * @param {Response} res
49 | * @returns {Promise}
50 | */
51 | public async handle(req: Request, res: Response): Promise {
52 | try {
53 | const depth = req.header("depth") ?? "1"
54 | const resource = await this.server.urlToResource(req)
55 |
56 | if (!resource) {
57 | await Responses.notFound(res, req.url)
58 |
59 | return
60 | }
61 |
62 | const sdk = this.server.getSDKForUser(req.username)
63 |
64 | if (!sdk) {
65 | await Responses.notAuthorized(res)
66 |
67 | return
68 | }
69 |
70 | const statfs = await this.statfs(req, sdk)
71 |
72 | if (resource.type === "directory" && depth !== "0") {
73 | const content = await sdk.fs().readdir({ path: resource.url })
74 | const contentIncludingStats = await promiseAllChunked(
75 | content.map(item => sdk.fs().stat({ path: pathModule.posix.join(resource.url, item) }))
76 | )
77 |
78 | for (const path in this.server.getVirtualFilesForUser(req.username)) {
79 | const parentPath = pathModule.dirname(path)
80 |
81 | if (parentPath === resource.path || parentPath === resource.url) {
82 | contentIncludingStats.push(this.server.getVirtualFilesForUser(req.username)[path]!)
83 | }
84 | }
85 |
86 | for (const path in this.server.getTempDiskFilesForUser(req.username)) {
87 | const parentPath = pathModule.dirname(path)
88 |
89 | if (parentPath === resource.path || parentPath === resource.url) {
90 | contentIncludingStats.push(this.server.getTempDiskFilesForUser(req.username)[path]!)
91 | }
92 | }
93 |
94 | await Responses.propfind(
95 | res,
96 | [
97 | resource,
98 | ...contentIncludingStats.map(item => ({
99 | ...item,
100 | path: pathModule.posix.join(resource.path, item.name),
101 | url: `${pathModule.posix.join(resource.path, item.name)}${item.type === "directory" ? "/" : ""}`,
102 | isVirtual: false
103 | }))
104 | ],
105 | {
106 | available: (statfs.max - statfs.used) * 1,
107 | used: statfs.used * 1
108 | }
109 | )
110 |
111 | return
112 | }
113 |
114 | await Responses.propfind(
115 | res,
116 | [
117 | {
118 | ...resource,
119 | url: `${resource.url}${resource.type === "directory" && !resource.url.endsWith("/") ? "/" : ""}`
120 | }
121 | ],
122 | {
123 | available: (statfs.max - statfs.used) * 1,
124 | used: statfs.used * 1
125 | }
126 | )
127 | } catch (e) {
128 | this.server.logger.log("error", e, "propfind")
129 | this.server.logger.log("error", e)
130 |
131 | Responses.internalError(res).catch(() => {})
132 | }
133 | }
134 | }
135 |
136 | export default Propfind
137 |
--------------------------------------------------------------------------------
/src/handlers/proppatch.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import Responses from "../responses"
3 | import type Server from ".."
4 | import { parseStringPromise } from "xml2js"
5 | import { isValidDate, removeLastSlash } from "../utils"
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 | export function extractSetProperties(parsedXml: any): { [key: string]: string | null } {
9 | const properties: { [key: string]: string | null } = {}
10 |
11 | // Ensure the root and "d:set" structure exist, with case-insensitive handling for namespaces
12 | if (!parsedXml || !parsedXml["d:propertyupdate"]) {
13 | return properties
14 | }
15 |
16 | const propertyUpdate = parsedXml["d:propertyupdate"]
17 | const setSection = propertyUpdate["d:set"] || propertyUpdate["D:set"]
18 |
19 | if (!setSection || (!setSection["d:prop"] && !setSection["D:prop"])) {
20 | return properties
21 | }
22 |
23 | const propSection = setSection["d:prop"] || setSection["D:prop"]
24 | const propEntries = Array.isArray(propSection) ? propSection : [propSection]
25 |
26 | for (const prop of propEntries) {
27 | for (const key in prop) {
28 | // Skip non-property keys or metadata
29 | if (key.startsWith("_") || key === "$") {
30 | continue
31 | }
32 |
33 | // Handle namespaces (e.g., "d:property1" becomes "property1")
34 | const cleanKey = key.split(":").pop() || key
35 |
36 | // Extract value, considering multiple possible formats
37 | const value = typeof prop[key] === "string" ? prop[key] : prop[key]?._text || prop[key]?._ || null
38 |
39 | properties[cleanKey] = value
40 | }
41 | }
42 |
43 | return properties
44 | }
45 |
46 | /**
47 | * Proppatch
48 | *
49 | * @export
50 | * @class Proppatch
51 | * @typedef {Proppatch}
52 | */
53 | export class Proppatch {
54 | /**
55 | * Creates an instance of Proppatch.
56 | *
57 | * @constructor
58 | * @public
59 | * @param {Server} server
60 | */
61 | public constructor(private readonly server: Server) {
62 | this.handle = this.handle.bind(this)
63 | }
64 |
65 | /**
66 | * Handle property patching. Not implemented (needed) right now.
67 | *
68 | * @public
69 | * @async
70 | * @param {Request} req
71 | * @param {Response} res
72 | * @returns {Promise}
73 | */
74 | public async handle(req: Request, res: Response): Promise {
75 | try {
76 | const path = removeLastSlash(decodeURIComponent(req.url))
77 | const resource = await this.server.urlToResource(req)
78 |
79 | if (!resource) {
80 | await Responses.notFound(res, req.url)
81 |
82 | return
83 | }
84 |
85 | if (resource.type !== "file") {
86 | await Responses.proppatch(res, req.url)
87 |
88 | return
89 | }
90 |
91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
92 | const parsed: Record = await parseStringPromise(req.body, {
93 | trim: true,
94 | normalize: true,
95 | normalizeTags: true,
96 | explicitArray: false
97 | })
98 |
99 | const properties = extractSetProperties(parsed)
100 | let lastModified: number | undefined
101 | let creation: number | undefined
102 |
103 | if (
104 | !lastModified &&
105 | properties["getlastmodified"] &&
106 | typeof properties["getlastmodified"] === "string" &&
107 | isValidDate(properties["getlastmodified"])
108 | ) {
109 | lastModified = new Date(properties["getlastmodified"]).getTime()
110 | }
111 |
112 | if (
113 | !lastModified &&
114 | properties["lastmodified"] &&
115 | typeof properties["lastmodified"] === "string" &&
116 | isValidDate(properties["lastmodified"])
117 | ) {
118 | lastModified = new Date(properties["lastmodified"]).getTime()
119 | }
120 |
121 | if (
122 | !creation &&
123 | properties["creationdate"] &&
124 | typeof properties["creationdate"] === "string" &&
125 | isValidDate(properties["creationdate"])
126 | ) {
127 | creation = new Date(properties["creationdate"]).getTime()
128 | }
129 |
130 | if (
131 | !creation &&
132 | properties["getcreationdate"] &&
133 | typeof properties["getcreationdate"] === "string" &&
134 | isValidDate(properties["getcreationdate"])
135 | ) {
136 | creation = new Date(properties["getcreationdate"]).getTime()
137 | }
138 |
139 | if (!lastModified && !creation) {
140 | await Responses.proppatch(res, req.url, Object.keys(properties))
141 |
142 | return
143 | }
144 |
145 | if (resource.isVirtual) {
146 | const current = this.server.getVirtualFilesForUser(req.username)[path]
147 |
148 | if (current && current.type === "file") {
149 | this.server.getVirtualFilesForUser(req.username)[path] = {
150 | ...current,
151 | lastModified: lastModified ? lastModified : current.lastModified,
152 | creation: creation ? creation : current.creation
153 | }
154 | }
155 | } else if (resource.tempDiskId) {
156 | const current = this.server.getTempDiskFilesForUser(req.username)[path]
157 |
158 | if (current && current.type === "file") {
159 | this.server.getTempDiskFilesForUser(req.username)[path] = {
160 | ...current,
161 | lastModified: lastModified ? lastModified : current.lastModified,
162 | creation: creation ? creation : current.creation
163 | }
164 | }
165 | } else {
166 | const sdk = this.server.getSDKForUser(req.username)
167 |
168 | if (!sdk) {
169 | await Responses.notAuthorized(res)
170 |
171 | return
172 | }
173 |
174 | await sdk.cloud().editFileMetadata({
175 | uuid: resource.uuid,
176 | metadata: {
177 | name: resource.name,
178 | key: resource.key,
179 | lastModified: lastModified ? lastModified : resource.lastModified,
180 | creation: creation ? creation : resource.creation,
181 | hash: resource.hash,
182 | size: resource.size,
183 | mime: resource.mime
184 | }
185 | })
186 |
187 | await sdk.fs()._removeItem({ path })
188 | await sdk.fs()._addItem({
189 | path,
190 | item: {
191 | type: "file",
192 | uuid: resource.uuid,
193 | metadata: {
194 | name: resource.name,
195 | size: resource.size,
196 | lastModified: lastModified ? lastModified : resource.lastModified,
197 | creation: creation ? creation : resource.creation,
198 | hash: resource.hash,
199 | key: resource.key,
200 | bucket: resource.bucket,
201 | region: resource.region,
202 | version: resource.version,
203 | chunks: resource.chunks,
204 | mime: resource.mime
205 | }
206 | }
207 | })
208 | }
209 |
210 | await Responses.proppatch(res, req.url, Object.keys(properties))
211 | } catch (e) {
212 | this.server.logger.log("error", e, "proppatch")
213 | this.server.logger.log("error", e)
214 |
215 | Responses.internalError(res).catch(() => {})
216 | }
217 | }
218 | }
219 |
220 | export default Proppatch
221 |
--------------------------------------------------------------------------------
/src/handlers/put.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import type Server from ".."
3 | import pathModule from "path"
4 | import { v4 as uuidv4 } from "uuid"
5 | import mimeTypes from "mime-types"
6 | import { removeLastSlash, pathToTempDiskFileId } from "../utils"
7 | import Responses from "../responses"
8 | import { PassThrough, pipeline, Transform } from "stream"
9 | import { promisify } from "util"
10 | import fs from "fs-extra"
11 | import { UPLOAD_CHUNK_SIZE } from "@filen/sdk"
12 |
13 | const pipelineAsync = promisify(pipeline)
14 |
15 | export class SizeCounter extends Transform {
16 | private totalBytes: number
17 |
18 | public constructor() {
19 | super()
20 |
21 | this.totalBytes = 0
22 | }
23 |
24 | public size(): number {
25 | return this.totalBytes
26 | }
27 |
28 | public _transform(chunk: Buffer, _: BufferEncoding, callback: () => void): void {
29 | this.totalBytes += chunk.length
30 |
31 | this.push(chunk)
32 |
33 | callback()
34 | }
35 |
36 | public _flush(callback: () => void): void {
37 | callback()
38 | }
39 | }
40 |
41 | /**
42 | * Put
43 | *
44 | * @export
45 | * @class Put
46 | * @typedef {Put}
47 | */
48 | export class Put {
49 | /**
50 | * Creates an instance of Put.
51 | *
52 | * @constructor
53 | * @public
54 | * @param {Server} server
55 | */
56 | public constructor(private readonly server: Server) {
57 | this.handle = this.handle.bind(this)
58 | }
59 |
60 | /**
61 | * Upload a file to the requested URL. If the incoming stream contains no data, we create a virtual file instead (Windows likes this).
62 | *
63 | * @public
64 | * @async
65 | * @param {Request} req
66 | * @param {Response} res
67 | * @returns {Promise}
68 | */
69 | public async handle(req: Request, res: Response): Promise {
70 | try {
71 | const path = removeLastSlash(decodeURIComponent(req.url))
72 | const parentPath = pathModule.posix.dirname(path)
73 | const name = pathModule.posix.basename(path)
74 | const thisResource = await this.server.pathToResource(req, path)
75 |
76 | // The SDK handles checking if a file with the same name and parent already exists
77 | if (thisResource && thisResource.type === "directory") {
78 | await Responses.alreadyExists(res)
79 |
80 | return
81 | }
82 |
83 | const sdk = this.server.getSDKForUser(req.username)
84 |
85 | if (!sdk) {
86 | await Responses.notAuthorized(res)
87 |
88 | return
89 | }
90 |
91 | await sdk.fs().mkdir({ path: parentPath })
92 |
93 | const parentResource = await this.server.pathToResource(req, parentPath)
94 |
95 | if (!parentResource || parentResource.type !== "directory") {
96 | await Responses.preconditionFailed(res)
97 |
98 | return
99 | }
100 |
101 | if (!req.firstBodyChunk || req.firstBodyChunk.byteLength === 0) {
102 | this.server.getVirtualFilesForUser(req.username)[path] = {
103 | type: "file",
104 | uuid: uuidv4(),
105 | path: path,
106 | url: path,
107 | isDirectory() {
108 | return false
109 | },
110 | isFile() {
111 | return true
112 | },
113 | mtimeMs: Date.now(),
114 | region: "",
115 | bucket: "",
116 | birthtimeMs: Date.now(),
117 | key: "",
118 | lastModified: Date.now(),
119 | name,
120 | mime: mimeTypes.lookup(name) || "application/octet-stream",
121 | version: 2,
122 | chunks: 1,
123 | size: 0,
124 | isVirtual: true
125 | }
126 |
127 | await Responses.created(res)
128 |
129 | delete this.server.getTempDiskFilesForUser(req.username)[path]
130 |
131 | return
132 | }
133 |
134 | let didError = false
135 | const stream = new PassThrough()
136 |
137 | await new Promise((resolve, reject) => {
138 | stream.write(req.firstBodyChunk, err => {
139 | if (err) {
140 | reject(err)
141 |
142 | return
143 | }
144 |
145 | resolve()
146 | })
147 | })
148 |
149 | stream.on("error", () => {
150 | delete this.server.getVirtualFilesForUser(req.username)[path]
151 | delete this.server.getTempDiskFilesForUser(req.username)[path]
152 |
153 | didError = true
154 |
155 | Responses.internalError(res).catch(() => {})
156 | })
157 |
158 | if (this.server.putMatcher && (this.server.putMatcher(path) || this.server.putMatcher(name))) {
159 | const destinationTempDiskFileId = pathToTempDiskFileId(path, req.username)
160 |
161 | await fs.rm(pathModule.join(this.server.tempDiskPath, destinationTempDiskFileId), {
162 | force: true,
163 | maxRetries: 60 * 10,
164 | recursive: true,
165 | retryDelay: 100
166 | })
167 |
168 | const sizeCounter = new SizeCounter()
169 |
170 | await pipelineAsync(
171 | req.pipe(stream),
172 | sizeCounter,
173 | fs.createWriteStream(pathModule.join(this.server.tempDiskPath, destinationTempDiskFileId), {
174 | flags: "w",
175 | autoClose: true
176 | })
177 | )
178 |
179 | this.server.getTempDiskFilesForUser(req.username)[path] = {
180 | type: "file",
181 | uuid: uuidv4(),
182 | path: path,
183 | url: path,
184 | isDirectory() {
185 | return false
186 | },
187 | isFile() {
188 | return true
189 | },
190 | mtimeMs: Date.now(),
191 | region: "",
192 | bucket: "",
193 | birthtimeMs: Date.now(),
194 | key: "",
195 | lastModified: Date.now(),
196 | name,
197 | mime: mimeTypes.lookup(name) || "application/octet-stream",
198 | version: 2,
199 | chunks: Math.ceil(sizeCounter.size() / UPLOAD_CHUNK_SIZE),
200 | size: sizeCounter.size(),
201 | isVirtual: false,
202 | tempDiskId: destinationTempDiskFileId
203 | }
204 |
205 | delete this.server.getVirtualFilesForUser(req.username)[path]
206 |
207 | await Responses.created(res)
208 |
209 | return
210 | }
211 |
212 | const item = await sdk.cloud().uploadLocalFileStream({
213 | source: req.pipe(stream),
214 | parent: parentResource.uuid,
215 | name,
216 | onError: () => {
217 | delete this.server.getVirtualFilesForUser(req.username)[path]
218 | delete this.server.getTempDiskFilesForUser(req.username)[path]
219 |
220 | didError = true
221 |
222 | Responses.internalError(res).catch(() => {})
223 | }
224 | })
225 |
226 | delete this.server.getVirtualFilesForUser(req.username)[path]
227 | delete this.server.getTempDiskFilesForUser(req.username)[path]
228 |
229 | if (didError) {
230 | return
231 | }
232 |
233 | if (item.type !== "file") {
234 | await Responses.badRequest(res)
235 |
236 | return
237 | }
238 |
239 | await sdk.fs()._removeItem({ path })
240 | await sdk.fs()._addItem({
241 | path,
242 | item: {
243 | type: "file",
244 | uuid: item.uuid,
245 | metadata: {
246 | name,
247 | size: item.size,
248 | lastModified: item.lastModified,
249 | creation: item.creation,
250 | hash: item.hash,
251 | key: item.key,
252 | bucket: item.bucket,
253 | region: item.region,
254 | version: item.version,
255 | chunks: item.chunks,
256 | mime: item.mime
257 | }
258 | }
259 | })
260 |
261 | await Responses.created(res)
262 | } catch (e) {
263 | this.server.logger.log("error", e, "put")
264 | this.server.logger.log("error", e)
265 |
266 | Responses.internalError(res).catch(() => {})
267 | }
268 | }
269 | }
270 |
271 | export default Put
272 |
--------------------------------------------------------------------------------
/src/handlers/unlock.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from "express"
2 | import Responses from "../responses"
3 | import type Server from ".."
4 |
5 | /**
6 | * Unlock
7 | *
8 | * @export
9 | * @class Unlock
10 | * @typedef {Unlock}
11 | */
12 | export class Unlock {
13 | /**
14 | * Creates an instance of Unlock.
15 | *
16 | * @constructor
17 | * @public
18 | * @param {Server} server
19 | */
20 | public constructor(private readonly server: Server) {
21 | this.handle = this.handle.bind(this)
22 | }
23 |
24 | /**
25 | * Handle unlocking. Not implemented (needed) right now.
26 | *
27 | * @public
28 | * @async
29 | * @param {Request} _
30 | * @param {Response} res
31 | * @returns {Promise}
32 | */
33 | public async handle(_: Request, res: Response): Promise {
34 | try {
35 | await Responses.notImplemented(res)
36 | } catch (e) {
37 | this.server.logger.log("error", e, "unlock")
38 | this.server.logger.log("error", e)
39 |
40 | Responses.internalError(res).catch(() => {})
41 | }
42 | }
43 | }
44 |
45 | export default Unlock
46 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import express, { type Express, type Request } from "express"
2 | import Head from "./handlers/head"
3 | import FilenSDK, { type FSStats, type FilenSDKConfig } from "@filen/sdk"
4 | import Get from "./handlers/get"
5 | import Errors from "./middlewares/errors"
6 | import bodyParser from "body-parser"
7 | import Options from "./handlers/options"
8 | import Propfind from "./handlers/propfind"
9 | import Put from "./handlers/put"
10 | import Mkcol from "./handlers/mkcol"
11 | import Delete from "./handlers/delete"
12 | import Copy from "./handlers/copy"
13 | import Proppatch from "./handlers/proppatch"
14 | import Move from "./handlers/move"
15 | import Auth, { parseDigestAuthHeader } from "./middlewares/auth"
16 | import { removeLastSlash, tempDiskPath } from "./utils"
17 | import Lock from "./handlers/lock"
18 | import Unlock from "./handlers/unlock"
19 | import { Semaphore, type ISemaphore } from "./semaphore"
20 | import https from "https"
21 | import Certs from "./certs"
22 | import body from "./middlewares/body"
23 | import NodeCache from "node-cache"
24 | import http, { type IncomingMessage, type ServerResponse } from "http"
25 | import { type Socket } from "net"
26 | import { v4 as uuidv4 } from "uuid"
27 | import { type Duplex } from "stream"
28 | import { rateLimit } from "express-rate-limit"
29 | import Logger from "./logger"
30 | import cluster from "cluster"
31 | import os from "os"
32 | // @ts-expect-error Picomatch exports wrong types
33 | import picomatch from "picomatch/posix"
34 | import { type Matcher } from "picomatch"
35 | import fs from "fs-extra"
36 |
37 | export type ServerConfig = {
38 | hostname: string
39 | port: number
40 | }
41 |
42 | export type Resource = FSStats & {
43 | url: string
44 | path: string
45 | isVirtual: boolean
46 | tempDiskId?: string
47 | }
48 |
49 | export type User = {
50 | sdkConfig?: FilenSDKConfig
51 | sdk?: FilenSDK
52 | username: string
53 | password: string
54 | }
55 |
56 | export type AuthMode = "basic" | "digest"
57 |
58 | export type RateLimit = {
59 | windowMs: number
60 | limit: number
61 | key: "ip" | "username"
62 | }
63 |
64 | /**
65 | * WebDAVServer
66 | *
67 | * @export
68 | * @class WebDAVServer
69 | * @typedef {WebDAVServer}
70 | */
71 | export class WebDAVServer {
72 | public readonly server: Express
73 | public readonly users: Record = {}
74 | public readonly serverConfig: ServerConfig
75 | public readonly virtualFiles: Record> = {}
76 | public readonly tempDiskFiles: Record> = {}
77 | public readonly proxyMode: boolean
78 | public readonly defaultUsername: string = ""
79 | public readonly defaultPassword: string = ""
80 | public readonly authMode: AuthMode
81 | public readonly rwMutex: Record> = {}
82 | public readonly enableHTTPS: boolean
83 | public readonly cache: Record = {}
84 | public serverInstance:
85 | | https.Server
86 | | http.Server
87 | | null = null
88 | public connections: Record = {}
89 | public readonly rateLimit: RateLimit
90 | public readonly logger: Logger
91 | public readonly tempDiskPath: string
92 | public readonly putMatcher: Matcher | null
93 |
94 | /**
95 | * Creates an instance of WebDAVServer.
96 | *
97 | * @constructor
98 | * @public
99 | * @param {{
100 | * hostname?: string
101 | * port?: number
102 | * authMode?: "basic" | "digest"
103 | * https?: boolean
104 | * user?: User
105 | * rateLimit?: RateLimit
106 | * disableLogging?: boolean
107 | * tempFilesToStoreOnDisk?: string[]
108 | * }} param0
109 | * @param {string} [param0.hostname="127.0.0.1"]
110 | * @param {number} [param0.port=1900]
111 | * @param {User} param0.user
112 | * @param {("basic" | "digest")} [param0.authMode="basic"]
113 | * @param {boolean} [param0.https=false]
114 | * @param {RateLimit} [param0.rateLimit={
115 | * windowMs: 1000,
116 | * limit: 1000,
117 | * key: "username"
118 | * }]
119 | * @param {boolean} [param0.disableLogging=false]
120 | * @param {{}} [param0.tempFilesToStoreOnDisk=[]] Glob patterns of files that should not be uploaded to the cloud. Files matching the pattern will be served locally.
121 | */
122 | public constructor({
123 | hostname = "127.0.0.1",
124 | port = 1900,
125 | user,
126 | authMode = "basic",
127 | https = false,
128 | rateLimit = {
129 | windowMs: 1000,
130 | limit: 1000,
131 | key: "username"
132 | },
133 | disableLogging = false,
134 | tempFilesToStoreOnDisk = []
135 | }: {
136 | hostname?: string
137 | port?: number
138 | authMode?: "basic" | "digest"
139 | https?: boolean
140 | user?: User
141 | rateLimit?: RateLimit
142 | disableLogging?: boolean
143 | tempFilesToStoreOnDisk?: string[]
144 | }) {
145 | this.enableHTTPS = https
146 | this.authMode = authMode
147 | this.rateLimit = rateLimit
148 | this.serverConfig = {
149 | hostname,
150 | port
151 | }
152 | this.proxyMode = typeof user === "undefined"
153 | this.server = express()
154 | this.logger = new Logger(disableLogging, false)
155 | this.tempDiskPath = tempDiskPath()
156 | this.putMatcher = tempFilesToStoreOnDisk.length > 0 ? picomatch(tempFilesToStoreOnDisk) : null
157 |
158 | if (this.proxyMode && this.authMode === "digest") {
159 | throw new Error("Digest authentication is not supported in proxy mode.")
160 | }
161 |
162 | if (user) {
163 | if (!user.sdk && !user.sdkConfig) {
164 | throw new Error("Either pass a configured SDK instance OR a SDKConfig object to the user object.")
165 | }
166 |
167 | this.defaultUsername = user.username
168 | this.defaultPassword = user.password
169 |
170 | this.users[user.username] = {
171 | username: user.username,
172 | password: user.password,
173 | sdk: user.sdk
174 | ? user.sdk
175 | : new FilenSDK({
176 | ...user.sdkConfig,
177 | connectToSocket: true,
178 | metadataCache: true
179 | })
180 | }
181 |
182 | if (this.defaultUsername.length === 0 || this.defaultPassword.length === 0) {
183 | throw new Error("Username or password empty.")
184 | }
185 | }
186 | }
187 |
188 | /**
189 | * Return all virtual file handles for the passed username.
190 | *
191 | * @public
192 | * @param {?(string)} [username]
193 | * @returns {Record}
194 | */
195 | public getVirtualFilesForUser(username?: string): Record {
196 | if (!username) {
197 | return {}
198 | }
199 |
200 | if (this.virtualFiles[username]) {
201 | return this.virtualFiles[username]!
202 | }
203 |
204 | this.virtualFiles[username] = {}
205 |
206 | return this.virtualFiles[username]!
207 | }
208 |
209 | /**
210 | * Return all temp disk file handles for the passed username.
211 | *
212 | * @public
213 | * @param {?string} [username]
214 | * @returns {Record}
215 | */
216 | public getTempDiskFilesForUser(username?: string): Record {
217 | if (!username) {
218 | return {}
219 | }
220 |
221 | if (this.tempDiskFiles[username]) {
222 | return this.tempDiskFiles[username]!
223 | }
224 |
225 | this.tempDiskFiles[username] = {}
226 |
227 | return this.tempDiskFiles[username]!
228 | }
229 |
230 | /**
231 | * Return the FilenSDK instance for the passed username.
232 | *
233 | * @public
234 | * @param {?(string)} [username]
235 | * @returns {(FilenSDK | null)}
236 | */
237 | public getSDKForUser(username?: string): FilenSDK | null {
238 | if (!username) {
239 | return null
240 | }
241 |
242 | if (this.users[username] && this.users[username]!.sdk) {
243 | return this.users[username]!.sdk!
244 | }
245 |
246 | return null
247 | }
248 |
249 | /**
250 | * Returns a NodeCache instance for each user.
251 | *
252 | * @public
253 | * @param {?string} [username]
254 | * @returns {NodeCache}
255 | */
256 | public getCacheForUser(username?: string): NodeCache {
257 | if (!username) {
258 | return new NodeCache()
259 | }
260 |
261 | if (!this.cache[username]) {
262 | this.cache[username] = new NodeCache()
263 | }
264 |
265 | return this.cache[username]!
266 | }
267 |
268 | /**
269 | * Get the RW mutex for the given username and path.
270 | *
271 | * @public
272 | * @param {string} path
273 | * @param {?string} [username]
274 | * @returns {ISemaphore}
275 | */
276 | public getRWMutexForUser(path: string, username?: string): ISemaphore {
277 | path = removeLastSlash(decodeURIComponent(path))
278 |
279 | if (!username) {
280 | return new Semaphore(1)
281 | }
282 |
283 | if (!this.rwMutex[username]) {
284 | this.rwMutex[username] = {}
285 | }
286 |
287 | if (this.rwMutex[username]![path]) {
288 | return this.rwMutex[username]![path]!
289 | }
290 |
291 | this.rwMutex[username]![path]! = new Semaphore(1)
292 |
293 | return this.rwMutex[username]![path]!
294 | }
295 |
296 | /**
297 | * Get the WebDAV resource of the requested URL.
298 | *
299 | * @public
300 | * @async
301 | * @param {Request} req
302 | * @returns {Promise}
303 | */
304 | public async urlToResource(req: Request): Promise {
305 | const url = decodeURIComponent(req.url)
306 | const path = url === "/" ? url : removeLastSlash(url)
307 |
308 | if (this.getVirtualFilesForUser(req.username)[path]) {
309 | return this.getVirtualFilesForUser(req.username)[path]!
310 | }
311 |
312 | if (this.getTempDiskFilesForUser(req.username)[path]) {
313 | return this.getTempDiskFilesForUser(req.username)[path]!
314 | }
315 |
316 | const sdk = this.getSDKForUser(req.username)
317 |
318 | if (!sdk) {
319 | return null
320 | }
321 |
322 | try {
323 | const stat = await sdk.fs().stat({ path })
324 |
325 | return {
326 | ...stat,
327 | url: `${path}${stat.type === "directory" && stat.uuid !== sdk.config.baseFolderUUID ? "/" : ""}`,
328 | path,
329 | isVirtual: false
330 | }
331 | } catch {
332 | return null
333 | }
334 | }
335 |
336 | /**
337 | * Convert a FilenSDK style path to a WebDAV resource.
338 | *
339 | * @public
340 | * @async
341 | * @param {Request} req
342 | * @param {string} path
343 | * @returns {Promise}
344 | */
345 | public async pathToResource(req: Request, path: string): Promise {
346 | if (this.getVirtualFilesForUser(req.username)[path]) {
347 | return this.getVirtualFilesForUser(req.username)[path]!
348 | }
349 |
350 | if (this.getTempDiskFilesForUser(req.username)[path]) {
351 | return this.getTempDiskFilesForUser(req.username)[path]!
352 | }
353 |
354 | const sdk = this.getSDKForUser(req.username)
355 |
356 | if (!sdk) {
357 | return null
358 | }
359 |
360 | try {
361 | const stat = await sdk.fs().stat({ path: path === "/" ? path : removeLastSlash(path) })
362 |
363 | return {
364 | ...stat,
365 | url: `${path}${stat.type === "directory" && stat.uuid !== sdk.config.baseFolderUUID ? "/" : ""}`,
366 | path,
367 | isVirtual: false
368 | }
369 | } catch {
370 | return null
371 | }
372 | }
373 |
374 | /**
375 | * Start the server.
376 | *
377 | * @public
378 | * @async
379 | * @returns {Promise}
380 | */
381 | public async start(): Promise {
382 | this.connections = {}
383 |
384 | this.server.disable("x-powered-by")
385 |
386 | this.server.use(
387 | rateLimit({
388 | windowMs: this.rateLimit.windowMs,
389 | limit: this.rateLimit.limit,
390 | standardHeaders: "draft-7",
391 | legacyHeaders: true,
392 | keyGenerator: req => {
393 | if (this.rateLimit.key === "ip") {
394 | return req.ip ?? "ip"
395 | }
396 |
397 | if (this.authMode === "digest") {
398 | const authHeader = req.headers["authorization"]
399 |
400 | if (!authHeader || !authHeader.startsWith("Digest ")) {
401 | return req.ip ?? "ip"
402 | }
403 |
404 | const authParams = parseDigestAuthHeader(authHeader.slice(7))
405 | const username = authParams.username
406 |
407 | if (!username || !authParams.response) {
408 | return req.ip ?? "ip"
409 | }
410 |
411 | return username
412 | } else {
413 | const authHeader = req.headers["authorization"]
414 |
415 | if (!authHeader || !authHeader.startsWith("Basic ")) {
416 | return req.ip ?? "ip"
417 | }
418 |
419 | const base64Credentials = authHeader.split(" ")[1]
420 |
421 | if (!base64Credentials) {
422 | return req.ip ?? "ip"
423 | }
424 |
425 | const credentials = Buffer.from(base64Credentials, "base64").toString("utf-8")
426 | const [username, password] = credentials.split(":")
427 |
428 | if (!username || !password) {
429 | return req.ip ?? "ip"
430 | }
431 |
432 | return username
433 | }
434 | }
435 | })
436 | )
437 |
438 | this.server.use(new Auth(this).handle)
439 |
440 | this.server.use((_, res, next) => {
441 | res.set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK")
442 | res.set("DAV", "1, 2")
443 | res.set("Access-Control-Allow-Origin", "*")
444 | res.set("Access-Control-Allow-Credentials", "true")
445 | res.set("Access-Control-Expose-Headers", "DAV, content-length, Allow")
446 | res.set("MS-Author-Via", "DAV")
447 | res.set("Server", "Filen WebDAV")
448 | res.set("Cache-Control", "no-cache")
449 |
450 | next()
451 | })
452 |
453 | this.server.use((req, res, next) => {
454 | const method = req.method.toUpperCase()
455 |
456 | if (method === "POST" || method === "PUT") {
457 | body(req, res, next)
458 |
459 | return
460 | }
461 |
462 | bodyParser.text({
463 | type: ["application/xml", "text/xml"]
464 | })(req, res, next)
465 | })
466 |
467 | this.server.head("*", new Head(this).handle)
468 | this.server.get("*", new Get(this).handle)
469 | this.server.options("*", new Options(this).handle)
470 | this.server.propfind("*", new Propfind(this).handle)
471 | this.server.put("*", new Put(this).handle)
472 | this.server.post("*", new Put(this).handle)
473 | this.server.mkcol("*", new Mkcol(this).handle)
474 | this.server.delete("*", new Delete(this).handle)
475 | this.server.copy("*", new Copy(this).handle)
476 | this.server.lock("*", new Lock(this).handle)
477 | this.server.unlock("*", new Unlock(this).handle)
478 | this.server.proppatch("*", new Proppatch(this).handle)
479 | this.server.move("*", new Move(this).handle)
480 |
481 | this.server.use(Errors)
482 |
483 | await fs.emptyDir(this.tempDiskPath)
484 |
485 | await new Promise((resolve, reject) => {
486 | if (this.enableHTTPS) {
487 | Certs.get()
488 | .then(certs => {
489 | this.serverInstance = https
490 | .createServer(
491 | {
492 | cert: certs.cert,
493 | key: certs.privateKey
494 | },
495 | this.server
496 | )
497 | .listen(this.serverConfig.port, this.serverConfig.hostname, () => {
498 | this.serverInstance!.setTimeout(86400000 * 7)
499 | this.serverInstance!.timeout = 86400000 * 7
500 | this.serverInstance!.keepAliveTimeout = 86400000 * 7
501 | this.serverInstance!.headersTimeout = 86400000 * 7 * 2
502 |
503 | resolve()
504 | })
505 | .on("connection", socket => {
506 | const socketId = uuidv4()
507 |
508 | this.connections[socketId] = socket
509 |
510 | socket.once("close", () => {
511 | delete this.connections[socketId]
512 | })
513 | })
514 | })
515 | .catch(reject)
516 | } else {
517 | this.serverInstance = http
518 | .createServer(this.server)
519 | .listen(this.serverConfig.port, this.serverConfig.hostname, () => {
520 | this.serverInstance!.setTimeout(86400000 * 7)
521 | this.serverInstance!.timeout = 86400000 * 7
522 | this.serverInstance!.keepAliveTimeout = 86400000 * 7
523 | this.serverInstance!.headersTimeout = 86400000 * 7 * 2
524 |
525 | resolve()
526 | })
527 | .on("connection", socket => {
528 | const socketId = uuidv4()
529 |
530 | this.connections[socketId] = socket
531 |
532 | socket.once("close", () => {
533 | delete this.connections[socketId]
534 | })
535 | })
536 | }
537 | })
538 | }
539 |
540 | /**
541 | * Stop the server.
542 | *
543 | * @public
544 | * @async
545 | * @param {boolean} [terminate=false]
546 | * @returns {Promise}
547 | */
548 | public async stop(terminate: boolean = false): Promise {
549 | await new Promise((resolve, reject) => {
550 | if (!this.serverInstance) {
551 | resolve()
552 |
553 | return
554 | }
555 |
556 | this.serverInstance.close(err => {
557 | if (err) {
558 | reject(err)
559 |
560 | return
561 | }
562 |
563 | resolve()
564 | })
565 |
566 | if (terminate) {
567 | for (const socketId in this.connections) {
568 | try {
569 | this.connections[socketId]?.destroy()
570 |
571 | delete this.connections[socketId]
572 | } catch {
573 | // Noop
574 | }
575 | }
576 | }
577 | })
578 | }
579 | }
580 |
581 | /**
582 | * WebDAVServerCluster
583 | *
584 | * @export
585 | * @class WebDAVServerCluster
586 | * @typedef {WebDAVServerCluster}
587 | */
588 | export class WebDAVServerCluster {
589 | private enableHTTPS: boolean
590 | private authMode: AuthMode
591 | private rateLimit: RateLimit
592 | private serverConfig: ServerConfig
593 | private proxyMode: boolean
594 | private user:
595 | | {
596 | sdkConfig?: FilenSDKConfig
597 | sdk?: FilenSDK
598 | username: string
599 | password: string
600 | }
601 | | undefined
602 | private threads: number
603 | private workers: Record<
604 | number,
605 | {
606 | worker: ReturnType
607 | ready: boolean
608 | }
609 | > = {}
610 | private stopSpawning: boolean = false
611 | private tempFilesToStoreOnDisk: string[]
612 |
613 | /**
614 | * Creates an instance of WebDAVServerCluster.
615 | *
616 | * @constructor
617 | * @public
618 | * @param {{
619 | * hostname?: string
620 | * port?: number
621 | * authMode?: "basic" | "digest"
622 | * https?: boolean
623 | * user?: {
624 | * sdkConfig?: FilenSDKConfig
625 | * sdk?: FilenSDK
626 | * username: string
627 | * password: string
628 | * }
629 | * rateLimit?: RateLimit
630 | * disableLogging?: boolean
631 | * threads?: number
632 | * tempFilesToStoreOnDisk?: string[]
633 | * }} param0
634 | * @param {string} [param0.hostname="127.0.0.1"]
635 | * @param {number} [param0.port=1900]
636 | * @param {{ sdkConfig?: FilenSDKConfig; sdk?: FilenSDK; username: string; password: string; }} param0.user
637 | * @param {("basic" | "digest")} [param0.authMode="basic"]
638 | * @param {boolean} [param0.https=false]
639 | * @param {RateLimit} [param0.rateLimit={
640 | * windowMs: 1000,
641 | * limit: 1000,
642 | * key: "username"
643 | * }]
644 | * @param {number} param0.threads
645 | * @param {{}} [param0.tempFilesToStoreOnDisk=[]] Glob patterns of files that should not be uploaded to the cloud. Files matching the pattern will be served locally.
646 | */
647 | public constructor({
648 | hostname = "127.0.0.1",
649 | port = 1900,
650 | user,
651 | authMode = "basic",
652 | https = false,
653 | rateLimit = {
654 | windowMs: 1000,
655 | limit: 1000,
656 | key: "username"
657 | },
658 | threads,
659 | tempFilesToStoreOnDisk = []
660 | }: {
661 | hostname?: string
662 | port?: number
663 | authMode?: "basic" | "digest"
664 | https?: boolean
665 | user?: {
666 | sdkConfig?: FilenSDKConfig
667 | sdk?: FilenSDK
668 | username: string
669 | password: string
670 | }
671 | rateLimit?: RateLimit
672 | disableLogging?: boolean
673 | threads?: number
674 | tempFilesToStoreOnDisk?: string[]
675 | }) {
676 | this.enableHTTPS = https
677 | this.authMode = authMode
678 | this.rateLimit = rateLimit
679 | this.serverConfig = {
680 | hostname,
681 | port
682 | }
683 | this.proxyMode = typeof user === "undefined"
684 | this.threads = typeof threads === "number" ? threads : os.cpus().length
685 | this.user = user
686 | this.tempFilesToStoreOnDisk = tempFilesToStoreOnDisk
687 |
688 | if (this.proxyMode && this.authMode === "digest") {
689 | throw new Error("Digest authentication is not supported in proxy mode.")
690 | }
691 |
692 | if (this.user) {
693 | if (!this.user.sdk && !this.user.sdkConfig) {
694 | throw new Error("Either pass a configured SDK instance OR a SDKConfig object to the user object.")
695 | }
696 |
697 | if (this.user.username.length === 0 || this.user.password.length === 0) {
698 | throw new Error("Username or password empty.")
699 | }
700 | }
701 | }
702 |
703 | /**
704 | * Spawn a worker.
705 | *
706 | * @private
707 | */
708 | private spawnWorker(): void {
709 | if (this.stopSpawning) {
710 | return
711 | }
712 |
713 | const worker = cluster.fork()
714 |
715 | this.workers[worker.id] = {
716 | worker,
717 | ready: false
718 | }
719 | }
720 |
721 | /**
722 | * Fork all needed threads.
723 | *
724 | * @private
725 | * @async
726 | * @returns {Promise<"master" | "worker">}
727 | */
728 | private async startCluster(): Promise<"master" | "worker"> {
729 | if (cluster.isPrimary) {
730 | return await new Promise<"master" | "worker">((resolve, reject) => {
731 | try {
732 | let workersReady = 0
733 |
734 | for (let i = 0; i < this.threads; i++) {
735 | this.spawnWorker()
736 | }
737 |
738 | cluster.on("exit", async worker => {
739 | if (workersReady < this.threads) {
740 | return
741 | }
742 |
743 | workersReady--
744 |
745 | delete this.workers[worker.id]
746 |
747 | await new Promise(resolve => setTimeout(resolve, 1000))
748 |
749 | try {
750 | this.spawnWorker()
751 | } catch {
752 | // Noop
753 | }
754 | })
755 |
756 | const errorTimeout = setTimeout(() => {
757 | reject(new Error("Could not spawn all workers."))
758 | }, 15000)
759 |
760 | cluster.on("message", (worker, message) => {
761 | if (message === "ready" && this.workers[worker.id]) {
762 | workersReady++
763 |
764 | this.workers[worker.id]!.ready = true
765 |
766 | if (workersReady >= this.threads) {
767 | clearTimeout(errorTimeout)
768 |
769 | resolve("master")
770 | }
771 | }
772 | })
773 | } catch (e) {
774 | reject(e)
775 | }
776 | })
777 | }
778 |
779 | const server = new WebDAVServer({
780 | hostname: this.serverConfig.hostname,
781 | port: this.serverConfig.port,
782 | authMode: this.authMode,
783 | disableLogging: true,
784 | user: this.user,
785 | rateLimit: this.rateLimit,
786 | https: this.enableHTTPS,
787 | tempFilesToStoreOnDisk: this.tempFilesToStoreOnDisk
788 | })
789 |
790 | await server.start()
791 |
792 | if (process.send) {
793 | process.send("ready")
794 | }
795 |
796 | return "worker"
797 | }
798 |
799 | /**
800 | * Start the WebDAV cluster.
801 | *
802 | * @public
803 | * @async
804 | * @returns {Promise}
805 | */
806 | public async start(): Promise {
807 | await new Promise((resolve, reject) => {
808 | this.startCluster()
809 | .then(type => {
810 | if (type === "master") {
811 | resolve()
812 | }
813 | })
814 | .catch(reject)
815 | })
816 | }
817 |
818 | /**
819 | * Stop the WebDAV cluster.
820 | *
821 | * @public
822 | * @async
823 | * @returns {Promise}
824 | */
825 | public async stop(): Promise {
826 | cluster.removeAllListeners()
827 |
828 | this.stopSpawning = true
829 |
830 | for (const id in this.workers) {
831 | this.workers[id]!.worker.destroy()
832 | }
833 |
834 | await new Promise(resolve => setTimeout(resolve, 1000))
835 |
836 | this.workers = {}
837 | this.stopSpawning = false
838 | }
839 | }
840 |
841 | export default WebDAVServer
842 |
--------------------------------------------------------------------------------
/src/logger.ts:
--------------------------------------------------------------------------------
1 | import pathModule from "path"
2 | import pino, { type Logger as PinoLogger } from "pino"
3 | import os from "os"
4 | import fs from "fs-extra"
5 | import { createStream } from "rotating-file-stream"
6 |
7 | export async function filenLogsPath(): Promise {
8 | let configPath = ""
9 |
10 | switch (process.platform) {
11 | case "win32":
12 | configPath = pathModule.resolve(process.env.APPDATA!)
13 |
14 | break
15 | case "darwin":
16 | configPath = pathModule.resolve(pathModule.join(os.homedir(), "Library/Application Support/"))
17 |
18 | break
19 | default:
20 | configPath = process.env.XDG_CONFIG_HOME
21 | ? pathModule.resolve(process.env.XDG_CONFIG_HOME)
22 | : pathModule.resolve(pathModule.join(os.homedir(), ".config/"))
23 |
24 | break
25 | }
26 |
27 | if (!configPath || configPath.length === 0) {
28 | throw new Error("Could not find homedir path.")
29 | }
30 |
31 | configPath = pathModule.join(configPath, "@filen", "logs")
32 |
33 | if (!(await fs.exists(configPath))) {
34 | await fs.mkdir(configPath, {
35 | recursive: true
36 | })
37 | }
38 |
39 | return configPath
40 | }
41 |
42 | export class Logger {
43 | private logger: PinoLogger | null = null
44 | private dest: string | null = null
45 | private isCleaning: boolean = false
46 | private readonly disableLogging: boolean
47 | private readonly isWorker: boolean
48 |
49 | public constructor(disableLogging: boolean = false, isWorker: boolean = false) {
50 | this.disableLogging = disableLogging
51 | this.isWorker = isWorker
52 |
53 | this.init()
54 | }
55 |
56 | public async init(): Promise {
57 | try {
58 | this.dest = pathModule.join(await filenLogsPath(), this.isWorker ? "webdav-worker.log" : "webdav.log")
59 |
60 | this.logger = pino(
61 | createStream(pathModule.basename(this.dest), {
62 | size: "10M",
63 | interval: "7d",
64 | compress: "gzip",
65 | encoding: "utf-8",
66 | maxFiles: 3,
67 | path: pathModule.dirname(this.dest)
68 | })
69 | )
70 | } catch (e) {
71 | console.error(e)
72 | }
73 | }
74 |
75 | public async waitForPino(): Promise {
76 | if (this.logger) {
77 | return
78 | }
79 |
80 | await new Promise(resolve => {
81 | const wait = setInterval(() => {
82 | if (this.logger) {
83 | clearInterval(wait)
84 |
85 | resolve()
86 | }
87 | }, 100)
88 | })
89 | }
90 |
91 | public log(level: "info" | "debug" | "warn" | "error" | "trace" | "fatal", object?: unknown, where?: string): void {
92 | if (this.isCleaning || this.disableLogging) {
93 | return
94 | }
95 |
96 | // eslint-disable-next-line no-extra-semi
97 | ;(async () => {
98 | try {
99 | if (!this.logger) {
100 | await this.waitForPino()
101 | }
102 |
103 | const log = `${where ? `[${where}] ` : ""}${
104 | typeof object !== "undefined"
105 | ? typeof object === "string" || typeof object === "number"
106 | ? object
107 | : JSON.stringify(object)
108 | : ""
109 | }`
110 |
111 | if (level === "info") {
112 | this.logger?.info(log)
113 | } else if (level === "debug") {
114 | this.logger?.debug(log)
115 | } else if (level === "error") {
116 | this.logger?.error(log)
117 |
118 | if (object instanceof Error) {
119 | this.logger?.error(object)
120 | }
121 | } else if (level === "warn") {
122 | this.logger?.warn(log)
123 | } else if (level === "trace") {
124 | this.logger?.trace(log)
125 | } else if (level === "fatal") {
126 | this.logger?.fatal(log)
127 | } else {
128 | this.logger?.info(log)
129 | }
130 | } catch (e) {
131 | console.error(e)
132 | }
133 | })()
134 | }
135 | }
136 |
137 | export default Logger
138 |
--------------------------------------------------------------------------------
/src/middlewares/auth.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response, type NextFunction } from "express"
2 | import type Server from ".."
3 | import FilenSDK, { type SocketEvent } from "@filen/sdk"
4 | import { Semaphore, ISemaphore } from "../semaphore"
5 | import crypto from "crypto"
6 |
7 | export const REALM = "Default realm"
8 | export const BASIC_AUTH_HEADER = `Basic realm="${REALM}", charset="UTF-8"`
9 |
10 | export function parseDigestAuthHeader(header: string): Record {
11 | const params: Record = {}
12 |
13 | header.split(",").forEach(param => {
14 | let [key, value] = param.split("=")
15 |
16 | if (key && value) {
17 | key = key.split(" ").join("")
18 | value = value.split(" ").join("")
19 |
20 | // eslint-disable-next-line quotes
21 | params[key] = value.split('"').join("")
22 | }
23 | })
24 |
25 | return params
26 | }
27 |
28 | /**
29 | * Auth
30 | *
31 | * @export
32 | * @class Auth
33 | * @typedef {Auth}
34 | */
35 | export class Auth {
36 | public readonly authedFilenUsers: Record = {}
37 | private readonly mutex: Record = {}
38 |
39 | /**
40 | * Creates an instance of Auth.
41 | *
42 | * @constructor
43 | * @public
44 | * @param {Server} server
45 | */
46 | public constructor(private readonly server: Server) {
47 | this.handle = this.handle.bind(this)
48 | }
49 |
50 | /**
51 | * Generate a random 16 byte hex string used as a nonce or opaque.
52 | *
53 | * @public
54 | * @returns {string}
55 | */
56 | public generateNonce(): string {
57 | return crypto.randomBytes(16).toString("hex")
58 | }
59 |
60 | /**
61 | * Returns the appropriate auth header based on the chosen auth mode.
62 | *
63 | * @public
64 | * @returns {string}
65 | */
66 | public authHeader(): string {
67 | if (this.server.authMode === "digest") {
68 | return `Digest realm="${REALM}", qop="auth", nonce="${this.generateNonce()}", opaque="${this.generateNonce()}"`
69 | }
70 |
71 | return `Basic realm="${REALM}", charset="UTF-8"`
72 | }
73 |
74 | /**
75 | * Filen based authentication. Only used in proxy mode.
76 | *
77 | * @public
78 | * @async
79 | * @param {Request} req
80 | * @param {string} username
81 | * @param {string} password
82 | * @returns {Promise}
83 | */
84 | public async filenAuth(req: Request, username: string, password: string): Promise {
85 | if (!this.mutex[username]) {
86 | this.mutex[username] = new Semaphore(1)
87 | }
88 |
89 | await this.mutex[username]!.acquire()
90 |
91 | try {
92 | if (this.authedFilenUsers[username] && this.authedFilenUsers[username] === password) {
93 | req.username = username
94 |
95 | return
96 | }
97 |
98 | let parsedPassword: string | null = null
99 | let parsedTwoFactorCode: string | undefined = undefined
100 |
101 | if (!password.startsWith("password=")) {
102 | parsedPassword = password
103 | } else {
104 | const passwordEx = password.split("&twoFactorAuthentication=")
105 |
106 | if (!passwordEx[0] || !passwordEx[0].startsWith("password=")) {
107 | throw new Error("Credentials wrongly formatted.")
108 | }
109 |
110 | parsedPassword = passwordEx[0]!.slice("password=".length)
111 |
112 | if (passwordEx.length >= 2) {
113 | const twoFactor = passwordEx[1]
114 |
115 | if (twoFactor && twoFactor.length >= 6) {
116 | parsedTwoFactorCode = twoFactor
117 | }
118 | }
119 | }
120 |
121 | if (!parsedPassword) {
122 | throw new Error("Could not parse password.")
123 | }
124 |
125 | this.server.users[username] = {
126 | sdk: new FilenSDK({
127 | connectToSocket: true,
128 | metadataCache: true
129 | }),
130 | username,
131 | password: parsedPassword
132 | }
133 |
134 | const sdk = this.server.getSDKForUser(username)
135 |
136 | if (!sdk) {
137 | throw new Error("Could not find SDK for user.")
138 | }
139 |
140 | try {
141 | await sdk.login({
142 | email: username,
143 | password: parsedPassword,
144 | twoFactorCode: parsedTwoFactorCode
145 | })
146 | } catch (e) {
147 | delete this.server.users[username]
148 |
149 | throw e
150 | }
151 |
152 | sdk.socket.on("socketEvent", (event: SocketEvent) => {
153 | if (event.type === "passwordChanged") {
154 | delete this.server.users[username]
155 | delete this.authedFilenUsers[username]
156 | delete this.server.virtualFiles[username]
157 | delete this.server.tempDiskFiles[username]
158 | }
159 | })
160 |
161 | this.authedFilenUsers[username] = password
162 |
163 | req.username = username
164 | } finally {
165 | this.mutex[username]!.release()
166 | }
167 | }
168 |
169 | /**
170 | * Default auth based on predefined username/password.
171 | *
172 | * @public
173 | * @async
174 | * @param {Request} req
175 | * @param {string} username
176 | * @param {string} password
177 | * @returns {Promise}
178 | */
179 | public async defaultAuth(req: Request, username: string, password: string): Promise {
180 | if (username === this.server.defaultUsername && password === this.server.defaultPassword) {
181 | req.username = this.server.defaultUsername
182 |
183 | return
184 | }
185 |
186 | throw new Error("Invalid credentials.")
187 | }
188 |
189 | /**
190 | * Basic auth handling. Switches to Filen auth when it parses valid username/password combination and the server is set to proxy mode.
191 | *
192 | * @public
193 | * @async
194 | * @param {Request} req
195 | * @returns {Promise}
196 | */
197 | public async basic(req: Request): Promise {
198 | const authHeader = req.headers["authorization"]
199 |
200 | if (!authHeader || !authHeader.startsWith("Basic ")) {
201 | throw new Error("No authorization header provided.")
202 | }
203 |
204 | const base64Credentials = authHeader.split(" ")[1]
205 |
206 | if (!base64Credentials) {
207 | throw new Error("Invalid authorization header provided.")
208 | }
209 |
210 | const credentials = Buffer.from(base64Credentials, "base64").toString("utf-8")
211 | const [username, password] = credentials.split(":")
212 |
213 | if (!username || !password) {
214 | throw new Error("No credentials provided.")
215 | }
216 |
217 | if (username.includes("@") && password.startsWith("password=") && this.server.proxyMode) {
218 | await this.filenAuth(req, username, password)
219 |
220 | return
221 | }
222 |
223 | await this.defaultAuth(req, username, password)
224 | }
225 |
226 | /**
227 | * Digest auth handling.
228 | *
229 | * @public
230 | * @async
231 | * @param {Request} req
232 | * @returns {Promise}
233 | */
234 | public async digest(req: Request): Promise {
235 | const authHeader = req.headers["authorization"]
236 |
237 | if (!authHeader || !authHeader.startsWith("Digest ")) {
238 | throw new Error("No authorization header provided.")
239 | }
240 |
241 | const authParams = parseDigestAuthHeader(authHeader.slice(7))
242 | const username = authParams.username
243 |
244 | if (!username || !authParams.response) {
245 | throw new Error("Invalid header provided.")
246 | }
247 |
248 | const ha1 = crypto.createHash("md5").update(`${username}:${REALM}:${this.server.defaultPassword}`).digest("hex")
249 | const ha2 = crypto.createHash("md5").update(`${req.method}:${authParams.uri}`).digest("hex")
250 | const response = crypto
251 | .createHash("md5")
252 | .update(`${ha1}:${authParams.nonce}:${authParams.nc}:${authParams.cnonce}:${authParams.qop}:${ha2}`)
253 | .digest("hex")
254 |
255 | if (response !== authParams.response) {
256 | throw new Error("Invalid credentials.")
257 | }
258 |
259 | req.username = this.server.defaultUsername
260 | }
261 |
262 | /**
263 | * Handle auth.
264 | *
265 | * @public
266 | * @param {Request} req
267 | * @param {Response} res
268 | * @param {NextFunction} next
269 | */
270 | public handle(req: Request, res: Response, next: NextFunction): void {
271 | const authHeader = req.headers["authorization"]
272 |
273 | if (!authHeader) {
274 | res.set("WWW-Authenticate", this.authHeader())
275 | res.status(401).end("Unauthorized")
276 |
277 | return
278 | }
279 |
280 | if (this.server.authMode === "digest") {
281 | this.digest(req)
282 | .then(() => {
283 | next()
284 | })
285 | .catch(err => {
286 | this.server.logger.log("error", err, "auth.digest")
287 | this.server.logger.log("error", err)
288 |
289 | res.set("WWW-Authenticate", this.authHeader())
290 | res.status(401).end("Unauthorized")
291 | })
292 | } else {
293 | this.basic(req)
294 | .then(() => {
295 | next()
296 | })
297 | .catch(err => {
298 | this.server.logger.log("error", err, "auth.basic")
299 | this.server.logger.log("error", err)
300 |
301 | res.set("WWW-Authenticate", this.authHeader())
302 | res.status(401).end("Unauthorized")
303 | })
304 | }
305 | }
306 | }
307 |
308 | export default Auth
309 |
--------------------------------------------------------------------------------
/src/middlewares/body.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response, type NextFunction } from "express"
2 | import Responses from "../responses"
3 |
4 | export default function body(req: Request, res: Response, next: NextFunction): void {
5 | if (!["POST", "PUT"].includes(req.method)) {
6 | next()
7 |
8 | return
9 | }
10 |
11 | req.once("readable", () => {
12 | try {
13 | const chunk = req.read(1)
14 |
15 | req.firstBodyChunk = chunk instanceof Buffer ? chunk : null
16 |
17 | next()
18 | } catch {
19 | Responses.internalError(res).catch(() => {})
20 | }
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/src/middlewares/errors.ts:
--------------------------------------------------------------------------------
1 | import { type ErrorRequestHandler, type Request, type Response } from "express"
2 |
3 | /**
4 | * WebDAVError
5 | *
6 | * @export
7 | * @class WebDAVError
8 | * @typedef {WebDAVError}
9 | * @extends {Error}
10 | */
11 | export class WebDAVError extends Error {
12 | public errno: number
13 | public code: number
14 |
15 | /**
16 | * Creates an instance of WebDAVError.
17 | *
18 | * @constructor
19 | * @public
20 | * @param {number} code
21 | * @param {string} message
22 | */
23 | public constructor(code: number, message: string) {
24 | super(message)
25 |
26 | this.name = "WebDAVError"
27 | this.code = code
28 | this.errno = code
29 | }
30 | }
31 |
32 | /**
33 | * Error handling middleware.
34 | *
35 | * @param {Error} err
36 | * @param {Request} req
37 | * @param {Response} res
38 | * @returns {void}
39 | */
40 | export const Errors: ErrorRequestHandler = (err: Error, req: Request, res: Response): void => {
41 | if (res.headersSent) {
42 | return
43 | }
44 |
45 | res.status(err instanceof WebDAVError ? err.code : 500)
46 | res.set("Content-Length", "0")
47 | res.end("Internal server error")
48 | }
49 |
50 | export default Errors
51 |
--------------------------------------------------------------------------------
/src/responses.ts:
--------------------------------------------------------------------------------
1 | import { type Response } from "express"
2 | import { Builder } from "xml2js"
3 | import { type Resource } from "."
4 | import mimeTypes from "mime-types"
5 |
6 | /**
7 | * Responses
8 | *
9 | * @export
10 | * @class Responses
11 | * @typedef {Responses}
12 | */
13 | export class Responses {
14 | public static readonly xmlBuilder = new Builder({
15 | xmldec: {
16 | version: "1.0",
17 | encoding: "utf-8"
18 | }
19 | })
20 |
21 | public static async propfind(res: Response, resources: Resource[], quota: { used: number; available: number }): Promise {
22 | if (res.headersSent) {
23 | return
24 | }
25 |
26 | const response = this.xmlBuilder.buildObject({
27 | "D:multistatus": {
28 | $: {
29 | "xmlns:D": "DAV:"
30 | },
31 | "D:response": resources.map(resource => {
32 | const lastModified = new Date(resource.mtimeMs).toUTCString()
33 | const creationDate = new Date(resource.birthtimeMs).toISOString()
34 |
35 | return {
36 | "D:href": `${resource.url
37 | .split("/")
38 | .map(part => encodeURIComponent(part))
39 | .join("/")}`,
40 | ["D:propstat"]: {
41 | "D:prop": {
42 | "D:getlastmodified": lastModified,
43 | "D:lastmodified": lastModified,
44 | "D:displayname": encodeURIComponent(resource.name),
45 | "D:getcontentlength": resource.type === "directory" ? 0 : resource.size,
46 | "D:getetag": resource.uuid,
47 | "D:creationdate": creationDate,
48 | "D:getcreationdate": creationDate,
49 | "D:quota-available-bytes": quota.available.toString(),
50 | "D:quota-used-bytes": quota.used.toString(),
51 | "D:getcontenttype":
52 | resource.type === "directory"
53 | ? "httpd/unix-directory"
54 | : mimeTypes.lookup(resource.name) || "application/octet-stream",
55 | "D:resourcetype":
56 | resource.type === "directory"
57 | ? {
58 | "D:collection": ""
59 | }
60 | : {
61 | "D:file": ""
62 | }
63 | },
64 | "D:status": "HTTP/1.1 200 OK"
65 | }
66 | }
67 | })
68 | }
69 | })
70 |
71 | res.set("Content-Type", "application/xml; charset=utf-8")
72 | res.set("Content-Length", Buffer.from(response, "utf-8").byteLength.toString())
73 | res.status(207)
74 |
75 | await new Promise(resolve => {
76 | res.end(response, () => {
77 | resolve()
78 | })
79 | })
80 | }
81 |
82 | public static async proppatch(res: Response, url: string, propsSet?: string[]): Promise {
83 | if (res.headersSent) {
84 | return
85 | }
86 |
87 | const response = this.xmlBuilder.buildObject({
88 | "D:multistatus": {
89 | $: {
90 | "xmlns:D": "DAV:"
91 | },
92 | "D:response": {
93 | "D:href": `${url
94 | .split("/")
95 | .map(part => encodeURIComponent(part))
96 | .join("/")}`,
97 | ["D:propstat"]: {
98 | "D:prop": propsSet
99 | ? propsSet.reduce(
100 | (prev, curr) => ({
101 | ...prev,
102 | [curr.startsWith("d:") ? curr.split("d:").join("D:") : `D:${curr}`]: {}
103 | }),
104 | {}
105 | )
106 | : {},
107 | "D:status": "HTTP/1.1 207 Multi-Status"
108 | }
109 | }
110 | }
111 | })
112 |
113 | res.set("Content-Type", "application/xml; charset=utf-8")
114 | res.set("Content-Length", Buffer.from(response, "utf-8").byteLength.toString())
115 | res.status(207)
116 |
117 | await new Promise(resolve => {
118 | res.end(response, () => {
119 | resolve()
120 | })
121 | })
122 | }
123 |
124 | public static async notFound(res: Response, url: string): Promise {
125 | if (res.headersSent) {
126 | return
127 | }
128 |
129 | const response = this.xmlBuilder.buildObject({
130 | "D:multistatus": {
131 | $: {
132 | "xmlns:D": "DAV:"
133 | },
134 | "D:response": {
135 | "D:href": `${url
136 | .split("/")
137 | .map(part => encodeURIComponent(part))
138 | .join("/")}`,
139 | ["D:propstat"]: {
140 | "D:prop": {},
141 | "D:status": "HTTP/1.1 404 NOT FOUND"
142 | }
143 | }
144 | }
145 | })
146 |
147 | res.set("Content-Type", "application/xml; charset=utf-8")
148 | res.set("Content-Length", Buffer.from(response, "utf-8").byteLength.toString())
149 | res.status(404)
150 |
151 | await new Promise(resolve => {
152 | res.end(response, () => {
153 | resolve()
154 | })
155 | })
156 | }
157 |
158 | public static async badRequest(res: Response): Promise {
159 | if (res.headersSent) {
160 | return
161 | }
162 |
163 | res.set("Content-Length", "0")
164 | res.status(400)
165 |
166 | await new Promise(resolve => {
167 | res.end(() => {
168 | resolve()
169 | })
170 | })
171 | }
172 |
173 | public static async alreadyExists(res: Response): Promise {
174 | if (res.headersSent) {
175 | return
176 | }
177 |
178 | res.set("Content-Length", "0")
179 | res.status(403)
180 |
181 | await new Promise(resolve => {
182 | res.end(() => {
183 | resolve()
184 | })
185 | })
186 | }
187 |
188 | public static async created(res: Response): Promise {
189 | if (res.headersSent) {
190 | return
191 | }
192 |
193 | res.set("Content-Length", "0")
194 | res.status(201)
195 |
196 | await new Promise(resolve => {
197 | res.end(() => {
198 | resolve()
199 | })
200 | })
201 | }
202 |
203 | public static async ok(res: Response): Promise {
204 | if (res.headersSent) {
205 | return
206 | }
207 |
208 | res.set("Content-Length", "0")
209 | res.status(200)
210 |
211 | await new Promise(resolve => {
212 | res.end(() => {
213 | resolve()
214 | })
215 | })
216 | }
217 |
218 | public static async noContent(res: Response): Promise {
219 | if (res.headersSent) {
220 | return
221 | }
222 |
223 | res.set("Content-Length", "0")
224 | res.status(204)
225 |
226 | await new Promise(resolve => {
227 | res.end(() => {
228 | resolve()
229 | })
230 | })
231 | }
232 |
233 | public static async notImplemented(res: Response): Promise {
234 | if (res.headersSent) {
235 | return
236 | }
237 |
238 | res.set("Content-Length", "0")
239 | res.status(501)
240 |
241 | await new Promise(resolve => {
242 | res.end(() => {
243 | resolve()
244 | })
245 | })
246 | }
247 |
248 | public static async forbidden(res: Response): Promise {
249 | if (res.headersSent) {
250 | return
251 | }
252 |
253 | res.set("Content-Length", "0")
254 | res.status(403)
255 |
256 | await new Promise(resolve => {
257 | res.end(() => {
258 | resolve()
259 | })
260 | })
261 | }
262 |
263 | public static async internalError(res: Response): Promise {
264 | if (res.headersSent) {
265 | return
266 | }
267 |
268 | res.set("Content-Length", "0")
269 | res.status(500)
270 |
271 | await new Promise(resolve => {
272 | res.end(() => {
273 | resolve()
274 | })
275 | })
276 | }
277 |
278 | public static async notAuthorized(res: Response): Promise {
279 | if (res.headersSent) {
280 | return
281 | }
282 |
283 | res.set("Content-Length", "0")
284 | res.status(401)
285 |
286 | await new Promise(resolve => {
287 | res.end(() => {
288 | resolve()
289 | })
290 | })
291 | }
292 |
293 | public static async preconditionFailed(res: Response): Promise {
294 | if (res.headersSent) {
295 | return
296 | }
297 |
298 | res.set("Content-Length", "0")
299 | res.status(412)
300 |
301 | await new Promise(resolve => {
302 | res.end(() => {
303 | resolve()
304 | })
305 | })
306 | }
307 | }
308 |
309 | export default Responses
310 |
--------------------------------------------------------------------------------
/src/semaphore.ts:
--------------------------------------------------------------------------------
1 | export interface ISemaphore {
2 | acquire(): Promise
3 | release(): void
4 | count(): number
5 | setMax(newMax: number): void
6 | purge(): number
7 | }
8 |
9 | /**
10 | * Basic Semaphore implementation.
11 | * @date 2/15/2024 - 4:52:51 AM
12 | *
13 | * @type {new (max: number) => ISemaphore}
14 | */
15 | export const Semaphore = function (this: ISemaphore, max: number) {
16 | let counter = 0
17 | let waiting: { resolve: (value: void | PromiseLike) => void; err: (reason?: unknown) => void }[] = []
18 | let maxCount = max || 1
19 |
20 | const take = function (): void {
21 | if (waiting.length > 0 && counter < maxCount) {
22 | counter++
23 |
24 | const promise = waiting.shift()
25 |
26 | if (!promise) {
27 | return
28 | }
29 |
30 | promise.resolve()
31 | }
32 | }
33 |
34 | this.acquire = function (): Promise {
35 | if (counter < maxCount) {
36 | counter++
37 |
38 | return new Promise(resolve => {
39 | resolve()
40 | })
41 | } else {
42 | return new Promise((resolve, err) => {
43 | waiting.push({
44 | resolve: resolve,
45 | err: err
46 | })
47 | })
48 | }
49 | }
50 |
51 | this.release = function (): void {
52 | if (counter <= 0) {
53 | return
54 | }
55 |
56 | counter--
57 |
58 | take()
59 | }
60 |
61 | this.count = function (): number {
62 | return counter
63 | }
64 |
65 | this.setMax = function (newMax: number): void {
66 | maxCount = newMax
67 | }
68 |
69 | this.purge = function (): number {
70 | const unresolved = waiting.length
71 |
72 | for (let i = 0; i < unresolved; i++) {
73 | const w = waiting[i]
74 |
75 | if (!w) {
76 | continue
77 | }
78 |
79 | w.err("Task has been purged")
80 | }
81 |
82 | counter = 0
83 | waiting = []
84 |
85 | return unresolved
86 | }
87 | } as unknown as { new (max: number): ISemaphore }
88 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import pathModule from "path"
2 | import fs from "fs-extra"
3 | import os from "os"
4 | import { xxHash32 } from "js-xxhash"
5 |
6 | /**
7 | * Chunk large Promise.all executions.
8 | * @date 2/14/2024 - 11:59:34 PM
9 | *
10 | * @export
11 | * @async
12 | * @template T
13 | * @param {Promise[]} promises
14 | * @param {number} [chunkSize=10000]
15 | * @returns {Promise}
16 | */
17 | export async function promiseAllChunked(promises: Promise[], chunkSize = 10000): Promise {
18 | const results: T[] = []
19 |
20 | for (let i = 0; i < promises.length; i += chunkSize) {
21 | const chunkResults = await Promise.all(promises.slice(i, i + chunkSize))
22 |
23 | results.push(...chunkResults)
24 | }
25 |
26 | return results
27 | }
28 |
29 | export function removeLastSlash(str: string): string {
30 | return str.endsWith("/") ? str.substring(0, str.length - 1) : str
31 | }
32 |
33 | /**
34 | * Parse the requested byte range from the header.
35 | *
36 | * @export
37 | * @param {string} range
38 | * @param {number} totalLength
39 | * @returns {({ start: number; end: number } | null)}
40 | */
41 | export function parseByteRange(range: string, totalLength: number): { start: number; end: number } | null {
42 | const [unit, rangeValue] = range.split("=")
43 |
44 | if (unit !== "bytes" || !rangeValue) {
45 | return null
46 | }
47 |
48 | const [startStr, endStr] = rangeValue.split("-")
49 |
50 | if (!startStr) {
51 | return null
52 | }
53 |
54 | const start = parseInt(startStr, 10)
55 | const end = endStr ? parseInt(endStr, 10) : totalLength - 1
56 |
57 | if (isNaN(start) || isNaN(end) || start < 0 || end >= totalLength || start > end) {
58 | return null
59 | }
60 |
61 | return {
62 | start,
63 | end
64 | }
65 | }
66 |
67 | /**
68 | * Return the platforms config path.
69 | *
70 | * @export
71 | * @returns {string}
72 | */
73 | export function platformConfigPath(): string {
74 | // Ref: https://github.com/FilenCloudDienste/filen-cli/blob/main/src/util.ts
75 |
76 | let configPath = ""
77 |
78 | switch (process.platform) {
79 | case "win32":
80 | configPath = pathModule.resolve(process.env.APPDATA!)
81 | break
82 | case "darwin":
83 | configPath = pathModule.resolve(pathModule.join(os.homedir(), "Library/Application Support/"))
84 | break
85 | default:
86 | configPath = process.env.XDG_CONFIG_HOME
87 | ? pathModule.resolve(process.env.XDG_CONFIG_HOME)
88 | : pathModule.resolve(pathModule.join(os.homedir(), ".config/"))
89 | break
90 | }
91 |
92 | if (!configPath || configPath.length === 0) {
93 | throw new Error("Could not find homedir path.")
94 | }
95 |
96 | configPath = pathModule.join(configPath, "@filen", "webdav")
97 |
98 | if (!fs.existsSync(configPath)) {
99 | fs.mkdirSync(configPath, {
100 | recursive: true
101 | })
102 | }
103 |
104 | return configPath
105 | }
106 |
107 | export function tempDiskPath(): string {
108 | const path = pathModule.join(platformConfigPath(), "tempDiskFiles")
109 |
110 | if (!fs.existsSync(path)) {
111 | fs.mkdirSync(path, {
112 | recursive: true
113 | })
114 | }
115 |
116 | return path
117 | }
118 |
119 | const reservedWindowsNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
120 | // eslint-disable-next-line no-control-regex
121 | const invalidChars = /[<>:"/\\|?*\x00-\x1F]/g
122 |
123 | export function sanitizeFileName(fileName: string): string {
124 | const sanitized = fileName.replace(invalidChars, "").replace(/\.+$/, "").replace(/\s+/g, "_").slice(0, 255)
125 |
126 | if (reservedWindowsNames.test(sanitized)) {
127 | return "_" + sanitized
128 | }
129 |
130 | return sanitized
131 | }
132 |
133 | export function fastStringHash(input: string): string {
134 | return input.substring(0, 8) + xxHash32(input, 0).toString(16) + input.substring(input.length - 8, input.length)
135 | }
136 |
137 | export function pathToTempDiskFileId(path: string, username?: string): string {
138 | return sanitizeFileName(fastStringHash(username ? username + "_" + path : path))
139 | }
140 |
141 | export function isValidDate(date: string): boolean {
142 | try {
143 | const d = new Date(date) as unknown as string
144 |
145 | return d !== "Invalid Date"
146 | } catch {
147 | return false
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": "src",
4 | "outDir": "dist",
5 | "lib": ["ES2017", "DOM", "DOM.Iterable"],
6 | "module": "commonjs",
7 | "target": "ES2017",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "moduleResolution": "node",
12 | "sourceMap": true,
13 | "skipDefaultLibCheck": true,
14 | "skipLibCheck": true,
15 | "declaration": true,
16 | "resolveJsonModule": true,
17 | "noUncheckedIndexedAccess": true
18 | },
19 | "ts-node": {
20 | "files": true
21 | },
22 | "include": ["src", "types.d.ts"],
23 | "exclude": ["node_modules", "dist", "__tests__", "docs", ".github", "dev", ".vscode"]
24 | }
25 |
--------------------------------------------------------------------------------
/types.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | namespace Express {
3 | interface Request {
4 | username?: string
5 | firstBodyChunk?: Buffer | null
6 | }
7 | }
8 | }
9 |
10 | export {}
11 |
--------------------------------------------------------------------------------