ajout app

This commit is contained in:
2024-04-17 20:22:30 +02:00
parent cc017cfc5e
commit f9d05a2fd3
8025 changed files with 729805 additions and 0 deletions

View File

@ -0,0 +1 @@
node_modules

View 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
View File

@ -0,0 +1,3 @@
PORT=6000
IDP_URL="http://127.0.0.1:7000"
API_URL="http://127.0.0.1:5000"

View 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
View 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
View File

@ -0,0 +1,2 @@
node_modules
.env.local

View File

@ -0,0 +1,12 @@
{
"singleQuote": true,
"semi": false,
"quoteProps": "preserve",
"importOrder": [
"^@/infra/env$",
"<THIRD_PARTY_MODULES>",
"^@/(.*)$",
"^[./]"
],
"importOrderSeparation": false
}

View File

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

View 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

View 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

Binary file not shown.

View 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"
}
}

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

View 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)
}
}
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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)
}
}

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

View 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()
}

View 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()
}
}

View 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}`)
})

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"module": "commonjs",
"esModuleInterop": true,
"target": "es2021",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "build",
"experimentalDecorators": true
}
}