diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e35be2ee0..84ca1d5f30 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 153b525fe5..667408d8e9 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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' }, }), ); diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9c39e580b6..aa20eb1c0b 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -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, ]; diff --git a/server/src/controllers/webdav.controller.ts b/server/src/controllers/webdav.controller.ts new file mode 100644 index 0000000000..919211c02c --- /dev/null +++ b/server/src/controllers/webdav.controller.ts @@ -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 { + 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 { + 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, + @Res() response: Response, + @Headers() headers: Record, + ) { + 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 { + 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 { + 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 { + 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, + @Res() response: Response, + @Param('path') path: string[], + @Headers() headers: Record, + ) { + 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 { + 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, + @Body() body: any, + ): Promise { + 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, + @Body() body: any, + @Param('path') path: string[], + ): Promise { + 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(); + } + } + } +} diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index e94818b2b5..7a81823faf 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -17,6 +17,7 @@ export class AuthDto { apiKey?: AuthApiKey; sharedLink?: AuthSharedLink; session?: AuthSession; + basicAuth?: boolean; } export class LoginCredentialDto { diff --git a/server/src/dtos/webdav.dto.ts b/server/src/dtos/webdav.dto.ts new file mode 100644 index 0000000000..c92c4651bf --- /dev/null +++ b/server/src/dtos/webdav.dto.ts @@ -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; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index e7e40eb122..ef38b59c61 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -367,6 +367,7 @@ export enum MetadataKey { export enum RouteKey { ASSET = 'assets', USER = 'users', + WEBDAV = 'webdav', } export enum CacheControl { diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 438843436b..8e6c99f1a3 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -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(); + const response = context.switchToHttp().getResponse(); - request.user = await this.authService.authenticate({ - headers: request.headers, - queryParams: request.query as Record, - metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path }, - }); + try { + request.user = await this.authService.authenticate({ + headers: request.headers, + queryParams: request.query as Record, + 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; + } } } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index e6c541a624..9f210f1f40 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -46,6 +46,7 @@ export type ValidateRequest = { headers: IncomingHttpHeaders; queryParams: Record; 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 { 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 { + 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; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 88b68d2c13..462ef1cafb 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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, ]; diff --git a/server/src/services/webdav.service.ts b/server/src/services/webdav.service.ts new file mode 100644 index 0000000000..b7ebf7f549 --- /dev/null +++ b/server/src/services/webdav.service.ts @@ -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 { + 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 { + 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, + ): 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 { + 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 { + 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, + ) { + 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, + ) { + 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, + _body: unknown, + ): Promise { + 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 = + '\n' + + '\n' + + ' \n' + + ` /${resourcePathSegments.join('/')}\n` + + ' \n' + + ' HTTP/1.1 200 OK\n' + + ' \n' + + ' \n' + + ''; + + 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, + _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, + ) { + 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 { + 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 { + 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 = ` + + + Index of ${dirPath} + + + +

Index of ${dirPath}

+ + + + + + `; + + if (dirPath !== '/') { + html += ` + + + + + `; + } + + 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 += ` + + + + + `; + } + + html += ` +
NameLast ModifiedSize
../--
${name}${new Date(resource.modified).toISOString().split('T')[0]}${size}
+ +`; + + 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 = '\n'; + xml += '\n'; + + for (const resource of resources) { + xml += ' \n'; + xml += ` /api/webdav/${resource.path}\n`; + xml += ' \n'; + xml += ' \n'; + + if (resource.isCollection) { + xml += ' \n'; + } else { + xml += ' \n'; + xml += ` ${resource.size}\n`; + xml += ` ${resource.contentType || 'application/octet-stream'}\n`; + } + + xml += ` ${new Date(resource.modified).toUTCString()}\n`; + xml += ` ${new Date(resource.created).toISOString()}\n`; + + if (resource.etag) { + xml += ` ${resource.etag}\n`; + } + + xml += ' \n'; + xml += ' HTTP/1.1 200 OK\n'; + xml += ' \n'; + xml += ' \n'; + } + + xml += ''; + 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'); + } + } +} diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index ce1520c475..a0ffb27de5 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -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 });