diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js
index 1f167ec8..0e882c0c 100644
--- a/src/backend/src/CoreModule.js
+++ b/src/backend/src/CoreModule.js
@@ -388,6 +388,8 @@ const install = async ({ services, app, useapi, modapi }) => {
const { WispService } = require('./services/WispService');
services.registerService('wisp', WispService);
+ const { WebDavFS } = require('./services/WebDavFS');
+ services.registerService('dav', WebDavFS);
const { RequestMeasureService } = require('./services/RequestMeasureService');
services.registerService('request-measure', RequestMeasureService);
diff --git a/src/backend/src/filesystem/ll_operations/ll_read.js b/src/backend/src/filesystem/ll_operations/ll_read.js
index 91abf5ac..5b458e1c 100644
--- a/src/backend/src/filesystem/ll_operations/ll_read.js
+++ b/src/backend/src/filesystem/ll_operations/ll_read.js
@@ -117,7 +117,7 @@ class LLRead extends LLFilesystemOperation {
const context = a.iget('context');
const storage = context.get('storage');
- const { fsNode, version_id, offset, length, has_range } = a.values();
+ const { fsNode, version_id, offset, length, has_range, range } = a.values();
// Empty object here is in the case of local fiesystem,
// where s3:location will return null.
@@ -130,9 +130,9 @@ class LLRead extends LLFilesystemOperation {
bucket_region: location.bucket_region,
version_id,
key: location.key,
- ...(has_range ? {
+ ...(range? {range} : (has_range ? {
range: `bytes=${offset}-${offset+length-1}`
- } : {}),
+ } : {})),
}));
a.set('stream', stream);
diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js
index 1f61f953..7133e0fe 100644
--- a/src/backend/src/middleware/configurable_auth.js
+++ b/src/backend/src/middleware/configurable_auth.js
@@ -55,7 +55,7 @@ const configurable_auth = options => async (req, res, next) => {
if(req.body && req.body.auth_token)
token = req.body.auth_token;
// HTTML Auth header
- else if(req.header && req.header('Authorization')) {
+ else if (req.header && req.header('Authorization') && !req.header('Authorization').startsWith("Basic ")) {
token = req.header('Authorization');
token = token.replace('Bearer ', '').trim();
if ( token === 'undefined' ) {
@@ -74,7 +74,7 @@ const configurable_auth = options => async (req, res, next) => {
else if(req.handshake && req.handshake.query && req.handshake.query.auth_token)
token = req.handshake.query.auth_token;
- if(!token) {
+ if(!token || token.startsWith("Basic ")) {
if ( optional ) {
next();
return;
diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js
index c59bfcc7..97390352 100644
--- a/src/backend/src/modules/web/WebServerService.js
+++ b/src/backend/src/modules/web/WebServerService.js
@@ -628,10 +628,11 @@ class WebServerService extends BaseService {
}
// Request methods to allow
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK');
const allowed_headers = [
- "Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization", "sentry-trace", "baggage"
+ "Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization", "sentry-trace", "baggage",
+ "Depth", "Destination", "Overwrite", "If", "Lock-Token", "DAV"
];
// Request headers to allow
@@ -663,7 +664,22 @@ class WebServerService extends BaseService {
});
// Options for all requests (for CORS)
- app.options('/*', (_, res) => {
+ app.options('/*', (req, res) => {
+ if (req.path.startsWith('/dav/')) {
+ res.set({
+ 'Allow': 'OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, ORDERPATCH',
+ 'DAV': '1, 2, ordered-collections', // WebDAV compliance classes with ordered-collections for macOS
+ 'MS-Author-Via': 'DAV', // Microsoft compatibility
+ 'Server': 'Puter/WebDAV', // Server identification
+ 'Accept-Ranges': 'bytes',
+ 'Content-Type': 'text/plain; charset=utf-8', // Explicit content type
+ 'Content-Length': '0',
+ 'Cache-Control': 'no-cache', // Prevent caching issues
+ 'Connection': 'Keep-Alive' // Keep connection alive for macOS
+ });
+ res.status(200).end();
+ console.log("OPTIONS request completed for macOS compatibility");
+ }
return res.sendStatus(200);
});
}
diff --git a/src/backend/src/modules/web/lib/eggspress.js b/src/backend/src/modules/web/lib/eggspress.js
index b7ecb5b8..2c81242f 100644
--- a/src/backend/src/modules/web/lib/eggspress.js
+++ b/src/backend/src/modules/web/lib/eggspress.js
@@ -193,22 +193,57 @@ module.exports = function eggspress (route, settings, handler) {
}
}
};
-
- if ( settings.allowedMethods.includes('GET') ) {
+ if (settings.allowedMethods.includes('GET')) {
router.get(route, ...mw, errorHandledHandler, ...afterMW);
}
- if ( settings.allowedMethods.includes('POST') ) {
+ if (settings.allowedMethods.includes('HEAD')) {
+ router.head(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('POST')) {
router.post(route, ...mw, errorHandledHandler, ...afterMW);
}
- if ( settings.allowedMethods.includes('PUT') ) {
+ if (settings.allowedMethods.includes('PUT')) {
router.put(route, ...mw, errorHandledHandler, ...afterMW);
}
- if ( settings.allowedMethods.includes('DELETE') ) {
+ if (settings.allowedMethods.includes('DELETE')) {
router.delete(route, ...mw, errorHandledHandler, ...afterMW);
}
+ if (settings.allowedMethods.includes('PROPFIND')) {
+ router.propfind(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('PROPPATCH')) {
+ router.proppatch(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('MKCOL')) {
+ router.mkcol(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('COPY')) {
+ router.copy(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('MOVE')) {
+ router.move(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('LOCK')) {
+ router.lock(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('UNLOCK')) {
+ router.unlock(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
+ if (settings.allowedMethods.includes('OPTIONS')) {
+ router.options(route, ...mw, errorHandledHandler, ...afterMW);
+ }
+
return router;
}
\ No newline at end of file
diff --git a/src/backend/src/services/WebDavFS.js b/src/backend/src/services/WebDavFS.js
new file mode 100644
index 00000000..c925101f
--- /dev/null
+++ b/src/backend/src/services/WebDavFS.js
@@ -0,0 +1,1194 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+
+const { HLReadDir } = require("../filesystem/hl_operations/hl_readdir");
+const { HLStat } = require("../filesystem/hl_operations/hl_stat");
+const { LLRead } = require("../filesystem/ll_operations/ll_read");
+const { HLWrite } = require("../filesystem/hl_operations/hl_write");
+const { HLMkdir } = require("../filesystem/hl_operations/hl_mkdir");
+const { HLMove } = require("../filesystem/hl_operations/hl_move");
+const { HLCopy } = require("../filesystem/hl_operations/hl_copy");
+const { NodePathSelector, NodeUIDSelector } = require("../filesystem/node/selectors");
+const configurable_auth = require("../middleware/configurable_auth");
+const { Context } = require("../util/context");
+const { Endpoint } = require("../util/expressutil");
+const BaseService = require("./BaseService");
+const path = require('path');
+const { HLRemove } = require("../filesystem/hl_operations/hl_remove");
+const bcrypt = require('bcrypt')
+
+let COOKIE_NAME = null;
+
+/**
+ * Converts a puter fsitem (from stat) to the WebDav PROPFIND equivilent.
+ * Used for a singlefile PROPFIND.
+ *
+ * @param {any} fsEntry
+ * @returns
+ */
+function convertToWebDAVPropfindXML(fsEntry) {
+ const isDirectory = fsEntry.is_dir;
+ const lastModified = new Date(fsEntry.modified * 1000).toUTCString();
+ const createdDate = new Date(fsEntry.created * 1000).toISOString();
+
+ // Ensure href ends with / for directories
+ let href = fsEntry.path;
+ if (isDirectory && !href.endsWith('/')) {
+ href += '/';
+ }
+
+ // Build the XML response
+ const xml = `
+
+
+ /dav${escapeXml(href)}
+
+
+ ${escapeXml(fsEntry.name)}
+ ${lastModified}
+ ${createdDate}
+ ${isDirectory ?
+ '' :
+ `
+ ${fsEntry.size || 0}
+ ${escapeXml(getProperMimeType(fsEntry.type, fsEntry.name))}`
+ }
+ "${fsEntry.uid}-${Math.floor(fsEntry.modified)}"
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+ HTTP/1.1 200 OK
+
+
+`;
+
+ return xml;
+}
+
+/**
+ * Converts a puter fsitem (from readdir) to the WebDav PROPFIND equivilent.
+ * Used for a directory PROPFIND
+ *
+ * @param {any} fsEntry
+ * @returns
+ */
+function convertMultipleToWebDAVPropfindXML(selfStat, fsEntries) {
+ fsEntries = [selfStat, ...fsEntries];
+ const responses = fsEntries.map(fsEntry => {
+ const isDirectory = fsEntry.is_dir;
+ const lastModified = new Date((fsEntry.modified||0) * 1000).toUTCString();
+ const createdDate = new Date((fsEntry.created||0) * 1000).toISOString();
+
+ // Ensure href ends with / for directories
+ let href = fsEntry.path;
+ if (isDirectory && !href.endsWith('/')) {
+ href += '/';
+ }
+
+ return `
+ /dav${escapeXml(href)}
+
+
+ ${escapeXml(fsEntry.name)}
+ ${lastModified}
+ ${createdDate}
+ ${isDirectory ?
+ '' :
+ `
+ ${fsEntry.size || 0}
+ ${escapeXml(getProperMimeType(fsEntry.type, fsEntry.name))}`
+ }
+ "${fsEntry.uid}-${Math.floor(fsEntry.modified)}"
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+ HTTP/1.1 200 OK
+
+ `;
+ }).join('\n');
+
+ return `
+
+${responses}
+`;
+}
+
+function getProperMimeType(originalType, filename) {
+ // If we have a type and it's not the generic octet-stream, use it
+ if (originalType && originalType !== 'application/octet-stream') {
+ return originalType;
+ }
+
+ // Otherwise, guess based on file extension
+ const ext = filename.split('.').pop()?.toLowerCase();
+ switch (ext) {
+ case 'js': return 'application/javascript';
+ case 'css': return 'text/css';
+ case 'html': case 'htm': return 'text/html';
+ case 'txt': return 'text/plain';
+ case 'json': return 'application/json';
+ case 'xml': return 'application/xml';
+ case 'pdf': return 'application/pdf';
+ case 'png': return 'image/png';
+ case 'jpg': case 'jpeg': return 'image/jpeg';
+ case 'gif': return 'image/gif';
+ case 'svg': return 'image/svg+xml';
+ default: return 'application/octet-stream';
+ }
+}
+
+/**
+ * Small utility function to escape XML
+ *
+ * @param {string} text
+ * @returns
+ */
+function escapeXml(text) {
+ if (typeof text !== 'string') return text;
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function createStaticDavRootResponse() {
+ const currentDate = new Date().toUTCString();
+ const currentISODate = new Date().toISOString();
+ const timestamp = Math.floor(Date.now() / 1000);
+
+ return `
+
+
+ /dav/
+
+
+ dav
+ ${currentDate}
+ ${currentISODate}
+
+ "dav-root-${timestamp}"
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+ /dav/admin/
+
+
+ admin
+ ${currentDate}
+ ${currentISODate}
+
+ "admin-folder-${timestamp}"
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+ HTTP/1.1 200 OK
+
+
+`;
+}
+
+function createRootWebDAVResponse() {
+ return `
+
+
+ /
+
+
+ /
+ Fri, 03 Jan 2025 10:30:45 GMT
+ 2025-01-03T10:30:45Z
+
+ "dav-folder-1735898444"
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+ /dav/
+
+
+ dav
+ Fri, 03 Jan 2025 10:30:45 GMT
+ 2025-01-03T10:30:45Z
+
+ "dav-folder-1735898445"
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+ HTTP/1.1 200 OK
+
+
+`;
+}
+
+// Small operations wrapper to make my life a bit easier. Generally it takes a FileNode and returns what puter.fs in puter.js would return.
+const operations = {
+ stat: (node)=>{
+ const hl_stat = new HLStat();
+ return hl_stat.run({
+ subject: node,
+ user: Context.get("actor"),
+ return_subdomains: true,
+ return_permissions: true,
+ return_shares: false,
+ return_versions: false,
+ return_size: true,
+ });;
+ },
+ readdir: (node) => {
+ const hl_readdir = new HLReadDir();
+ return hl_readdir.run({
+ subject: node,
+ // user: Context.get("actor").type.user,
+ actor: Context.get("actor"),
+ recursive: false,
+ no_thumbs: false,
+ no_assocs: false,
+ });
+ },
+ read: (node, options) => {
+ const ll_read = new LLRead();
+ return ll_read.run({
+ fsNode: node,
+ actor: Context.get("actor"),
+ ...options
+ });
+ },
+ write: (node, options) => {
+ const hl_write = new HLWrite();
+ return hl_write.run({
+ destination_or_parent: node,
+ actor: Context.get("actor"),
+ file: {
+ stream: options.stream,
+ size: options.size || 0,
+ ...options.file // Allow additional file properties
+ },
+ overwrite: options.overwrite !== undefined ? options.overwrite : true, // Default to true for WebDAV PUT
+ create_missing_parents: false,
+ dedupe_name: false,
+ user: Context.get("actor").type.user,
+ specified_name: options.name, // Optional filename if node is a directory
+ fallback_name: options.fallback_name,
+ shortcut_to: options.shortcut_to,
+ no_thumbnail: options.no_thumbnail || true, // Disable thumbnails for WebDAV by default
+ message: options.message,
+ app_id: options.app_id,
+ socket_id: options.socket_id,
+ operation_id: options.operation_id,
+ item_upload_id: options.item_upload_id,
+ offset: options.offset, // For partial/resume uploads
+ });
+ },
+ mkdir: (node, options) => {
+ const hl_mkdir = new HLMkdir();
+ return hl_mkdir.run({
+ parent: node,
+ path: options.path || options.name, // Support both path and name parameters
+ actor: Context.get("actor"),
+ overwrite: options.overwrite || false, // WebDAV MKCOL should not overwrite by default
+ create_missing_parents: options.create_missing_parents !== undefined ? options.create_missing_parents : true, // Auto-create parent directories
+ shortcut_to: options.shortcut_to, // Support for shortcuts
+ user: Context.get("actor").type.user, // User context for permissions
+ });
+ },
+ delete: (node) => {
+ const hl_remove = new HLRemove();
+ return hl_remove.run({
+ target: node,
+ recursive: true,
+ user: Context.get("actor"),
+ });
+ },
+ move: (sourceNode, options) => {
+ const hl_move = new HLMove();
+ return hl_move.run({
+ source: sourceNode, // The source fileNode being moved
+ destination_or_parent: options.destinationNode, // The destination fileNode (could be parent dir or exact destination)
+ user: Context.get("actor").type.user,
+ actor: Context.get("actor"),
+ new_name: options.new_name, // New name in the destination folder
+ overwrite: options.overwrite !== undefined ? options.overwrite : false, // WebDAV overwrite is optional
+ dedupe_name: options.dedupe_name || false, // Handle name conflicts
+ create_missing_parents: options.create_missing_parents || false, // Whether to create missing parent directories
+ new_metadata: options.new_metadata, // Optional metadata updates
+ });
+ },
+ copy: (sourceNode, options) => {
+ const hl_copy = new HLCopy();
+ return hl_copy.run({
+ source: sourceNode, // The source fileNode being copied
+ destination_or_parent: options.destinationNode, // The destination fileNode (could be parent dir or exact destination)
+ user: Context.get("actor").type.user,
+ new_name: options.new_name, // New name in the destination folder
+ overwrite: options.overwrite !== undefined ? options.overwrite : false, // WebDAV overwrite is optional
+ dedupe_name: options.dedupe_name || false, // Handle name conflicts
+ });
+ }
+
+}
+
+/**
+ * Handles username/password && OTP login. Is used by and wrapped by handleHttpBasicAuth().
+ *
+ * @param {string} username
+ * @param {string} password
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ * @returns {actor|null}
+ */
+async function authenticateWebDavUser(username, password, req, res) {
+ // Default implementation - you should override this method
+ // Return null to reject authentication
+ const svc_auth = req.services.get('auth');
+
+ const user = await req.services.get('get-user').get_user({ username: username, cached: false });
+ let otpToken = null;
+ let real_password = password
+
+ if (username === "-token") {
+ return await svc_auth.authenticate_from_token(password);
+ }
+
+ if (user.otp_enabled) {
+ real_password = password.slice(0, -6);
+ otpToken = password.slice(-6);
+ }
+
+ if (await bcrypt.compare(real_password, user.password)) {
+ const { token } = await svc_auth.create_session_token(user);
+ if (user.otp_enabled) {
+ const svc_otp = req.services.get('otp');
+ const ok = svc_otp.verify(user.username, user.otp_secret, otpToken);
+ if (!ok) {
+ return null;
+ }
+ }
+
+ res.cookie(COOKIE_NAME, token, {
+ sameSite: 'none',
+ secure: true,
+ httpOnly: true,
+ maxAge: 34560000000 // 400 days, chrome maximum
+ });
+ return await svc_auth.authenticate_from_token(token);
+ }
+ return null;
+}
+
+/**
+ * Handler for HTTP BASIC username/password authentication of a puter account.
+ * It sets a puter token cookie and then returns an actor if it could successfully get one.
+ * Otherwise, it returns null and responds with an HTTP BASIC authentication request with a 401.
+ *
+ * @param {any} actor
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ * @returns {actor|null}
+ */
+async function handleHttpBasicAuth(actor, req, res) {
+
+ if (actor)
+ return actor;
+ // Check for Basic Authentication header
+ const authHeader = req.headers.authorization;
+ if (authHeader && authHeader.startsWith('Basic ')) {
+ try {
+ // Parse Basic auth credentials
+ const base64Credentials = authHeader.split(' ')[1];
+ const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
+ let [username, ...password] = credentials.split(':');
+ password = password.join(":");
+
+ // Call user's authentication function
+ actor = await authenticateWebDavUser(username, password, req, res);
+ if (!actor) {
+ // Authentication failed
+ res.set({
+ 'WWW-Authenticate': 'Basic realm="WebDAV"',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+ res.status(401).end('Unauthorized');
+ return;
+ } else {
+ return actor;
+ }
+ } catch (error) {
+ res.set({
+ 'WWW-Authenticate': 'Basic realm="WebDAV"',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+ res.status(401).end('Unauthorized');
+ return;
+ }
+ } else {
+ // No credentials provided, send challenge
+ res.set({
+ 'WWW-Authenticate': 'Basic realm="WebDAV"',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+ res.status(401).end('Unauthorized');
+ return;
+ }
+}
+
+/**
+ * A full WebDav server in one function. Takes the requested filePath, and an express.js req, res. It responds for you.
+ *
+ * @param {string} filePath
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ * @returns
+ */
+
+async function handleWebDavServer(filePath, req, res) {
+ const svc_fs = this.services.get('filesystem');
+ const fileNode = await svc_fs.node(new NodePathSelector(filePath));
+ const exists = await fileNode.exists();
+ switch (req.method) {
+ case "GET":
+ case "HEAD":
+ if (!exists) {
+ res.status(404).end('File not found');
+ return;
+ }
+
+ // Get file stats for Content-Length and other headers
+ const fileStat = await operations.stat(fileNode);
+
+ // Set appropriate headers
+ const headers = {
+ 'Accept-Ranges': 'bytes'
+ };
+
+ // Set Content-Length for files (not directories)
+ if (!fileStat.is_dir) {
+ headers['Content-Length'] = fileStat.size || 0;
+ headers['Content-Type'] = getProperMimeType(fileStat.type, fileStat.name);
+ }
+
+ // Set last modified header
+ if (fileStat.modified) {
+ headers['Last-Modified'] = new Date(fileStat.modified * 1000).toUTCString();
+ }
+
+ // Set ETag
+ headers['ETag'] = `"${fileStat.uid}-${Math.floor(fileStat.modified)}"`;
+
+ res.set(headers);
+
+ // For HEAD requests, only send headers, no body
+ if (req.method === "HEAD") {
+ res.status(200).end();
+ break;
+ }
+
+ // For GET requests, send the file content
+ if (fileStat.is_dir) {
+ res.status(400).end('Cannot GET a directory');
+ return;
+ }
+
+ const options = {
+ ...(req.headers["range"] ? { range: req.headers["range"] } : {})
+ };
+
+ const stream = await operations.read(fileNode, options);
+ stream.on("data", (data) => {
+ res.write(data);
+ });
+ stream.on("end", () => {
+ res.end();
+ });
+ stream.on("error", (error) => {
+ console.error("Stream error:", error);
+ res.status(500).end('Internal server error');
+ });
+ break;
+ case "PROPFIND":
+ // Set proper headers for WebDAV XML response
+ res.set({
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+
+ // Handle special case for /dav/ root - return static response with only admin folder
+ if (filePath === "/" || filePath === "") {
+ res.status(207);
+ // res.end(createStaticDavRootResponse());
+ const rootNode = await svc_fs.node(new NodePathSelector("/"));
+ res.end(convertMultipleToWebDAVPropfindXML(await operations.stat(rootNode), await operations.readdir(rootNode)));
+ return;
+ }
+
+ if (!exists) {
+ res.status(404).end('Not Found');
+ return;
+ }
+
+ // Handle Depth header (Windows WebDAV client compatibility)
+ const depth = req.headers.depth || '1';
+
+ const stat = await operations.stat(fileNode);
+ if (stat.is_dir && depth !== '0') {
+ res.status(207);
+ res.end(convertMultipleToWebDAVPropfindXML(stat, await operations.readdir(fileNode)));
+ } else {
+ res.status(207);
+ res.end(convertToWebDAVPropfindXML(stat));
+ }
+ break;
+ case "PUT":
+ try {
+ // Check Content-Length header
+ const contentLength = req.headers['content-length'];
+ if (!contentLength) {
+ res.status(400).end('Content-Length header required');
+ return;
+ }
+
+ const fileSize = parseInt(contentLength);
+ if (isNaN(fileSize) || fileSize < 0) {
+ res.status(400).end('Invalid Content-Length');
+ return;
+ }
+
+ // Check if file exists before writing (for proper status code)
+ const existedBefore = exists;
+
+ // Set Content-Type if provided
+ const contentType = req.headers['content-type'];
+
+ // Prepare write options
+ const writeOptions = {
+ stream: req, // Express request object is a readable stream
+ size: fileSize,
+ overwrite: true, // PUT should always overwrite
+ create_missing_parents: true, // Create directories as needed
+ no_thumbnail: true, // Disable thumbnails for WebDAV
+ };
+
+ // If Content-Type is provided, include it in file metadata
+ if (contentType) {
+ writeOptions.file = {
+ mimetype: contentType
+ };
+ }
+
+ // Write the file
+ const result = await operations.write(fileNode, writeOptions);
+
+ // Set response headers
+ res.set({
+ 'ETag': `"${result.uid}-${Math.floor(result.modified)}"`,
+ 'Last-Modified': new Date(result.modified * 1000).toUTCString()
+ });
+
+ // Return appropriate status code
+ if (existedBefore) {
+ res.status(204).end(); // 204 No Content for updated file
+ } else {
+ res.status(201).end(); // 201 Created for new file
+ }
+ } catch (error) {
+ // Handle specific error types
+ if (error.code === 'item_with_same_name_exists') {
+ res.status(409).end('Conflict: Item already exists');
+ } else if (error.code === 'storage_limit_reached') {
+ res.status(507).end('Insufficient Storage');
+ } else if (error.code === 'permission_denied') {
+ res.status(403).end('Forbidden');
+ } else if (error.code === 'file_too_large') {
+ res.status(413).end('Request Entity Too Large');
+ } else {
+ res.status(500).end('Internal Server Error');
+ }
+ }
+ break;
+ case "MKCOL":
+ try {
+ // Check if request has a body (not allowed for MKCOL)
+ const contentLength = req.headers['content-length'];
+ if (contentLength && parseInt(contentLength) > 0) {
+ res.status(415).end('Unsupported Media Type');
+ return;
+ }
+
+ // Parse the path to get parent directory and target name
+ const targetPath = filePath;
+ const parentPath = path.dirname(targetPath);
+ const targetName = path.basename(targetPath);
+
+ // Handle root directory case
+ if (parentPath === '.' || targetPath === '/') {
+ res.status(403).end('Forbidden');
+ return;
+ }
+
+ // Check if target already exists
+ if (exists) {
+ res.status(405).end('Method Not Allowed');
+ return;
+ }
+
+ // Get parent directory node
+ const parentNode = await svc_fs.node(new NodePathSelector(parentPath));
+ const parentExists = await parentNode.exists();
+
+ if (!parentExists) {
+ res.status(409).end('Conflict');
+ return;
+ }
+
+ // Verify parent is a directory
+ const parentStat = await operations.stat(parentNode);
+ if (!parentStat.is_dir) {
+ res.status(409).end('Conflict');
+ return;
+ }
+
+ // Create the directory
+ const result = await operations.mkdir(parentNode, {
+ name: targetName,
+ overwrite: false,
+ create_missing_parents: false
+ });
+
+ // Set response headers
+ res.set({
+ 'Location': `/dav${targetPath}${targetPath.endsWith('/') ? '' : '/'}`,
+ 'Content-Length': '0'
+ });
+
+ res.status(201).end(); // 201 Created
+ } catch (error) {
+ // Handle specific error types
+ if (error.code === 'item_with_same_name_exists') {
+ res.status(405).end('Method Not Allowed');
+ } else if (error.code === 'permission_denied') {
+ res.status(403).end('Forbidden');
+ } else if (error.code === 'dest_does_not_exist') {
+ res.status(409).end('Conflict');
+ } else if (error.code === 'invalid_file_name') {
+ res.status(400).end('Bad Request');
+ } else {
+ res.status(500).end('Internal Server Error');
+ }
+ }
+ break;
+ case "PROPPATCH":
+ // Stub implementation for PROPPATCH - always returns success
+ // Our filesystem doesn't support extended attributes, so we just
+ // pretend that property updates succeed
+ try {
+ // Set proper headers for WebDAV XML response
+ res.set({
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+
+ // Return a generic success response
+ // In a real implementation, we would parse the request body and
+ // return specific success/failure for each property
+ const stubResponse = `
+
+
+ /dav${escapeXml(filePath)}
+
+
+ HTTP/1.1 200 OK
+
+
+`;
+
+ res.status(207);
+ res.end(stubResponse);
+
+ } catch (error) {
+ res.status(500).end('Internal Server Error');
+ }
+ break;
+ case "DELETE":
+ try {
+ // Check if the resource exists
+ if (!exists) {
+ res.status(404).end('Not Found');
+ return;
+ }
+
+ // Delete the resource using operations.delete
+ await operations.delete(fileNode);
+
+ // Return success response
+ res.status(204).end(); // 204 No Content for successful deletion
+ } catch (error) {
+ // Handle specific error types
+ if (error.code === 'permission_denied') {
+ res.status(403).end('Forbidden');
+ } else if (error.code === 'immutable') {
+ res.status(403).end('Forbidden');
+ } else if (error.code === 'dir_not_empty') {
+ res.status(409).end('Conflict');
+ } else {
+ res.status(500).end('Internal Server Error');
+ }
+ }
+ break;
+ case "MOVE":
+ try {
+ // Check if the resource exists
+ if (!exists) {
+ res.status(404).end('Not Found');
+ return;
+ }
+
+ // Parse Destination header (required for MOVE)
+ const destinationHeader = req.headers.destination;
+ if (!destinationHeader) {
+ res.status(400).end('Bad Request: Destination header required');
+ return;
+ }
+
+ // Parse destination URI - extract path after /dav
+ let destinationPath;
+ try {
+ const destUrl = new URL(destinationHeader, `http://${req.headers.host}`);
+ if (!destUrl.pathname.startsWith('/dav/')) {
+ res.status(400).end('Bad Request: Destination must be within WebDAV namespace');
+ return;
+ }
+ destinationPath = destUrl.pathname.substring(4); // Remove '/dav' prefix
+ if (!destinationPath.startsWith('/')) {
+ destinationPath = '/' + destinationPath;
+ }
+ } catch (error) {
+ res.status(400).end('Bad Request: Invalid destination URI');
+ return;
+ }
+
+ // Parse Overwrite header (T = true, F = false, default = T)
+ const overwriteHeader = req.headers.overwrite;
+ const overwrite = overwriteHeader !== 'F'; // Default to true unless explicitly F
+
+ // Parse destination path to get parent and new name
+ const destParentPath = path.dirname(destinationPath);
+ const destName = path.basename(destinationPath);
+
+ // Check if destination already exists
+ const destNode = await svc_fs.node(new NodePathSelector(destinationPath));
+ const destExists = await destNode.exists();
+
+ if (destExists && !overwrite) {
+ res.status(412).end('Precondition Failed: Destination exists and Overwrite is F');
+ return;
+ }
+
+ // Get destination parent node
+ const destParentNode = await svc_fs.node(new NodePathSelector(destParentPath));
+ const destParentExists = await destParentNode.exists();
+
+ if (!destParentExists) {
+ res.status(409).end('Conflict: Destination parent does not exist');
+ return;
+ }
+
+ // Verify destination parent is a directory
+ const destParentStat = await operations.stat(destParentNode);
+ if (!destParentStat.is_dir) {
+ res.status(409).end('Conflict: Destination parent is not a directory');
+ return;
+ }
+
+ // Perform the move operation
+ const result = await operations.move(fileNode, {
+ destinationNode: destParentNode,
+ new_name: destName,
+ overwrite: overwrite,
+ dedupe_name: false, // WebDAV should not auto-dedupe
+ create_missing_parents: false
+ });
+
+ // Set response headers
+ if (destExists) {
+ res.status(204).end(); // 204 No Content for overwrite
+ } else {
+ res.status(201).end(); // 201 Created for new resource
+ }
+ } catch (error) {
+ // Handle specific error types
+ if (error.code === 'permission_denied') {
+ res.status(403).end('Forbidden');
+ } else if (error.code === 'item_with_same_name_exists') {
+ res.status(412).end('Precondition Failed: Destination exists');
+ } else if (error.code === 'immutable') {
+ res.status(403).end('Forbidden: Resource is immutable');
+ } else if (error.code === 'dest_does_not_exist') {
+ res.status(409).end('Conflict: Destination parent does not exist');
+ } else {
+ res.status(500).end('Internal Server Error');
+ }
+ }
+ break;
+ case "COPY":
+ try {
+ // Check if the resource exists
+ if (!exists) {
+ res.status(404).end('Not Found');
+ return;
+ }
+
+ // Parse Destination header (required for COPY)
+ const destinationHeader = req.headers.destination;
+ if (!destinationHeader) {
+ res.status(400).end('Bad Request: Destination header required');
+ return;
+ }
+
+ // Parse destination URI - extract path after /dav
+ let destinationPath;
+ try {
+ const destUrl = new URL(destinationHeader, `http://${req.headers.host}`);
+ if (!destUrl.pathname.startsWith('/dav/')) {
+ res.status(400).end('Bad Request: Destination must be within WebDAV namespace');
+ return;
+ }
+ destinationPath = destUrl.pathname.substring(4); // Remove '/dav' prefix
+ if (!destinationPath.startsWith('/')) {
+ destinationPath = '/' + destinationPath;
+ }
+ } catch (error) {
+ res.status(400).end('Bad Request: Invalid destination URI');
+ return;
+ }
+
+ // Parse Overwrite header (T = true, F = false, default = T)
+ const overwriteHeader = req.headers.overwrite;
+ const overwrite = overwriteHeader !== 'F'; // Default to true unless explicitly F
+
+ // Parse destination path to get parent and new name
+ const destParentPath = path.dirname(destinationPath);
+ const destName = path.basename(destinationPath);
+
+ // Check if destination already exists
+ const destNode = await svc_fs.node(new NodePathSelector(destinationPath));
+ const destExists = await destNode.exists();
+
+ if (destExists && !overwrite) {
+ res.status(412).end('Precondition Failed: Destination exists and Overwrite is F');
+ return;
+ }
+
+ // Get destination parent node
+ const destParentNode = await svc_fs.node(new NodePathSelector(destParentPath));
+ const destParentExists = await destParentNode.exists();
+
+ if (!destParentExists) {
+ res.status(409).end('Conflict: Destination parent does not exist');
+ return;
+ }
+
+ // Verify destination parent is a directory
+ const destParentStat = await operations.stat(destParentNode);
+ if (!destParentStat.is_dir) {
+ res.status(409).end('Conflict: Destination parent is not a directory');
+ return;
+ }
+
+ // Perform the copy operation
+ const result = await operations.copy(fileNode, {
+ destinationNode: destParentNode,
+ new_name: destName,
+ overwrite: overwrite,
+ dedupe_name: false, // WebDAV should not auto-dedupe
+ });
+
+ // Set response headers
+ if (destExists) {
+ res.status(204).end(); // 204 No Content for overwrite
+ } else {
+ res.status(201).end(); // 201 Created for new resource
+ }
+ } catch (error) {
+ // Handle specific error types
+ if (error.code === 'permission_denied') {
+ res.status(403).end('Forbidden');
+ } else if (error.code === 'item_with_same_name_exists') {
+ res.status(412).end('Precondition Failed: Destination exists');
+ } else if (error.code === 'immutable') {
+ res.status(403).end('Forbidden: Resource is immutable');
+ } else if (error.code === 'dest_does_not_exist') {
+ res.status(409).end('Conflict: Destination parent does not exist');
+ } else {
+ res.status(500).end('Internal Server Error');
+ }
+ }
+ break;
+ case "LOCK":
+ // Stub implementation for LOCK - always returns a fake lock token
+ // Puter doesn't support file locking, so we pretend to lock successfully
+ try {
+ // Check if the resource exists
+ if (!exists) {
+ res.status(404).end('Not Found');
+ return;
+ }
+
+ // Generate a fake lock token
+ const lockToken = `opaquelocktoken:${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+ // Set proper headers for WebDAV XML response
+ res.set({
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'Lock-Token': `<${lockToken}>`,
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+
+ // Return a fake lock response
+ const lockResponse = `
+
+
+
+
+
+ 0
+
+ webdav-user
+
+ Second-7200
+
+ ${lockToken}
+
+
+ /dav${escapeXml(filePath)}
+
+
+
+`;
+
+ res.status(200);
+ res.end(lockResponse);
+ } catch (error) {
+ res.status(500).end('Internal Server Error');
+ }
+ break;
+ case "UNLOCK":
+ // Stub implementation for UNLOCK - always returns success
+ // Puter doesn't support file locking, so we pretend to unlock successfully
+ try {
+ // Check if the resource exists
+ if (!exists) {
+ res.status(404).end('Not Found');
+ return;
+ }
+
+ // Check for Lock-Token header (normally required for UNLOCK)
+ const lockToken = req.headers['lock-token'];
+ if (!lockToken) {
+ res.status(400).end('Bad Request: Lock-Token header required');
+ return;
+ }
+
+ // Always return success since we don't actually track locks
+ res.status(204).end(); // 204 No Content for successful unlock
+ } catch (error) {
+ res.status(500).end('Internal Server Error');
+ }
+ break;
+ default:
+ // Method not allowed
+ res.set({
+ 'Allow': 'OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+ res.status(405).end('Method Not Allowed');
+ break;
+ }
+}
+
+
+class WebDavFS extends BaseService {
+ async init() {
+ const svc_web = this.services('web');
+ svc_web.allow_undefined_origin(/^\/dav(\/.*)?$/);;
+ }
+
+ ['__on_install.routes'](_, { app }) {
+ COOKIE_NAME = this.global_config.cookie_name
+
+ const r_webdav = (() => {
+ const require = this.require;
+ const express = require('express');
+ return express.Router();
+ })();
+
+ app.use('/dav', r_webdav);
+
+ Endpoint({
+ route: '/*',
+ methods: ["PROPFIND", "PROPPATCH", "MKCOL", "GET", "HEAD", "POST", "PUT", "DELETE", "COPY", "MOVE", "LOCK", "UNLOCK"],
+ mw: [configurable_auth({ optional: true })],
+ /**
+ *
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ */
+ handler: async (req, res) => {
+ const svc_su = this.services.get("su")
+ let actor = await handleHttpBasicAuth(req.actor, req, res);
+ if (!actor) return;
+
+ let filePath = decodeURIComponent(req.path)
+ // Handle root path for WebDAV compatibility
+ if (filePath === "/" || filePath === "") {
+ filePath = "/"; // Keep as root for WebDAV
+ }
+
+ svc_su.sudo(actor, async ()=> {
+ handleWebDavServer(filePath, req, res);
+ })
+
+ }
+
+ }).attach(r_webdav);
+
+ const r_rootdav = (() => {
+ const require = this.require;
+ const express = require('express');
+ return express.Router();
+ })();
+ app.use('/', r_rootdav);
+ Endpoint({
+ route: "/*",
+ methods: ["PROPFIND"],
+ mw: [configurable_auth({ optional: true })],
+ /**
+ *
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ */
+ handler: async (req, res) => {
+ const svc_su = this.services.get("su");
+
+ let actor = await handleHttpBasicAuth(req.actor, req, res);
+ if (!actor) return;
+
+ if (req.path !== "/" && !req.path.startsWith("/dav")) {
+ return res.status(404).end('Not Found');
+ }
+ if (req.path === "/dav") {
+ svc_su.sudo(actor, async () => {
+ handleWebDavServer("/", req, res);
+ })
+ }
+
+ // Set proper headers for WebDAV XML response
+ res.set({
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'DAV': '1, 2',
+ 'MS-Author-Via': 'DAV'
+ });
+
+ res.status(207);
+ res.end(createRootWebDAVResponse());
+
+ }
+
+ }).attach(r_rootdav);
+ }
+}
+
+module.exports = {
+ WebDavFS,
+};