mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 04:41:40 -08:00
Compare commits
1 Commits
refactor/v
...
feat/dav
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f7b3a86d |
@@ -8322,6 +8322,538 @@
|
||||
"View"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/webdav": {
|
||||
"delete": {
|
||||
"operationId": "handleRootWebDavMethods_delete",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "handleRootWebDavMethods_get",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"head": {
|
||||
"operationId": "handleRootWebDavMethods_head",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"operationId": "handleRootWebDavMethods_options",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "handleRootWebDavMethods_patch",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "handleRootWebDavMethods_post",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "handleRootWebDavMethods_put",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"operationId": "handleRootWebDavMethods_search",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for root resource",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/webdav/{path}": {
|
||||
"delete": {
|
||||
"operationId": "handleCustomMethod_delete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "handleCustomMethod_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"head": {
|
||||
"operationId": "handleCustomMethod_head",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"operationId": "handleCustomMethod_options",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "handleCustomMethod_patch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "handleCustomMethod_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "handleCustomMethod_put",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"operationId": "handleCustomMethod_search",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"207": {
|
||||
"description": "Multi-status response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "WebDAV methods for path resources",
|
||||
"tags": [
|
||||
"WebDAV"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
|
||||
@@ -68,7 +68,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
this.authService.authenticate({
|
||||
headers: client.request.headers,
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, webDavRoute: false, uri: '/api/socket.io' },
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TrashController } from 'src/controllers/trash.controller';
|
||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
import { ViewController } from 'src/controllers/view.controller';
|
||||
import { WebDavController } from 'src/controllers/webdav.controller';
|
||||
|
||||
export const controllers = [
|
||||
APIKeyController,
|
||||
@@ -66,4 +67,5 @@ export const controllers = [
|
||||
UserAdminController,
|
||||
UserController,
|
||||
ViewController,
|
||||
WebDavController,
|
||||
];
|
||||
|
||||
249
server/src/controllers/webdav.controller.ts
Normal file
249
server/src/controllers/webdav.controller.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
All,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Head,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Next,
|
||||
Options,
|
||||
Param,
|
||||
Put,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { WebDavService } from 'src/services/webdav.service';
|
||||
|
||||
@ApiTags('WebDAV')
|
||||
@Controller(RouteKey.WEBDAV)
|
||||
export class WebDavController {
|
||||
constructor(private service: WebDavService) {}
|
||||
|
||||
// Root resource handlers
|
||||
@Get()
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV GET - Retrieve root resource' })
|
||||
@ApiResponse({ status: 200, description: 'Root resource retrieved successfully' })
|
||||
async getRootResource(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
@Next() next: NextFunction,
|
||||
): Promise<void> {
|
||||
await this.service.handleGet(auth, [], request, response, next);
|
||||
}
|
||||
|
||||
@Head()
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV HEAD - Get root resource metadata' })
|
||||
@ApiResponse({ status: 200, description: 'Root resource metadata retrieved' })
|
||||
async headRootResource(@Auth() auth: AuthDto, @Req() request: Request, @Res() response: Response): Promise<void> {
|
||||
await this.service.handleHead(auth, [], request, response);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV PUT - Create or update root resource' })
|
||||
@ApiResponse({ status: 405, description: 'Method not allowed' })
|
||||
@HttpCode(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
putRootResource(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: RawBodyRequest<Request>,
|
||||
@Res() response: Response,
|
||||
@Headers() headers: Record<string, string>,
|
||||
) {
|
||||
this.service.handlePut(auth, [], request, response, headers);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV DELETE - Delete root resource' })
|
||||
@ApiResponse({ status: 405, description: 'Method not allowed' })
|
||||
@HttpCode(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
async deleteRootResource(@Auth() auth: AuthDto, @Req() request: Request, @Res() response: Response): Promise<void> {
|
||||
await this.service.handleDelete(auth, [], request, response);
|
||||
}
|
||||
|
||||
// COPY and MOVE are handled by the @All() decorator for root
|
||||
|
||||
@Get('*path')
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV GET - Retrieve resource' })
|
||||
@ApiResponse({ status: 200, description: 'Resource retrieved successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Resource not found' })
|
||||
async get(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
@Next() next: NextFunction,
|
||||
@Param('path') path: string[],
|
||||
): Promise<void> {
|
||||
await this.service.handleGet(auth, path, request, response, next);
|
||||
}
|
||||
|
||||
@Head('*path')
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV HEAD - Get resource metadata' })
|
||||
@ApiResponse({ status: 200, description: 'Resource metadata retrieved' })
|
||||
@ApiResponse({ status: 404, description: 'Resource not found' })
|
||||
async head(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
@Param('path') path: string[],
|
||||
): Promise<void> {
|
||||
await this.service.handleHead(auth, path, request, response);
|
||||
}
|
||||
|
||||
@Put('*path')
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV PUT - Create or update resource' })
|
||||
@ApiResponse({ status: 201, description: 'Resource created' })
|
||||
@ApiResponse({ status: 204, description: 'Resource updated' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
put(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: RawBodyRequest<Request>,
|
||||
@Res() response: Response,
|
||||
@Param('path') path: string[],
|
||||
@Headers() headers: Record<string, string>,
|
||||
) {
|
||||
this.service.handlePut(auth, path, request, response, headers);
|
||||
}
|
||||
|
||||
@Delete('*path')
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV DELETE - Delete resource' })
|
||||
@ApiResponse({ status: 204, description: 'Resource deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Resource not found' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
@Param('path') path: string[],
|
||||
): Promise<void> {
|
||||
await this.service.handleDelete(auth, path, request, response);
|
||||
}
|
||||
|
||||
// WebDAV OPTIONS handlers
|
||||
@Options()
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV OPTIONS - Get allowed methods for root' })
|
||||
@ApiResponse({ status: 200, description: 'Allowed methods returned' })
|
||||
handleRootOptions(@Req() request: Request, @Res() response: Response): void {
|
||||
this.service.handleOptions(request, response);
|
||||
}
|
||||
|
||||
@Options('*path')
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV OPTIONS - Get allowed methods for path' })
|
||||
@ApiResponse({ status: 200, description: 'Allowed methods returned' })
|
||||
handlePathOptions(@Req() request: Request, @Res() response: Response): void {
|
||||
this.service.handleOptions(request, response);
|
||||
}
|
||||
|
||||
// WebDAV root methods
|
||||
@All()
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV methods for root resource' })
|
||||
@ApiResponse({ status: 200, description: 'Success' })
|
||||
@ApiResponse({ status: 207, description: 'Multi-status response' })
|
||||
async handleRootWebDavMethods(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
@Headers() headers: Record<string, string>,
|
||||
@Body() body: any,
|
||||
): Promise<void> {
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
switch (method) {
|
||||
case 'PROPFIND': {
|
||||
await this.service.handlePropfind(auth, [], request, response, headers, body);
|
||||
break;
|
||||
}
|
||||
case 'PROPPATCH': {
|
||||
this.service.handleProppatch(auth, [], request, response, body);
|
||||
break;
|
||||
}
|
||||
case 'MKCOL': {
|
||||
await this.service.handleMkcol(auth, [], request, response);
|
||||
break;
|
||||
}
|
||||
case 'COPY': {
|
||||
this.service.handleCopy(auth, [], request, response, headers);
|
||||
break;
|
||||
}
|
||||
case 'MOVE': {
|
||||
this.service.handleMove(auth, [], request, response, headers);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Let other methods be handled by specific handlers above
|
||||
response.status(HttpStatus.METHOD_NOT_ALLOWED).end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all WebDAV methods for paths
|
||||
@All('*path')
|
||||
@Authenticated({ webdav: true })
|
||||
@ApiOperation({ summary: 'WebDAV methods for path resources' })
|
||||
@ApiResponse({ status: 200, description: 'Success' })
|
||||
@ApiResponse({ status: 207, description: 'Multi-status response' })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async handleCustomMethod(
|
||||
@Auth() auth: AuthDto,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
@Headers() headers: Record<string, string>,
|
||||
@Body() body: any,
|
||||
@Param('path') path: string[],
|
||||
): Promise<void> {
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
switch (method) {
|
||||
case 'PROPFIND': {
|
||||
await this.service.handlePropfind(auth, path, request, response, headers, body);
|
||||
break;
|
||||
}
|
||||
case 'PROPPATCH': {
|
||||
this.service.handleProppatch(auth, path, request, response, body);
|
||||
break;
|
||||
}
|
||||
case 'LOCK': {
|
||||
this.service.handleLock(auth, path, request, response, headers, body);
|
||||
break;
|
||||
}
|
||||
case 'UNLOCK': {
|
||||
this.service.handleUnlock(auth, path, request, response, headers);
|
||||
break;
|
||||
}
|
||||
case 'MKCOL': {
|
||||
await this.service.handleMkcol(auth, path, request, response);
|
||||
break;
|
||||
}
|
||||
case 'COPY': {
|
||||
this.service.handleCopy(auth, path, request, response, headers);
|
||||
break;
|
||||
}
|
||||
case 'MOVE': {
|
||||
this.service.handleMove(auth, path, request, response, headers);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
response.status(HttpStatus.METHOD_NOT_ALLOWED).end();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export class AuthDto {
|
||||
apiKey?: AuthApiKey;
|
||||
sharedLink?: AuthSharedLink;
|
||||
session?: AuthSession;
|
||||
basicAuth?: boolean;
|
||||
}
|
||||
|
||||
export class LoginCredentialDto {
|
||||
|
||||
72
server/src/dtos/webdav.dto.ts
Normal file
72
server/src/dtos/webdav.dto.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class WebDavResourceDto {
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
path!: string;
|
||||
|
||||
@ApiProperty()
|
||||
size!: number;
|
||||
|
||||
@ApiProperty()
|
||||
created!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
modified!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
isCollection!: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
etag?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export class WebDavPropfindRequestDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
depth?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
propfind?: any;
|
||||
}
|
||||
|
||||
export class WebDavCopyMoveRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
destination!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
overwrite?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
depth?: string;
|
||||
}
|
||||
|
||||
export class WebDavLockRequestDto {
|
||||
@ApiProperty({ required: false })
|
||||
lockinfo?: any;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
timeout?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
depth?: string;
|
||||
}
|
||||
|
||||
export class WebDavErrorResponseDto {
|
||||
@ApiProperty()
|
||||
error!: string;
|
||||
|
||||
@ApiProperty()
|
||||
statusCode!: number;
|
||||
}
|
||||
@@ -367,6 +367,7 @@ export enum MetadataKey {
|
||||
export enum RouteKey {
|
||||
ASSET = 'assets',
|
||||
USER = 'users',
|
||||
WEBDAV = 'webdav',
|
||||
}
|
||||
|
||||
export enum CacheControl {
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
applyDecorators,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichQuery, MetadataKey, Permission } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -17,7 +18,8 @@ import { UAParser } from 'ua-parser-js';
|
||||
|
||||
type AdminRoute = { admin?: true };
|
||||
type SharedLinkRoute = { sharedLink?: true };
|
||||
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
|
||||
type WebDavRoute = { webdav?: true };
|
||||
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute | WebDavRoute);
|
||||
|
||||
export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator => {
|
||||
const decorators: MethodDecorator[] = [
|
||||
@@ -84,16 +86,26 @@ export class AuthGuard implements CanActivate {
|
||||
const {
|
||||
admin: adminRoute,
|
||||
sharedLink: sharedLinkRoute,
|
||||
webdav: webDavRoute,
|
||||
permission,
|
||||
} = { sharedLink: false, admin: false, ...options };
|
||||
} = { sharedLink: false, admin: false, webdav: false, ...options };
|
||||
const request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
|
||||
request.user = await this.authService.authenticate({
|
||||
headers: request.headers,
|
||||
queryParams: request.query as Record<string, string>,
|
||||
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
|
||||
});
|
||||
try {
|
||||
request.user = await this.authService.authenticate({
|
||||
headers: request.headers,
|
||||
queryParams: request.query as Record<string, string>,
|
||||
metadata: { adminRoute, sharedLinkRoute, permission, webDavRoute, uri: request.path },
|
||||
});
|
||||
|
||||
return true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Add WWW-Authenticate header for WebDAV routes when authentication fails
|
||||
if (error instanceof UnauthorizedException && webDavRoute) {
|
||||
response.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ValidateRequest = {
|
||||
headers: IncomingHttpHeaders;
|
||||
queryParams: Record<string, string>;
|
||||
metadata: {
|
||||
webDavRoute: boolean;
|
||||
sharedLinkRoute: boolean;
|
||||
adminRoute: boolean;
|
||||
permission?: Permission;
|
||||
@@ -178,7 +179,12 @@ export class AuthService extends BaseService {
|
||||
|
||||
async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise<AuthDto> {
|
||||
const authDto = await this.validate({ headers, queryParams });
|
||||
const { adminRoute, sharedLinkRoute, permission, uri } = metadata;
|
||||
const { adminRoute, sharedLinkRoute, webDavRoute, permission, uri } = metadata;
|
||||
|
||||
if (authDto.basicAuth && !webDavRoute) {
|
||||
// basic auth is only allowed for WebDAV routes
|
||||
throw new ForbiddenException('Forbidden');
|
||||
}
|
||||
|
||||
if (!authDto.user.isAdmin && adminRoute) {
|
||||
this.logger.warn(`Denied access to admin only route: ${uri}`);
|
||||
@@ -204,6 +210,7 @@ export class AuthService extends BaseService {
|
||||
queryParams[ImmichQuery.SESSION_KEY] ||
|
||||
this.getBearerToken(headers) ||
|
||||
this.getCookieToken(headers)) as string;
|
||||
const basicToken = this.getBasicToken(headers);
|
||||
const apiKey = (headers[ImmichHeader.API_KEY] || queryParams[ImmichQuery.API_KEY]) as string;
|
||||
|
||||
if (shareKey) {
|
||||
@@ -218,6 +225,10 @@ export class AuthService extends BaseService {
|
||||
return this.validateApiKey(apiKey);
|
||||
}
|
||||
|
||||
if (basicToken) {
|
||||
return this.validateBasicAuth(basicToken, { isApiKey: false });
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
@@ -385,6 +396,15 @@ export class AuthService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getBasicToken(headers: IncomingHttpHeaders): string | null {
|
||||
const [type, token] = (headers.authorization || '').split(' ');
|
||||
if (type.toLowerCase() === 'basic') {
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getCookieToken(headers: IncomingHttpHeaders): string | null {
|
||||
const cookies = parse(headers.cookie || '');
|
||||
return cookies[ImmichCookie.ACCESS_TOKEN] || null;
|
||||
@@ -427,6 +447,36 @@ export class AuthService extends BaseService {
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
private async validateBasicAuth(token: string, { isApiKey }: { isApiKey: boolean }): Promise<AuthDto> {
|
||||
let base64decodedToken;
|
||||
try {
|
||||
base64decodedToken = Buffer.from(token, 'base64').toString('utf8');
|
||||
} catch {
|
||||
this.logger.warn(`Invalid token format: ${token}`);
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const [email, password] = base64decodedToken.split(':');
|
||||
if (isApiKey) {
|
||||
const apiKey = await this.validateApiKey(password);
|
||||
return {
|
||||
...apiKey,
|
||||
basicAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.userRepository.getByEmail(email, { withPassword: true });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (this.validateSecret(password, user.password)) {
|
||||
return {
|
||||
user,
|
||||
basicAuth: true,
|
||||
};
|
||||
}
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
private validateSecret(inputSecret: string, existingHash?: string | null): boolean {
|
||||
if (!existingHash) {
|
||||
return false;
|
||||
|
||||
@@ -39,6 +39,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { WebDavService } from 'src/services/webdav.service';
|
||||
|
||||
export const services = [
|
||||
ApiKeyService,
|
||||
@@ -82,4 +83,5 @@ export const services = [
|
||||
UserService,
|
||||
VersionService,
|
||||
ViewService,
|
||||
WebDavService,
|
||||
];
|
||||
|
||||
624
server/src/services/webdav.service.ts
Normal file
624
server/src/services/webdav.service.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { WebDavResourceDto } from 'src/dtos/webdav.dto';
|
||||
import { AssetType, CacheControl, Permission } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { requireAccess } from 'src/utils/access';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
interface ParsedPath {
|
||||
path: string;
|
||||
segments: string[];
|
||||
isRoot: boolean;
|
||||
isAlbum: boolean;
|
||||
albumName?: string;
|
||||
assetFileName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WebDavService extends BaseService {
|
||||
async handleGet(
|
||||
auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: () => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Convert path segments array to parsed path structure
|
||||
const parsedPath = this.parsePathSegments(resourcePathSegments);
|
||||
|
||||
// Check if this is a collection or file request
|
||||
if (parsedPath.isRoot || parsedPath.isAlbum) {
|
||||
// Return directory listing as HTML
|
||||
const resources = await this.listResources(auth, parsedPath);
|
||||
const html = this.generateDirectoryListing(parsedPath.path, resources);
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.status(HttpStatus.OK).send(html);
|
||||
} else {
|
||||
// Return file content
|
||||
const asset = await this.getAssetByPath(auth, parsedPath);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Resource not found');
|
||||
}
|
||||
|
||||
return sendFile(
|
||||
response,
|
||||
next,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async () =>
|
||||
new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
contentType: mimeTypes.lookup(asset.originalPath) || 'application/octet-stream',
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
fileName: asset.originalFileName,
|
||||
}),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
async handleHead(
|
||||
auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const parsedPath = this.parsePathSegments(resourcePathSegments);
|
||||
const resource = await this.getResourceInfo(auth, parsedPath);
|
||||
|
||||
if (!resource) {
|
||||
response.status(HttpStatus.NOT_FOUND).end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader('ETag', resource.etag || `"${resource.modified.getTime()}"`);
|
||||
response.setHeader('Last-Modified', resource.modified.toUTCString());
|
||||
|
||||
if (!resource.isCollection) {
|
||||
response.setHeader('Content-Length', resource.size.toString());
|
||||
response.setHeader('Content-Type', resource.contentType || 'application/octet-stream');
|
||||
}
|
||||
|
||||
response.status(HttpStatus.OK).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handlePut(
|
||||
_auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
_headers: Record<string, string>,
|
||||
): void {
|
||||
try {
|
||||
const parsedPath = this.parsePathSegments(resourcePathSegments);
|
||||
|
||||
if (parsedPath.isRoot || parsedPath.isAlbum) {
|
||||
throw new BadRequestException('Cannot PUT to a collection');
|
||||
}
|
||||
|
||||
// For now, we'll return method not allowed as upload is complex
|
||||
response.status(HttpStatus.METHOD_NOT_ALLOWED).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(
|
||||
auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const parsedPath = this.parsePathSegments(resourcePathSegments);
|
||||
|
||||
if (parsedPath.isRoot) {
|
||||
throw new BadRequestException('Cannot delete root collection');
|
||||
}
|
||||
|
||||
if (parsedPath.assetFileName && parsedPath.albumName) {
|
||||
// Delete asset (soft delete by setting deletedAt)
|
||||
const asset = await this.getAssetByFileName(auth, parsedPath.albumName, parsedPath.assetFileName);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth,
|
||||
permission: Permission.ASSET_DELETE,
|
||||
ids: [asset.id],
|
||||
});
|
||||
await this.assetRepository.update({ id: asset.id, deletedAt: new Date() });
|
||||
response.status(HttpStatus.NO_CONTENT).end();
|
||||
} else if (parsedPath.albumName) {
|
||||
// Delete album by name
|
||||
const album = await this.getAlbumByName(auth, parsedPath.albumName);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album not found');
|
||||
}
|
||||
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth,
|
||||
permission: Permission.ALBUM_DELETE,
|
||||
ids: [album.id],
|
||||
});
|
||||
await this.albumRepository.delete(album.id);
|
||||
response.status(HttpStatus.NO_CONTENT).end();
|
||||
} else {
|
||||
throw new NotFoundException('Resource not found');
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMkcol(
|
||||
auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const parsedPath = this.parsePathSegments(resourcePathSegments);
|
||||
|
||||
if (parsedPath.isRoot) {
|
||||
throw new BadRequestException('Collection already exists');
|
||||
}
|
||||
|
||||
// Create new album
|
||||
const albumName = parsedPath.segments.at(-1);
|
||||
const album = await this.albumRepository.create(
|
||||
{
|
||||
ownerId: auth.user.id,
|
||||
albumName,
|
||||
description: '',
|
||||
},
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
response.setHeader('Location', `/webdav/${album.id}`);
|
||||
response.status(HttpStatus.CREATED).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handleCopy(
|
||||
_auth: AuthDto,
|
||||
_sourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
try {
|
||||
const destination = headers['destination'];
|
||||
if (!destination) {
|
||||
throw new BadRequestException('Destination header required');
|
||||
}
|
||||
|
||||
// For now, return method not allowed
|
||||
response.status(HttpStatus.METHOD_NOT_ALLOWED).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handleMove(
|
||||
_auth: AuthDto,
|
||||
_sourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
try {
|
||||
const destination = headers['destination'];
|
||||
if (!destination) {
|
||||
throw new BadRequestException('Destination header required');
|
||||
}
|
||||
|
||||
// For now, return method not allowed
|
||||
response.status(HttpStatus.METHOD_NOT_ALLOWED).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handleOptions(_request: Request, response: Response) {
|
||||
response.setHeader(
|
||||
'Allow',
|
||||
'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK',
|
||||
);
|
||||
response.setHeader('DAV', '1, 2');
|
||||
response.setHeader('MS-Author-Via', 'DAV');
|
||||
response.status(HttpStatus.OK).end();
|
||||
}
|
||||
|
||||
async handlePropfind(
|
||||
auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
headers: Record<string, string>,
|
||||
_body: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const depth = headers['depth'] || 'infinity';
|
||||
const parsedPath = this.parsePathSegments(resourcePathSegments);
|
||||
|
||||
const resources: WebDavResourceDto[] = [];
|
||||
|
||||
// Get current resource
|
||||
const currentResource = await this.getResourceInfo(auth, parsedPath);
|
||||
if (currentResource) {
|
||||
resources.push(currentResource);
|
||||
|
||||
// If depth > 0 and it's a collection, get children
|
||||
if (depth !== '0' && currentResource.isCollection) {
|
||||
const children = await this.listResources(auth, parsedPath);
|
||||
resources.push(...children);
|
||||
}
|
||||
}
|
||||
|
||||
const xml = this.generatePropfindResponse(resources);
|
||||
response.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
response.status(HttpStatus.MULTI_STATUS).send(xml);
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handleProppatch(
|
||||
_auth: AuthDto,
|
||||
resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
_body: unknown,
|
||||
) {
|
||||
try {
|
||||
// For now, return success but don't actually update properties
|
||||
const xml =
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n' +
|
||||
'<D:multistatus xmlns:D="DAV:">\n' +
|
||||
' <D:response>\n' +
|
||||
` <D:href>/${resourcePathSegments.join('/')}</D:href>\n` +
|
||||
' <D:propstat>\n' +
|
||||
' <D:status>HTTP/1.1 200 OK</D:status>\n' +
|
||||
' </D:propstat>\n' +
|
||||
' </D:response>\n' +
|
||||
'</D:multistatus>';
|
||||
|
||||
response.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
response.status(HttpStatus.MULTI_STATUS).send(xml);
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handleLock(
|
||||
_auth: AuthDto,
|
||||
_resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
_headers: Record<string, string>,
|
||||
_body: unknown,
|
||||
) {
|
||||
try {
|
||||
// WebDAV locking not implemented - return 501
|
||||
response.status(HttpStatus.NOT_IMPLEMENTED).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
handleUnlock(
|
||||
_auth: AuthDto,
|
||||
_resourcePathSegments: string[],
|
||||
_request: Request,
|
||||
response: Response,
|
||||
_headers: Record<string, string>,
|
||||
) {
|
||||
try {
|
||||
// WebDAV locking not implemented - return 501
|
||||
response.status(HttpStatus.NOT_IMPLEMENTED).end();
|
||||
} catch (error) {
|
||||
this.handleError(error, response);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private async getAlbumByName(auth: AuthDto, albumName: string) {
|
||||
const albums = await this.albumRepository.getOwned(auth.user.id);
|
||||
return albums.find((album) => album.albumName === albumName);
|
||||
}
|
||||
|
||||
private async getAssetByFileName(auth: AuthDto, albumName: string, fileName: string) {
|
||||
const album = await this.getAlbumByName(auth, albumName);
|
||||
if (!album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [album.id] });
|
||||
|
||||
// Get album with assets to search by filename
|
||||
const albumWithAssets = await this.albumRepository.getById(album.id, { withAssets: true });
|
||||
if (!albumWithAssets?.assets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find asset by original filename
|
||||
const asset = albumWithAssets.assets.find((asset) => asset.originalFileName === fileName);
|
||||
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [asset.id] });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private parsePathSegments(segments: string[]): ParsedPath {
|
||||
// Filter out empty segments
|
||||
const cleanSegments = segments.filter(Boolean);
|
||||
|
||||
return {
|
||||
path: '/' + cleanSegments.join('/'),
|
||||
segments: cleanSegments,
|
||||
isRoot: cleanSegments.length === 0,
|
||||
isAlbum: cleanSegments.length === 1,
|
||||
albumName: cleanSegments.length > 0 ? cleanSegments[0] : undefined,
|
||||
assetFileName: cleanSegments.length >= 2 ? cleanSegments[1] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async getResourceInfo(auth: AuthDto, parsedPath: ParsedPath): Promise<WebDavResourceDto | null> {
|
||||
if (parsedPath.isRoot) {
|
||||
return {
|
||||
name: '/',
|
||||
path: '/',
|
||||
size: 0,
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
isCollection: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedPath.albumName && !parsedPath.assetFileName) {
|
||||
// Get album info by name
|
||||
const album = await this.getAlbumByName(auth, parsedPath.albumName);
|
||||
if (!album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [album.id] });
|
||||
|
||||
return {
|
||||
name: album.albumName,
|
||||
path: parsedPath.path,
|
||||
size: 0,
|
||||
created: album.createdAt,
|
||||
modified: album.updatedAt,
|
||||
isCollection: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedPath.assetFileName && parsedPath.albumName) {
|
||||
// Get asset by filename within the album
|
||||
const asset = await this.getAssetByFileName(auth, parsedPath.albumName, parsedPath.assetFileName);
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get asset with exif info for file size
|
||||
const assetWithExif = await this.assetRepository.getById(asset.id, { exifInfo: true });
|
||||
|
||||
return {
|
||||
name: asset.originalFileName || asset.id,
|
||||
path: parsedPath.path,
|
||||
size: assetWithExif?.exifInfo?.fileSizeInByte || 0,
|
||||
created: asset.createdAt,
|
||||
modified: asset.updatedAt,
|
||||
isCollection: false,
|
||||
contentType:
|
||||
asset.type === AssetType.IMAGE
|
||||
? 'image/jpeg'
|
||||
: asset.type === AssetType.VIDEO
|
||||
? 'video/mp4'
|
||||
: 'application/octet-stream',
|
||||
etag: `"${asset.checksum}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listResources(auth: AuthDto, parsedPath: ParsedPath): Promise<WebDavResourceDto[]> {
|
||||
const resources: WebDavResourceDto[] = [];
|
||||
|
||||
if (parsedPath.isRoot) {
|
||||
// List albums owned by the user
|
||||
const albums = await this.albumRepository.getOwned(auth.user.id);
|
||||
for (const album of albums) {
|
||||
resources.push({
|
||||
name: album.albumName,
|
||||
path: `/${album.albumName}`,
|
||||
size: 0,
|
||||
created: album.createdAt,
|
||||
modified: album.updatedAt,
|
||||
isCollection: true,
|
||||
});
|
||||
}
|
||||
} else if (parsedPath.albumName) {
|
||||
// List assets in album by name
|
||||
const album = await this.getAlbumByName(auth, parsedPath.albumName);
|
||||
if (album) {
|
||||
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [album.id] });
|
||||
|
||||
// Get album with assets
|
||||
const albumWithAssets = await this.albumRepository.getById(album.id, { withAssets: true });
|
||||
if (!albumWithAssets?.assets) {
|
||||
return resources;
|
||||
}
|
||||
|
||||
for (const asset of albumWithAssets.assets) {
|
||||
resources.push({
|
||||
name: asset.originalFileName || asset.id,
|
||||
path: `/${album.albumName}/${asset.originalFileName || asset.id}`,
|
||||
size: asset.exifInfo?.fileSizeInByte || 0,
|
||||
created: asset.createdAt,
|
||||
modified: asset.updatedAt,
|
||||
isCollection: false,
|
||||
contentType:
|
||||
asset.type === AssetType.IMAGE
|
||||
? 'image/jpeg'
|
||||
: asset.type === AssetType.VIDEO
|
||||
? 'video/mp4'
|
||||
: 'application/octet-stream',
|
||||
etag: `"${asset.checksum}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private async getAssetByPath(auth: AuthDto, parsedPath: ParsedPath) {
|
||||
if (!parsedPath.assetFileName || !parsedPath.albumName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get asset by filename within the album
|
||||
const asset = await this.getAssetByFileName(auth, parsedPath.albumName, parsedPath.assetFileName);
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get asset with exif info for streaming
|
||||
return await this.assetRepository.getById(asset.id, { exifInfo: true });
|
||||
}
|
||||
|
||||
private generateDirectoryListing(dirPath: string, resources: WebDavResourceDto[]): string {
|
||||
let html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Index of ${dirPath}</title>
|
||||
<style>
|
||||
body { font-family: monospace; margin: 20px; }
|
||||
h1 { font-size: 1.5em; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { padding: 5px 15px; text-align: left; }
|
||||
tr:hover { background-color: #f5f5f5; }
|
||||
a { text-decoration: none; color: #0066cc; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.size { text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Index of ${dirPath}</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="size">Size</th>
|
||||
</tr>`;
|
||||
|
||||
if (dirPath !== '/') {
|
||||
html += `
|
||||
<tr>
|
||||
<td><a href="../">../</a></td>
|
||||
<td>-</td>
|
||||
<td class="size">-</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
const name = resource.isCollection ? resource.name + '/' : resource.name;
|
||||
const size = resource.isCollection ? '-' : this.formatBytes(resource.size);
|
||||
// For collections, use the last segment of the path (the name), for files use the name
|
||||
const pathSegments = resource.path.split('/').filter(Boolean);
|
||||
const href = resource.isCollection ? (pathSegments.length > 0 ? pathSegments.at(-1) + '/' : '') : resource.name;
|
||||
html += `
|
||||
<tr>
|
||||
<td><a href="${href}">${name}</a></td>
|
||||
<td>${new Date(resource.modified).toISOString().split('T')[0]}</td>
|
||||
<td class="size">${size}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
private generatePropfindResponse(resources: WebDavResourceDto[]): string {
|
||||
let xml = '<?xml version="1.0" encoding="utf-8"?>\n';
|
||||
xml += '<D:multistatus xmlns:D="DAV:">\n';
|
||||
|
||||
for (const resource of resources) {
|
||||
xml += ' <D:response>\n';
|
||||
xml += ` <D:href>/api/webdav/${resource.path}</D:href>\n`;
|
||||
xml += ' <D:propstat>\n';
|
||||
xml += ' <D:prop>\n';
|
||||
|
||||
if (resource.isCollection) {
|
||||
xml += ' <D:resourcetype><D:collection/></D:resourcetype>\n';
|
||||
} else {
|
||||
xml += ' <D:resourcetype/>\n';
|
||||
xml += ` <D:getcontentlength>${resource.size}</D:getcontentlength>\n`;
|
||||
xml += ` <D:getcontenttype>${resource.contentType || 'application/octet-stream'}</D:getcontenttype>\n`;
|
||||
}
|
||||
|
||||
xml += ` <D:getlastmodified>${new Date(resource.modified).toUTCString()}</D:getlastmodified>\n`;
|
||||
xml += ` <D:creationdate>${new Date(resource.created).toISOString()}</D:creationdate>\n`;
|
||||
|
||||
if (resource.etag) {
|
||||
xml += ` <D:getetag>${resource.etag}</D:getetag>\n`;
|
||||
}
|
||||
|
||||
xml += ' </D:prop>\n';
|
||||
xml += ' <D:status>HTTP/1.1 200 OK</D:status>\n';
|
||||
xml += ' </D:propstat>\n';
|
||||
xml += ' </D:response>\n';
|
||||
}
|
||||
|
||||
xml += '</D:multistatus>';
|
||||
return xml;
|
||||
}
|
||||
|
||||
private handleError(error: unknown, response: Response): void {
|
||||
if (error instanceof BadRequestException || error instanceof NotFoundException) {
|
||||
const status = error.getStatus();
|
||||
response.status(status).send(error.message);
|
||||
} else if (error && typeof error === 'object' && 'status' in error && typeof (error as any).status === 'number') {
|
||||
response.status((error as any).status).send((error as any).message || 'Error');
|
||||
} else {
|
||||
this.logger.error('WebDAV error', error);
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Internal Server Error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { json } from 'body-parser';
|
||||
import compression from 'compression';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import { existsSync } from 'node:fs';
|
||||
import sirv from 'sirv';
|
||||
import { ApiModule } from 'src/app.module';
|
||||
@@ -36,7 +37,20 @@ async function bootstrap() {
|
||||
app.use(cookieParser());
|
||||
app.use(json({ limit: '10mb' }));
|
||||
if (isDev) {
|
||||
app.enableCors();
|
||||
// Dynamic CORS configuration to exclude WebDAV paths
|
||||
app.use(
|
||||
cors((req, callback) => {
|
||||
const corsOptions = (req as unknown as Request).url?.startsWith('/api/webdav')
|
||||
? { origin: false }
|
||||
: {
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204,
|
||||
};
|
||||
callback(null, corsOptions);
|
||||
}),
|
||||
);
|
||||
}
|
||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||
useSwagger(app, { write: isDev });
|
||||
|
||||
Reference in New Issue
Block a user