ajout app
This commit is contained in:
1
Voltaserve/webdav/.dockerignore
Normal file
1
Voltaserve/webdav/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
10
Voltaserve/webdav/.editorconfig
Normal file
10
Voltaserve/webdav/.editorconfig
Normal file
@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,json,yml}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
3
Voltaserve/webdav/.env
Normal file
3
Voltaserve/webdav/.env
Normal file
@ -0,0 +1,3 @@
|
||||
PORT=6000
|
||||
IDP_URL="http://127.0.0.1:7000"
|
||||
API_URL="http://127.0.0.1:5000"
|
12
Voltaserve/webdav/.eslintrc.json
Normal file
12
Voltaserve/webdav/.eslintrc.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
}
|
4
Voltaserve/webdav/.gitattributes
vendored
Normal file
4
Voltaserve/webdav/.gitattributes
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
2
Voltaserve/webdav/.gitignore
vendored
Normal file
2
Voltaserve/webdav/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env.local
|
12
Voltaserve/webdav/.prettierrc.json
Normal file
12
Voltaserve/webdav/.prettierrc.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"quoteProps": "preserve",
|
||||
"importOrder": [
|
||||
"^@/infra/env$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": false
|
||||
}
|
3
Voltaserve/webdav/.vscode/extensions.json
vendored
Normal file
3
Voltaserve/webdav/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
18
Voltaserve/webdav/Dockerfile
Normal file
18
Voltaserve/webdav/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM registry.suse.com/bci/nodejs:18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY src ./src
|
||||
COPY .env .
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY tsconfig.json .
|
||||
|
||||
RUN npm install -g corepack
|
||||
RUN corepack enable
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
ENTRYPOINT ["pnpm", "run", "start"]
|
||||
|
||||
EXPOSE 6000
|
25
Voltaserve/webdav/README.md
Normal file
25
Voltaserve/webdav/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Voltaserve WebDAV
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```shell
|
||||
bun i
|
||||
```
|
||||
|
||||
Run for development:
|
||||
|
||||
```shell
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Run for production:
|
||||
|
||||
```shell
|
||||
bun run start
|
||||
```
|
||||
|
||||
Build Docker image:
|
||||
|
||||
```shell
|
||||
docker build -t voltaserve/webdav .
|
||||
```
|
BIN
Voltaserve/webdav/bun.lockb
Normal file
BIN
Voltaserve/webdav/bun.lockb
Normal file
Binary file not shown.
37
Voltaserve/webdav/package.json
Normal file
37
Voltaserve/webdav/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "voltaserve-webdav",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun src/server.ts",
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"tsc": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint './src/**/*.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "16.4.5",
|
||||
"passport": "0.7.0",
|
||||
"passport-http": "0.3.0",
|
||||
"uuid": "9.0.1",
|
||||
"xml2js": "0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "4.3.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "20.12.7",
|
||||
"@types/passport": "1.0.16",
|
||||
"@types/passport-http": "0.3.11",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"@typescript-eslint/eslint-plugin": "7.7.0",
|
||||
"@typescript-eslint/parser": "7.7.0",
|
||||
"eslint": "9.0.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"prettier": "3.2.5",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
299
Voltaserve/webdav/src/client/api.ts
Normal file
299
Voltaserve/webdav/src/client/api.ts
Normal file
@ -0,0 +1,299 @@
|
||||
import { API_URL } from '@/config'
|
||||
import { Token } from './idp'
|
||||
import { get } from 'http'
|
||||
import { createWriteStream, unlink } from 'fs'
|
||||
|
||||
export type APIErrorResponse = {
|
||||
code: string
|
||||
status: number
|
||||
message: string
|
||||
userMessage: string
|
||||
moreInfo: string
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(readonly error: APIErrorResponse) {
|
||||
super(JSON.stringify(error, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
export enum FileType {
|
||||
File = 'file',
|
||||
Folder = 'folder',
|
||||
}
|
||||
|
||||
export type File = {
|
||||
id: string
|
||||
workspaceId: string
|
||||
name: string
|
||||
type: FileType
|
||||
parentId: string
|
||||
version: number
|
||||
original?: Download
|
||||
preview?: Download
|
||||
thumbnail?: Thumbnail
|
||||
snapshots: Snapshot[]
|
||||
permission: PermissionType
|
||||
isShared: boolean
|
||||
createTime: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export type PermissionType = 'viewer' | 'editor' | 'owner'
|
||||
|
||||
export type Snapshot = {
|
||||
version: number
|
||||
original: Download
|
||||
preview?: Download
|
||||
ocr?: Download
|
||||
text?: Download
|
||||
thumbnail?: Thumbnail
|
||||
}
|
||||
|
||||
export type Download = {
|
||||
extension: string
|
||||
size: number
|
||||
image: ImageProps | undefined
|
||||
}
|
||||
|
||||
export type ImageProps = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type Thumbnail = {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type FileCopyOptions = {
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
export type FileRenameOptions = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type FileCreateFolderOptions = {
|
||||
workspaceId: string
|
||||
name: string
|
||||
parentId: string
|
||||
}
|
||||
|
||||
export type FileUploadOptions = {
|
||||
workspaceId: string
|
||||
parentId: string | null
|
||||
blob: Blob
|
||||
name: string
|
||||
}
|
||||
|
||||
export type FileMoveOptions = {
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
export class FileAPI {
|
||||
constructor(private token: Token) {}
|
||||
|
||||
private async jsonResponseOrThrow<T>(response: Response): Promise<T> {
|
||||
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||
const json = await response.json()
|
||||
if (response.status > 299) {
|
||||
throw new APIError(json)
|
||||
}
|
||||
return json
|
||||
} else {
|
||||
if (response.status > 299) {
|
||||
throw new Error(response.statusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async upload(options: FileUploadOptions): Promise<void> {
|
||||
const params = new URLSearchParams({
|
||||
workspace_id: options.workspaceId,
|
||||
})
|
||||
if (options.parentId) {
|
||||
params.append('parent_id', options.parentId)
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.set('file', options.blob, options.name)
|
||||
const response = await fetch(`${API_URL}/v1/files?${params}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async getByPath(path: string): Promise<File> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/files/get?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async listByPath(path: string): Promise<File[]> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/files/list?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async createFolder(options: FileCreateFolderOptions): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/v1/files/create_folder`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: options.workspaceId,
|
||||
parentId: options.parentId,
|
||||
name: options.name,
|
||||
}),
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async copy(id: string, options: FileCopyOptions): Promise<File[]> {
|
||||
const response = await fetch(`${API_URL}/v1/files/${id}/copy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ids: options.ids,
|
||||
}),
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async move(id: string, options: FileMoveOptions): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/v1/files/${id}/move`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ids: options.ids,
|
||||
}),
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async rename(id: string, options: FileRenameOptions): Promise<File> {
|
||||
const response = await fetch(`${API_URL}/v1/files/${id}/rename`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: options.name,
|
||||
}),
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/v1/files/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
downloadOriginal(file: File, outputPath: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = createWriteStream(outputPath)
|
||||
const request = get(
|
||||
`${API_URL}/v1/files/${file.id}/original${file.original.extension}?access_token=${this.token.access_token}`,
|
||||
(response) => {
|
||||
response.pipe(ws)
|
||||
ws.on('finish', () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
)
|
||||
request.on('error', (error) => {
|
||||
unlink(outputPath, () => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const VIEWER_PERMISSION = 'viewer'
|
||||
export const EDITOR_PERMISSION = 'editor'
|
||||
export const OWNER_PERMISSION = 'owner'
|
||||
|
||||
export function geViewerPermission(permission: string): boolean {
|
||||
return (
|
||||
getPermissionWeight(permission) >= getPermissionWeight(VIEWER_PERMISSION)
|
||||
)
|
||||
}
|
||||
|
||||
export function geEditorPermission(permission: string) {
|
||||
return (
|
||||
getPermissionWeight(permission) >= getPermissionWeight(EDITOR_PERMISSION)
|
||||
)
|
||||
}
|
||||
|
||||
export function geOwnerPermission(permission: string) {
|
||||
return (
|
||||
getPermissionWeight(permission) >= getPermissionWeight(OWNER_PERMISSION)
|
||||
)
|
||||
}
|
||||
|
||||
export function ltViewerPermission(permission: string): boolean {
|
||||
return (
|
||||
getPermissionWeight(permission) < getPermissionWeight(VIEWER_PERMISSION)
|
||||
)
|
||||
}
|
||||
|
||||
export function ltEditorPermission(permission: string) {
|
||||
return (
|
||||
getPermissionWeight(permission) < getPermissionWeight(EDITOR_PERMISSION)
|
||||
)
|
||||
}
|
||||
|
||||
export function ltOwnerPermission(permission: string) {
|
||||
return getPermissionWeight(permission) < getPermissionWeight(OWNER_PERMISSION)
|
||||
}
|
||||
|
||||
export function getPermissionWeight(permission: string) {
|
||||
switch (permission) {
|
||||
case VIEWER_PERMISSION:
|
||||
return 1
|
||||
case EDITOR_PERMISSION:
|
||||
return 2
|
||||
case OWNER_PERMISSION:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
66
Voltaserve/webdav/src/client/idp.ts
Normal file
66
Voltaserve/webdav/src/client/idp.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { IDP_URL } from '@/config'
|
||||
|
||||
export type IdPErrorResponse = {
|
||||
code: string
|
||||
status: number
|
||||
message: string
|
||||
userMessage: string
|
||||
moreInfo: string
|
||||
}
|
||||
|
||||
export class IdPError extends Error {
|
||||
constructor(readonly error: IdPErrorResponse) {
|
||||
super(JSON.stringify(error, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export type TokenGrantType = 'password' | 'refresh_token'
|
||||
|
||||
export type TokenExchangeOptions = {
|
||||
grant_type: TokenGrantType
|
||||
username?: string
|
||||
password?: string
|
||||
refresh_token?: string
|
||||
locale?: string
|
||||
}
|
||||
|
||||
export class TokenAPI {
|
||||
async exchange(options: TokenExchangeOptions): Promise<Token> {
|
||||
const formBody = []
|
||||
formBody.push(`grant_type=${options.grant_type}`)
|
||||
formBody.push(`username=${encodeURIComponent(options.username)}`)
|
||||
formBody.push(`password=${encodeURIComponent(options.password)}`)
|
||||
if (options.refresh_token) {
|
||||
formBody.push(`refresh_token=${options.refresh_token}`)
|
||||
}
|
||||
const response = await fetch(`${IDP_URL}/v1/token`, {
|
||||
method: 'POST',
|
||||
body: formBody.join('&'),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
})
|
||||
return this.jsonResponseOrThrow(response)
|
||||
}
|
||||
|
||||
private async jsonResponseOrThrow<T>(response: Response): Promise<T> {
|
||||
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||
const json = await response.json()
|
||||
if (response.status > 299) {
|
||||
throw new IdPError(json)
|
||||
}
|
||||
return json
|
||||
} else {
|
||||
if (response.status > 299) {
|
||||
throw new Error(response.statusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
Voltaserve/webdav/src/config/index.ts
Normal file
3
Voltaserve/webdav/src/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const PORT = process.env.PORT
|
||||
export const IDP_URL = process.env.IDP_URL
|
||||
export const API_URL = process.env.API_URL
|
48
Voltaserve/webdav/src/handler/handle-copy.ts
Normal file
48
Voltaserve/webdav/src/handler/handle-copy.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import path from 'path'
|
||||
import { getTargetPath } from '@/helper/path'
|
||||
import { FileAPI } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method copies a resource from a source URL to a destination URL.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the source and destination paths from the headers or request body.
|
||||
- Use fs.copyFile() to copy the file from the source to the destination.
|
||||
- Set the response status code to 204 if successful or an appropriate error code if the source file is not found or encountered an error.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleCopy(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
try {
|
||||
const api = new FileAPI(token)
|
||||
const sourceFile = await api.getByPath(decodeURIComponent(req.url))
|
||||
const targetFile = await api.getByPath(
|
||||
decodeURIComponent(path.dirname(getTargetPath(req)))
|
||||
)
|
||||
|
||||
if (sourceFile.workspaceId !== targetFile.workspaceId) {
|
||||
res.statusCode = 400
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const clones = await api.copy(targetFile.id, { ids: [sourceFile.id] })
|
||||
await api.rename(clones[0].id, {
|
||||
name: decodeURIComponent(path.basename(getTargetPath(req))),
|
||||
})
|
||||
|
||||
res.statusCode = 204
|
||||
res.end()
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handleCopy
|
34
Voltaserve/webdav/src/handler/handle-delete.ts
Normal file
34
Voltaserve/webdav/src/handler/handle-delete.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { FileAPI } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method deletes a resource identified by the URL.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the file path from the URL.
|
||||
- Use fs.unlink() to delete the file.
|
||||
- Set the response status code to 204 if successful or an appropriate error code if the file is not found.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleDelete(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
try {
|
||||
const api = new FileAPI(token)
|
||||
const file = await api.getByPath(decodeURIComponent(req.url))
|
||||
|
||||
await api.delete(file.id)
|
||||
|
||||
res.statusCode = 204
|
||||
res.end()
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handleDelete
|
68
Voltaserve/webdav/src/handler/handle-get.ts
Normal file
68
Voltaserve/webdav/src/handler/handle-get.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import fs, { createReadStream, rmSync } from 'fs'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { FileAPI } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method retrieves the content of a resource identified by the URL.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the file path from the URL.
|
||||
- Create a read stream from the file and pipe it to the response stream.
|
||||
- Set the response status code to 200 if successful or an appropriate error code if the file is not found.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleGet(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
try {
|
||||
const api = new FileAPI(token)
|
||||
const file = await api.getByPath(decodeURIComponent(req.url))
|
||||
|
||||
/* TODO: This should be optimized for the case when there is a range header,
|
||||
only a partial file should be fetched, here we are fetching the whole file
|
||||
which is not ideal. */
|
||||
const outputPath = path.join(os.tmpdir(), uuidv4())
|
||||
await api.downloadOriginal(file, outputPath)
|
||||
|
||||
const stat = fs.statSync(outputPath)
|
||||
const rangeHeader = req.headers.range
|
||||
if (rangeHeader) {
|
||||
const [start, end] = rangeHeader.replace(/bytes=/, '').split('-')
|
||||
const rangeStart = parseInt(start, 10) || 0
|
||||
const rangeEnd = parseInt(end, 10) || stat.size - 1
|
||||
const chunkSize = rangeEnd - rangeStart + 1
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${rangeStart}-${rangeEnd}/${stat.size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
})
|
||||
createReadStream(outputPath, {
|
||||
start: rangeStart,
|
||||
end: rangeEnd,
|
||||
})
|
||||
.pipe(res)
|
||||
.on('finish', () => rmSync(outputPath))
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stat.size.toString(),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
})
|
||||
createReadStream(outputPath)
|
||||
.pipe(res)
|
||||
.on('finish', () => rmSync(outputPath))
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handleGet
|
37
Voltaserve/webdav/src/handler/handle-head.ts
Normal file
37
Voltaserve/webdav/src/handler/handle-head.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { FileAPI, FileType } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method is similar to GET but only retrieves the metadata of a resource, without returning the actual content.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the file path from the URL.
|
||||
- Retrieve the file metadata using fs.stat().
|
||||
- Set the response status code to 200 if successful or an appropriate error code if the file is not found.
|
||||
- Set the Content-Length header with the file size.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleHead(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
try {
|
||||
const file = await new FileAPI(token).getByPath(decodeURIComponent(req.url))
|
||||
if (file.type === FileType.File) {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Length', file.original.size)
|
||||
res.end()
|
||||
} else {
|
||||
res.statusCode = 200
|
||||
res.end()
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handleHead
|
39
Voltaserve/webdav/src/handler/handle-mkcol.ts
Normal file
39
Voltaserve/webdav/src/handler/handle-mkcol.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import path from 'path'
|
||||
import { File, FileAPI } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method creates a new collection (directory) at the specified URL.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the directory path from the URL.
|
||||
- Use fs.mkdir() to create the directory.
|
||||
- Set the response status code to 201 if created or an appropriate error code if the directory already exists or encountered an error.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleMkcol(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
let directory: File
|
||||
try {
|
||||
const api = new FileAPI(token)
|
||||
directory = await api.getByPath(decodeURIComponent(path.dirname(req.url)))
|
||||
await api.createFolder({
|
||||
workspaceId: directory.workspaceId,
|
||||
parentId: directory.id,
|
||||
name: decodeURIComponent(path.basename(req.url)),
|
||||
})
|
||||
|
||||
res.statusCode = 201
|
||||
res.end()
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handleMkcol
|
57
Voltaserve/webdav/src/handler/handle-move.ts
Normal file
57
Voltaserve/webdav/src/handler/handle-move.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import path from 'path'
|
||||
import { FileAPI } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { getTargetPath } from '@/helper/path'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method moves or renames a resource from a source URL to a destination URL.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the source and destination paths from the headers or request body.
|
||||
- Use fs.rename() to move or rename the file from the source to the destination.
|
||||
- Set the response status code to 204 if successful or an appropriate error code if the source file is not found or encountered an error.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleMove(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
try {
|
||||
const sourcePath = decodeURIComponent(req.url)
|
||||
const targetPath = decodeURIComponent(getTargetPath(req))
|
||||
|
||||
const api = new FileAPI(token)
|
||||
const sourceFile = await api.getByPath(decodeURIComponent(req.url))
|
||||
const targetFile = await api.getByPath(
|
||||
decodeURIComponent(path.dirname(getTargetPath(req)))
|
||||
)
|
||||
|
||||
if (sourceFile.workspaceId !== targetFile.workspaceId) {
|
||||
res.statusCode = 400
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
sourcePath.split('/').length === targetPath.split('/').length &&
|
||||
path.dirname(sourcePath) === path.dirname(targetPath)
|
||||
) {
|
||||
await api.rename(sourceFile.id, {
|
||||
name: decodeURIComponent(path.basename(targetPath)),
|
||||
})
|
||||
} else {
|
||||
await api.move(targetFile.id, { ids: [sourceFile.id] })
|
||||
}
|
||||
|
||||
res.statusCode = 204
|
||||
res.end()
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handleMove
|
21
Voltaserve/webdav/src/handler/handle-options.ts
Normal file
21
Voltaserve/webdav/src/handler/handle-options.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
/*
|
||||
This method should respond with the allowed methods and capabilities of the server.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Set the response status code to 200.
|
||||
- Set the Allow header to specify the supported methods, such as OPTIONS, GET, PUT, DELETE, etc.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handleOptions(_: IncomingMessage, res: ServerResponse) {
|
||||
res.statusCode = 200
|
||||
res.setHeader(
|
||||
'Allow',
|
||||
'OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH'
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
|
||||
export default handleOptions
|
111
Voltaserve/webdav/src/handler/handle-propfind.ts
Normal file
111
Voltaserve/webdav/src/handler/handle-propfind.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { FileAPI, FileType } from '@/client/api'
|
||||
import { Token } from '@/client/idp'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method retrieves properties and metadata of a resource.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the file path from the URL.
|
||||
- Use fs.stat() to retrieve the file metadata.
|
||||
- Format the response body in the desired XML format with the properties and metadata.
|
||||
- Set the response status code to 207 if successful or an appropriate error code if the file is not found or encountered an error.
|
||||
- Set the Content-Type header to indicate the XML format.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handlePropfind(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
try {
|
||||
const api = new FileAPI(token)
|
||||
const file = await api.getByPath(decodeURIComponent(req.url))
|
||||
if (file.type === FileType.File) {
|
||||
const responseXml = `
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>${encodeURIComponent(file.name)}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype></D:resourcetype>
|
||||
${
|
||||
file.original
|
||||
? `<D:getcontentlength>${file.original.size}</D:getcontentlength>`
|
||||
: ''
|
||||
}
|
||||
<D:creationdate>${new Date(
|
||||
file.createTime
|
||||
).toUTCString()}</D:creationdate>
|
||||
<D:getlastmodified>${new Date(
|
||||
file.updateTime
|
||||
).toUTCString()}</D:getlastmodified>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>`
|
||||
res.statusCode = 207
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8')
|
||||
res.end(responseXml)
|
||||
} else if (file.type === FileType.Folder) {
|
||||
const list = await api.listByPath(decodeURIComponent(req.url))
|
||||
const responseXml = `
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>${req.url}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:getcontentlength>0</D:getcontentlength>
|
||||
<D:getlastmodified>${new Date(
|
||||
file.updateTime
|
||||
).toUTCString()}</D:getlastmodified>
|
||||
<D:creationdate>${new Date(
|
||||
file.createTime
|
||||
).toUTCString()}</D:creationdate>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
${list
|
||||
.map((item) => {
|
||||
return `
|
||||
<D:response>
|
||||
<D:href>${req.url}${encodeURIComponent(item.name)}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype>${
|
||||
item.type === FileType.Folder ? '<D:collection/>' : ''
|
||||
}</D:resourcetype>
|
||||
${
|
||||
item.type === FileType.File && item.original
|
||||
? `<D:getcontentlength>${item.original.size}</D:getcontentlength>`
|
||||
: ''
|
||||
}
|
||||
<D:getlastmodified>${new Date(
|
||||
item.updateTime
|
||||
).toUTCString()}</D:getlastmodified>
|
||||
<D:creationdate>${new Date(
|
||||
item.createTime
|
||||
).toUTCString()}</D:creationdate>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
`
|
||||
})
|
||||
.join('')}
|
||||
</D:multistatus>`
|
||||
res.statusCode = 207
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8')
|
||||
res.end(responseXml)
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handlePropfind
|
34
Voltaserve/webdav/src/handler/handle-proppatch.ts
Normal file
34
Voltaserve/webdav/src/handler/handle-proppatch.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
/*
|
||||
This method updates the properties of a resource.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Parse the request body to extract the properties to be updated.
|
||||
- Read the existing data from the file.
|
||||
- Parse the existing properties.
|
||||
- Merge the updated properties with the existing ones.
|
||||
- Format the updated properties and store them back in the file.
|
||||
- Set the response status code to 204 if successful or an appropriate error code if the file is not found or encountered an error.
|
||||
- Return the response.
|
||||
|
||||
In this example implementation, the handleProppatch() method first parses the XML
|
||||
payload containing the properties to be updated. Then, it reads the existing data from the file,
|
||||
parses the existing properties (assuming an XML format),
|
||||
merges the updated properties with the existing ones, and formats
|
||||
the properties back into the desired format (e.g., XML).
|
||||
|
||||
Finally, the updated properties are written back to the file.
|
||||
You can customize the parseProperties() and formatProperties()
|
||||
functions to match the specific property format you are using in your WebDAV server.
|
||||
|
||||
Note that this implementation assumes a simplified example and may require further
|
||||
customization based on your specific property format and requirements.
|
||||
*/
|
||||
async function handleProppatch(_: IncomingMessage, res: ServerResponse) {
|
||||
res.statusCode = 501
|
||||
res.end()
|
||||
}
|
||||
|
||||
export default handleProppatch
|
71
Voltaserve/webdav/src/handler/handle-put.ts
Normal file
71
Voltaserve/webdav/src/handler/handle-put.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import fs from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Token } from '@/client/idp'
|
||||
import { File, FileAPI } from '@/client/api'
|
||||
import { handleError } from '@/infra/error'
|
||||
|
||||
/*
|
||||
This method creates or updates a resource with the provided content.
|
||||
|
||||
Example implementation:
|
||||
|
||||
- Extract the file path from the URL.
|
||||
- Create a write stream to the file.
|
||||
- Listen for the data event to write the incoming data to the file.
|
||||
- Listen for the end event to indicate the completion of the write stream.
|
||||
- Set the response status code to 201 if created or 204 if updated.
|
||||
- Return the response.
|
||||
*/
|
||||
async function handlePut(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
token: Token
|
||||
) {
|
||||
const api = new FileAPI(token)
|
||||
try {
|
||||
const directory = await api.getByPath(
|
||||
decodeURIComponent(path.dirname(req.url))
|
||||
)
|
||||
const outputPath = path.join(os.tmpdir(), uuidv4())
|
||||
const ws = fs.createWriteStream(outputPath)
|
||||
req.pipe(ws)
|
||||
ws.on('error', (err) => {
|
||||
console.error(err)
|
||||
res.statusCode = 500
|
||||
res.end()
|
||||
})
|
||||
ws.on('finish', async () => {
|
||||
try {
|
||||
res.statusCode = 201
|
||||
res.end()
|
||||
const blob = new Blob([await readFile(outputPath)])
|
||||
let existingFile: File | null = null
|
||||
try {
|
||||
existingFile = await api.getByPath(decodeURIComponent(req.url))
|
||||
// Delete existing file to simulate an overwrite
|
||||
await api.delete(existingFile.id)
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
await api.upload({
|
||||
workspaceId: directory.workspaceId,
|
||||
parentId: directory.id,
|
||||
name: decodeURIComponent(path.basename(req.url)),
|
||||
blob,
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
} finally {
|
||||
fs.rmSync(outputPath)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
export default handlePut
|
17
Voltaserve/webdav/src/helper/path.ts
Normal file
17
Voltaserve/webdav/src/helper/path.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IncomingMessage } from 'http'
|
||||
|
||||
export function getTargetPath(req: IncomingMessage) {
|
||||
const destination = req.headers.destination as string
|
||||
if (!destination) {
|
||||
return null
|
||||
}
|
||||
/* Check if the destination header is a full URL */
|
||||
if (destination.startsWith('http://') || destination.startsWith('https://')) {
|
||||
return new URL(destination).pathname
|
||||
} else {
|
||||
/* Extract the path from the destination header */
|
||||
const startIndex =
|
||||
destination.indexOf(req.headers.host) + req.headers.host.length
|
||||
return destination.substring(startIndex)
|
||||
}
|
||||
}
|
7
Voltaserve/webdav/src/helper/token.ts
Normal file
7
Voltaserve/webdav/src/helper/token.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Token } from '@/client/idp'
|
||||
|
||||
export function newExpiry(token: Token): Date {
|
||||
const now = new Date()
|
||||
now.setSeconds(now.getSeconds() + token.expires_in)
|
||||
return now
|
||||
}
|
8
Voltaserve/webdav/src/infra/env.ts
Normal file
8
Voltaserve/webdav/src/infra/env.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
|
||||
if (fs.existsSync('.env.local')) {
|
||||
dotenv.config({ path: '.env.local' })
|
||||
} else {
|
||||
dotenv.config()
|
||||
}
|
19
Voltaserve/webdav/src/infra/error.ts
Normal file
19
Voltaserve/webdav/src/infra/error.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { APIError } from '@/client/api'
|
||||
import { IdPError } from '@/client/idp'
|
||||
import { ServerResponse } from 'http'
|
||||
|
||||
export function handleError(err: any, res: ServerResponse) {
|
||||
console.error(err)
|
||||
if (err instanceof APIError) {
|
||||
res.statusCode = err.error.status
|
||||
res.statusMessage = err.error.userMessage
|
||||
res.end()
|
||||
} else if (err instanceof IdPError) {
|
||||
res.statusCode = err.error.status
|
||||
res.statusMessage = err.error.userMessage
|
||||
res.end()
|
||||
} else if (err instanceof Error) {
|
||||
res.statusCode = 500
|
||||
res.end()
|
||||
}
|
||||
}
|
118
Voltaserve/webdav/src/server.ts
Normal file
118
Voltaserve/webdav/src/server.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import '@/infra/env'
|
||||
import { createServer, IncomingMessage, ServerResponse } from 'http'
|
||||
import passport from 'passport'
|
||||
import { BasicStrategy } from 'passport-http'
|
||||
import { TokenAPI, Token } from '@/client/idp'
|
||||
import { PORT } from '@/config'
|
||||
import handleCopy from '@/handler/handle-copy'
|
||||
import handleDelete from '@/handler/handle-delete'
|
||||
import handleGet from '@/handler/handle-get'
|
||||
import handleHead from '@/handler/handle-head'
|
||||
import handleMkcol from '@/handler/handle-mkcol'
|
||||
import handleMove from '@/handler/handle-move'
|
||||
import handleOptions from '@/handler/handle-options'
|
||||
import handlePropfind from '@/handler/handle-propfind'
|
||||
import handleProppatch from '@/handler/handle-proppatch'
|
||||
import handlePut from '@/handler/handle-put'
|
||||
import { newExpiry } from '@/helper/token'
|
||||
|
||||
const tokens = new Map<string, Token>()
|
||||
const expiries = new Map<string, Date>()
|
||||
const api = new TokenAPI()
|
||||
|
||||
/* Refresh tokens */
|
||||
setInterval(async () => {
|
||||
for (const [username, token] of tokens) {
|
||||
const expiry = expiries.get(username)
|
||||
const earlyExpiry = new Date(expiry)
|
||||
earlyExpiry.setMinutes(earlyExpiry.getMinutes() - 1)
|
||||
if (new Date() >= earlyExpiry) {
|
||||
const newToken = await api.exchange({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: token.refresh_token,
|
||||
})
|
||||
tokens.set(username, newToken)
|
||||
expiries.set(username, newExpiry(newToken))
|
||||
}
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
passport.use(
|
||||
new BasicStrategy(async (username, password, done) => {
|
||||
try {
|
||||
let token = tokens.get(username)
|
||||
if (!token) {
|
||||
token = await new TokenAPI().exchange({
|
||||
username,
|
||||
password,
|
||||
grant_type: 'password',
|
||||
})
|
||||
tokens.set(username, token)
|
||||
expiries.set(username, newExpiry(token))
|
||||
}
|
||||
return done(null, token)
|
||||
} catch (err) {
|
||||
return done(err, false)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url === '/v1/health' && req.method === 'GET') {
|
||||
res.statusCode = 200
|
||||
res.end('OK')
|
||||
return
|
||||
}
|
||||
passport.authenticate(
|
||||
'basic',
|
||||
{ session: false },
|
||||
async (err: Error, token: Token) => {
|
||||
if (err || !token) {
|
||||
res.statusCode = 401
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="WebDAV Server"')
|
||||
res.end()
|
||||
} else {
|
||||
const method = req.method
|
||||
switch (method) {
|
||||
case 'OPTIONS':
|
||||
await handleOptions(req, res)
|
||||
break
|
||||
case 'GET':
|
||||
await handleGet(req, res, token)
|
||||
break
|
||||
case 'HEAD':
|
||||
await handleHead(req, res, token)
|
||||
break
|
||||
case 'PUT':
|
||||
await handlePut(req, res, token)
|
||||
break
|
||||
case 'DELETE':
|
||||
await handleDelete(req, res, token)
|
||||
break
|
||||
case 'MKCOL':
|
||||
await handleMkcol(req, res, token)
|
||||
break
|
||||
case 'COPY':
|
||||
await handleCopy(req, res, token)
|
||||
break
|
||||
case 'MOVE':
|
||||
await handleMove(req, res, token)
|
||||
break
|
||||
case 'PROPFIND':
|
||||
await handlePropfind(req, res, token)
|
||||
break
|
||||
case 'PROPPATCH':
|
||||
await handleProppatch(req, res)
|
||||
break
|
||||
default:
|
||||
res.statusCode = 501
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
)(req, res)
|
||||
})
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Listening on port ${PORT}`)
|
||||
})
|
15
Voltaserve/webdav/tsconfig.json
Normal file
15
Voltaserve/webdav/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"target": "es2021",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"outDir": "build",
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user