Compare commits

...

1 Commits

Author SHA1 Message Date
Min Idzelis
65f7b3a86d feat: webdav 2025-06-07 04:18:50 +00:00
12 changed files with 1571 additions and 12 deletions

View File

@@ -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": {

View File

@@ -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' },
}),
);

View File

@@ -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,
];

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

View File

@@ -17,6 +17,7 @@ export class AuthDto {
apiKey?: AuthApiKey;
sharedLink?: AuthSharedLink;
session?: AuthSession;
basicAuth?: boolean;
}
export class LoginCredentialDto {

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

View File

@@ -367,6 +367,7 @@ export enum MetadataKey {
export enum RouteKey {
ASSET = 'assets',
USER = 'users',
WEBDAV = 'webdav',
}
export enum CacheControl {

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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,
];

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

View File

@@ -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 });