feat: DAV Locks support + refactor of DAV method handler (#1486)

* feat: DAV Locks support + refactor of DAV method handler

* format: Restore old whitespace for CoreModule.js

* fix: options route registering after likecycle hooks

---------

Co-authored-by: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com>
This commit is contained in:
Daniel Salazar
2025-09-26 11:00:29 -04:00
committed by GitHub
co-authored by Neal Shah
parent 7bfa85d4fe
commit d70d412115
24 changed files with 2937 additions and 2869 deletions
+45 -80
View File
@@ -4,6 +4,48 @@ import { defineConfig } from 'eslint/config';
import globals from 'globals';
import controlStructureSpacing from './control-structure-spacing.js';
const rules = {
'no-unused-vars': ['error', {
'vars': 'all',
'args': 'after-used',
'caughtErrors': 'all',
'ignoreRestSiblings': false,
'ignoreUsingDeclarations': false,
'reportUsedIgnorePattern': false,
'argsIgnorePattern': '^_',
'caughtErrorsIgnorePattern': '^_',
'destructuredArrayIgnorePattern': '^_',
}],
'@stylistic/curly-newline': ['error', 'always'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/indent': ['error', 4, {
'CallExpression': { arguments: 4 },
}],
'@stylistic/indent-binary-ops': ['error', 4],
'@stylistic/array-bracket-newline': ['error', 'consistent'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single'],
'@stylistic/function-call-argument-newline': ['error', 'consistent'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/space-before-function-paren': ['error', { 'anonymous': 'never', 'named': 'never', 'asyncArrow': 'always', 'catch': 'never' }],
'@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }],
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/space-infix-ops': ['error'],
'no-template-curly-in-string': 'error',
'prefer-template': 'error',
'no-undef': 'error',
'no-useless-concat': 'error',
'template-curly-spacing': ['error', 'never'],
curly: ['error', 'multi-line'],
'custom/control-structure-spacing': 'error',
'@stylistic/no-trailing-spaces': 'error',
};
export default defineConfig([
{
plugins: {
@@ -13,49 +55,9 @@ export default defineConfig([
},
},
{
files: ['src/backend/**/*.{js,mjs,cjs}'],
files: ['**/backend/**/*.{js,mjs,cjs}'],
languageOptions: { globals: globals.node },
rules: {
'no-unused-vars': ['error', {
'vars': 'all',
'args': 'after-used',
'caughtErrors': 'all',
'ignoreRestSiblings': false,
'ignoreUsingDeclarations': false,
'reportUsedIgnorePattern': false,
'argsIgnorePattern': '^_',
'caughtErrorsIgnorePattern': '^_',
'destructuredArrayIgnorePattern': '^_',
}],
curly: ['error', 'multi-line'],
'@stylistic/curly-newline': ['error', 'always'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/indent': ['error', 4, {
CallExpression: {
arguments: 4,
},
}],
'@stylistic/indent-binary-ops': ['error', 4],
'@stylistic/array-bracket-newline': ['error', 'consistent'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': 'off',
'@stylistic/function-call-argument-newline': ['error', 'consistent'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/space-before-function-paren': ['error', { 'anonymous': 'never', 'named': 'never', 'asyncArrow': 'always', 'catch': 'never' }],
'@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }],
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/space-infix-ops': ['error'],
'no-undef': 'error',
'custom/control-structure-spacing': 'error',
'@stylistic/no-trailing-spaces': 'error',
},
rules,
extends: ['js/recommended'],
plugins: {
js,
@@ -66,44 +68,7 @@ export default defineConfig([
files: ['**/*.{js,mjs,cjs}'],
ignores: ['src/backend/**/*.{js,mjs,cjs}'],
languageOptions: { globals: globals.browser },
rules: {
'no-unused-vars': ['error', {
'vars': 'all',
'args': 'after-used',
'caughtErrors': 'all',
'ignoreRestSiblings': false,
'ignoreUsingDeclarations': false,
'reportUsedIgnorePattern': false,
'argsIgnorePattern': '^_',
'caughtErrorsIgnorePattern': '^_',
'destructuredArrayIgnorePattern': '^_',
}],
'@stylistic/curly-newline': ['error', 'always'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/indent': ['error', 4, {
'CallExpression': { arguments: 4 },
}],
'@stylistic/indent-binary-ops': ['error', 4],
'@stylistic/array-bracket-newline': ['error', 'consistent'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single'],
'@stylistic/function-call-argument-newline': ['error', 'consistent'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/space-before-function-paren': ['error', { 'anonymous': 'never', 'named': 'never', 'asyncArrow': 'always', 'catch': 'never' }],
'@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }],
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/space-infix-ops': ['error'],
'no-undef': 'error',
curly: ['error', 'multi-line'],
'custom/control-structure-spacing': 'error',
'@stylistic/no-trailing-spaces': 'error',
},
rules,
extends: ['js/recommended'],
plugins: {
js,
+1128 -1451
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -18,7 +18,7 @@
"dotenv": "^16.4.5",
"eslint": "^9.35.0",
"express": "^4.18.2",
"globals": "^15.0.0",
"globals": "^15.15.0",
"html-entities": "^2.3.3",
"html-webpack-plugin": "^5.6.0",
"husky": "^9.1.7",
@@ -54,7 +54,9 @@
"@google/genai": "^1.19.0",
"@heyputer/putility": "^1.0.2",
"@paralleldrive/cuid2": "^2.2.2",
"@stylistic/eslint-plugin-js": "^4.4.1",
"dedent": "^1.5.3",
"express-xml-bodyparser": "^0.4.1",
"ioredis": "^5.6.0",
"javascript-time-ago": "^2.5.11",
"json-colorizer": "^3.0.1",
+1 -1
View File
@@ -390,7 +390,7 @@ const install = async ({ services, app, useapi, modapi }) => {
services.registerService('wisp', WispService);
// const { AWSSecretsPopulator } = require('./services/AWSSecretsPopulator.js');
// services.registerService('awsthing', AWSSecretsPopulator);
const { WebDavFS } = require('./services/WebDavFS');
const { WebDavFS } = require('./services/WebDAV/WebDAVService.js');
services.registerService('dav', WebDavFS);
const { RequestMeasureService } = require('./services/RequestMeasureService');
@@ -21,15 +21,15 @@ const BaseService = require('../../services/BaseService');
/**
* @typedef {Object} KVStoreInterface
* @property {(opts: KVStoreGetParams) => Promise<Record<string, unknonw>>} get - Retrieve the value(s) for the given key(s).
* @property {(opts: KVStoreSetParams) => Promise<void>} set - Set a value for a key, with optional expiration.
* @property {(opts: KVStoreDelParams) => Promise<void>} del - Delete a value by key.
* @property {(opts: KVStoreListParams) => Promise<string[]>} list - List all key-value pairs, optionally as a specific type.
* @property {() => Promise<void>} flush - Delete all key-value pairs in the store.
* @property {(opts: KVStoreIncrDecrParams) => Promise<number>} incr - Increment a numeric value by key.
* @property {(opts: KVStoreIncrDecrParams) => Promise<number>} decr - Decrement a numeric value by key.
* @property {(opts: KVStoreExpireAtParams) => Promise<number>} expireAt - Set a key to expire at a specific UNIX timestamp (seconds).
* @property {(opts: KVStoreExpireParams) => Promise<number>} expire - Set a key to expire after a given TTL (seconds).
* @property {function(KVStoreGetParams): Promise<Record<string, unknonw>>} get - Retrieve the value(s) for the given key(s).
* @property {function(KVStoreSetParams): Promise<void>} set - Set a value for a key, with optional expiration.
* @property {function(KVStoreDelParams): Promise<void>} del - Delete a value by key.
* @property {function(KVStoreListParams): Promise<string[]>} list - List all key-value pairs, optionally as a specific type.
* @property {function(): Promise<void>} flush - Delete all key-value pairs in the store.
* @property {function(KVStoreIncrDecrParams): Promise<number>} incr - Increment a numeric value by key.
* @property {function(KVStoreIncrDecrParams): Promise<number>} decr - Decrement a numeric value by key.
* @property {function(KVStoreExpireAtParams): Promise<number>} expireAt - Set a key to expire at a specific UNIX timestamp (seconds).
* @property {function(KVStoreExpireParams): Promise<number>} expire - Set a key to expire after a given TTL (seconds).
*
* @typedef {Object} KVStoreGetParams
* @property {string|string[]} key - The key or array of keys to retrieve.
@@ -61,8 +61,8 @@ const BaseService = require('../../services/BaseService');
/**
* Service for registering the puter-kvstore interface, exposing a simple key-value store API
* with support for get, set, delete, list, flush, increment, decrement, and key expiration.
* @extends BaseService
*/
* @extends BaseService
*/
class KVStoreInterfaceService extends BaseService {
/**
* Service class for managing KVStore interface registrations.
@@ -80,7 +80,12 @@ class WebServerService extends BaseService {
router_webhooks: this.router_webhooks,
});
await services.emit('install.routes-gui', { app });
// Register after other services registers theirs: Options for all requests (for CORS)
app.options('/*', (_req, res) => {
return res.sendStatus(200);
});
this.log.noticeme('web server setup done');
}
@@ -664,25 +669,6 @@ class WebServerService extends BaseService {
next();
});
// Options for all requests (for CORS)
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();
}
return res.sendStatus(200);
});
}
_register_commands (commands) {
@@ -0,0 +1,312 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
const { NodePathSelector } = require('../../filesystem/node/selectors');
const configurable_auth = require('../../middleware/configurable_auth');
const { Endpoint } = require('../../util/expressutil');
const BaseService = require('../BaseService');
const bcrypt = require('bcrypt');
const xmlparser = require('express-xml-bodyparser');
let davMethodMap;
let unsupportedMethodHandler;
let COOKIE_NAME = null;
const ROOT_WEB_DAV_RESPONSE_XML = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/</D:href>
<D:propstat>
<D:prop>
<D:displayname>/</D:displayname>
<D:getlastmodified>Fri, 03 Jan 2025 10:30:45 GMT</D:getlastmodified>
<D:creationdate>2025-01-03T10:30:45Z</D:creationdate>
<D:resourcetype><D:collection/></D:resourcetype>
<D:getetag>"dav-folder-1735898444"</D:getetag>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:ishidden>0</D:ishidden>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dav/</D:href>
<D:propstat>
<D:prop>
<D:displayname>dav</D:displayname>
<D:getlastmodified>Fri, 03 Jan 2025 10:30:45 GMT</D:getlastmodified>
<D:creationdate>2025-01-03T10:30:45Z</D:creationdate>
<D:resourcetype><D:collection/></D:resourcetype>
<D:getetag>"dav-folder-1735898445"</D:getetag>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:ishidden>0</D:ishidden>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
class WebDAVService extends BaseService {
async _construct(){
davMethodMap = (await import ( './methodHandlers/methodMap.mjs')).davMethodMap;
unsupportedMethodHandler = (await import('./methodHandlers/method.mjs')).unsupportedMethodHandler;
}
async _init() {
const svc_web = this.services.get('web-server');
svc_web.allow_undefined_origin(/^\/dav(\/.*)?$/);
}
#extractHeaderToken = ( headerToken = '' ) => {
let headerLockToken = null;
let prefix = null;
const match = headerToken.match(/(.*)<(urn:uuid:[0-9a-fA-F-]{36})>/);
if ( match ) {
if ( match.length > 2 ) {
headerLockToken = match[2];
prefix = match[1].trim().slice( 1, -1); // Remove surrounding parentheses
} else {
headerLockToken = match[1];
}
}
return { headerLockToken, prefix };
};
async authenticateWebDavUser( username, password, _req, res ) {
// Default implementation - you should override this method
// Return null to reject authentication
const svc_auth = this.services.get('auth');
const user = await this.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 = this.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;
}
async 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 this.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( _e ) {
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;
}
}
async handleWebDavServer( filePath, req, res ) {
const svc_fs = this.services.get('filesystem');
const fileNode = await svc_fs.node(new NodePathSelector(filePath));
// Extract the UUID from the If header (e.g., If: (<urn:uuid:...>))
const ifHeader = req.headers['if'];
const { headerLockToken } = this.#extractHeaderToken(ifHeader);
const methodHandler =
davMethodMap[req.method] ?? unsupportedMethodHandler;
methodHandler(req, res, filePath, fileNode, headerLockToken);
}
['__on_install.routes']( _, { app } ) {
COOKIE_NAME = this.global_config.cookie_name;
const r_webdav = (() => {
const express = require('express');
return express.Router();
} )();
r_webdav.use(xmlparser());
app.use('/dav', r_webdav);
Endpoint({
route: '/*',
methods: [
'PROPFIND',
'PROPPATCH',
'MKCOL',
'GET',
'HEAD',
'POST',
'PUT',
'DELETE',
'COPY',
'MOVE',
'LOCK',
'UNLOCK',
'OPTIONS',
],
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 this.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 () => {
this.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 this.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 () => {
this.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(ROOT_WEB_DAV_RESPONSE_XML);
},
}).attach( r_rootdav);
}
}
module.exports = {
WebDavFS: WebDAVService,
};
@@ -0,0 +1,160 @@
export const DAV_LOCK_DURATION = 30; // seconds
/*
* @param {string} headerToken
* @returns
*/
export const extractHeaderToken = (headerToken = '') => {
let headerLockToken = null;
let prefix = null;
const match = headerToken.match(/(.*)<(urn:uuid:[0-9a-fA-F-]{36})>/);
if ( match ) {
if ( match.length > 2 ) {
headerLockToken = match[2];
prefix = match[1].trim().slice( 1, -1); // Remove surrounding parentheses
} else {
headerLockToken = match[1];
}
}
return { headerLockToken, prefix };
};
const LOCK_PREFIX = 'locktoken:';
/**
* @param {{sudo:Function}} suService
* @param {import('../../modules/kvstore/KVStoreInterfaceService.js').KVStoreInterface} kvStoreService
* @param {...string} lockTokens
* @returns {Promise<{path: string, lockScope: 'shared' | 'exclusive', lockType?: string}[]>}
*/
export const getLocksIfValid = (suService, kvStoreService, ...lockTokens) => {
return suService.sudo(async () => {
const res = (await kvStoreService.get({
key: lockTokens.map(lockToken => `${LOCK_PREFIX}${lockToken}`),
})).filter(Boolean);
return res;
});
};
/**
* @param {{sudo:Function}} suService
* @param {import('../../modules/kvstore/KVStoreInterfaceService.js').KVStoreInterface} kvStoreService
* @param {string} filePath
* @param {string} lockScope
* @param {string} lockType
* @returns {Promise<string>}
*/
export const createLock = ( suService, kvStoreService, filePath, lockScope, lockType ) => {
return suService.sudo(async () => {
const lockToken = `urn:uuid:${crypto.randomUUID()}`;
const currentTokens = await getFileLocks(suService, kvStoreService, filePath);
kvStoreService.set({
key: `${LOCK_PREFIX}${lockToken}`,
value: { path: filePath, lockScope, lockType },
expireAt: (Date.now() / 1000) + DAV_LOCK_DURATION,
});
kvStoreService.set({
key: `${LOCK_PREFIX}${filePath}`,
value: { ...currentTokens, [lockToken]: { lockScope, lockType } },
expireAt: (Date.now() / 1000) + DAV_LOCK_DURATION,
});
return lockToken;
});
};
/**
* @param {{sudo:Function}} suService
* @param {import('../../modules/kvstore/KVStoreInterfaceService.js').KVStoreInterface} kvStoreService
* @param {string} lockToken
* @param {string} filePath
* @returns {void}
*/
export const deleteLock = ( suService, kvStoreService, lockToken, filePath ) => {
return suService.sudo(async () => {
kvStoreService.del({ key: `${LOCK_PREFIX}${lockToken}` });
kvStoreService.del({ key: `${LOCK_PREFIX}${filePath}` });
});
};
/**
* @param {{sudo:Function}} suService
* @param {import('../../modules/kvstore/KVStoreInterfaceService.js').KVStoreInterface} kvStoreService
* @param {string} lockToken
* @param {string} filePath
* @returns
*/
export const refreshLock = ( suService, kvStoreService, lockToken, filePath ) => {
return suService.sudo(async () => {
kvStoreService.expireAt({
key: `${LOCK_PREFIX}${lockToken}`,
timestamp: (Date.now() / 1000 ) + DAV_LOCK_DURATION,
});
kvStoreService.expireAt({
key: `${LOCK_PREFIX}${filePath}`,
timestamp: (Date.now() / 1000 ) + DAV_LOCK_DURATION,
});
return lockToken;
});
};
/**
* @param {{sudo:Function}} suService
* @param {import('../../modules/kvstore/KVStoreInterfaceService.js').KVStoreInterface} kvStoreService
* @param {string} filePath
* @returns {Promise<{lockToken: string, lockScope: 'shared' | 'exclusive', lockType?: string}[]>}
*/
export const getFileLocks = ( suService, kvStoreService, filePath ) => {
return suService.sudo(async () => {
const parentPaths = filePath.split('/');
const filePaths = parentPaths.map((_, i, paths) => `${LOCK_PREFIX}${paths.slice(0, i + 1).join('/')}`).filter(Boolean);
const tokenMapList = await kvStoreService.get({
key: filePaths.slice(2),
});
return tokenMapList.flatMap(tokenMap => Object.entries(tokenMap ?? {}).map(([ lockToken, lockInfo ]) => ({
lockToken: lockToken.replace(LOCK_PREFIX, ''),
...lockInfo,
}))).filter(Boolean);
});
};
/**
* @param {{sudo:Function}} suService
* @param {import('../../modules/kvstore/KVStoreInterfaceService.js').KVStoreInterface} kvStoreService
* @param {string} filePath
* @param {string} headerLockToken
* @returns {Promise<boolean>}
*/
export const hasWritePermissionInDAV = async ( suService, kvStoreService, filePath, headerLockToken ) => {
// if no lock on file, allow write
const locksOnFile = await getFileLocks(suService, kvStoreService, filePath);
if ( !locksOnFile?.length ) {
return true;
}
if ( !headerLockToken ) {
return false;
}
const existingFileFromLock = (await getLocksIfValid(suService, kvStoreService, headerLockToken))?.pop();
if ( !filePath.startsWith(existingFileFromLock.path) ) {
return false;
}
const lock = locksOnFile.find(( l ) => l.lockToken === headerLockToken);
if ( !lock ) {
return false;
}
if ( lock.lockScope === 'exclusive' ) {
// only 1 exclusive lock can exist, and headerLockToken matches it, allow write
return true;
}
// if lock(s) on file are shared locks, and headerLockToken is one of them, allow write
if ( lock.lockScope === 'shared' ) {
// this lock should not exist if there are any exclusive locks
return locksOnFile.find(( l ) => l.lockScope === 'exclusive') === undefined;
}
// else, deny write
return false;
};
@@ -0,0 +1,115 @@
import path from 'path';
import { NodePathSelector } from '../../../filesystem/node/selectors.js';
import { hasWritePermissionInDAV } from '../lockStore.mjs';
import { fsOperations } from '../utils.mjs';
/**
* @type {import('./method.mjs').HandlerFunction}
*/
export const COPY = async ( req, res, _filePath, fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const svc_fs = req.services.get('filesystem');
const exists = await fileNode?.exists();
// 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( _e ) {
res.status(400).end( 'Bad Request: Invalid destination URI');
return;
}
destinationPath = decodeURI(destinationPath);
// 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 fsOperations.stat(destParentNode);
if ( !destParentStat.is_dir ) {
res.status(409).end( 'Conflict: Destination parent is not a directory');
return;
}
// check lock
const hasDestinationWriteAccess = await hasWritePermissionInDAV(...servicesForLocks, destinationPath, headerLockToken);
if ( !hasDestinationWriteAccess ) {
// DAV lock in place blocking write to this file
res.status(423).end( 'Locked: No write access to destination');
}
// Perform the copy operation
await fsOperations.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 {
console.error('LOCK error:', error);
res.status(500).end( 'Internal Server Error');
}
}
};
@@ -0,0 +1,43 @@
import { hasWritePermissionInDAV } from '../lockStore.mjs';
import { fsOperations } from '../utils.mjs';
/**
* Handler for the DELETE HTTP method in WebDAV.
* @type {import('./method.mjs').HandlerFunction}
*/
export const DELETE = async ( req, res, filePath, fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const hasDestinationWriteAccess = await hasWritePermissionInDAV(...servicesForLocks, filePath, headerLockToken);
const exists = await fileNode?.exists();
// Check if the resource exists
if ( !exists ) {
res.status(404).end('Not Found');
return;
}
if ( !hasDestinationWriteAccess ){
// DAV lock in place blocking write to this file
res.status(423).end('Locked: No write access to destination');
return;
}
// Delete the resource using operations.delete
await fsOperations.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 {
console.error('LOCK error:', error);
res.status(500).end( 'Internal Server Error');
}
}
};
@@ -0,0 +1,133 @@
import { fsOperations, getProperMimeType } from '../utils.mjs';
const parseRangeHeader = ( rangeHeader ) => {
// Check if this is a multipart range request
if ( rangeHeader.includes(',') ) {
// For now, we'll only serve the first range in multipart requests
// as the underlying storage layer doesn't support multipart responses
const firstRange = rangeHeader.split(',')[0].trim();
const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
if ( !matches ) {
return null;
}
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: true };
}
// Single range request
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if ( !matches ) {
return null;
}
const start = parseInt(matches[1], 10);
const end = matches[2] ? parseInt(matches[2], 10) : null;
return { start, end, isMultipart: false };
};
/**
* @type {import('./method.mjs').HandlerFunction}
*/
export const HEAD_GET = async ( req, res, _filePath, fileNode, _headerLockToken ) => {
try {
const exists = await fileNode?.exists();
if ( !exists ) {
res.status(404).end( 'File not found');
return;
}
// Get file stats for Content-Length and other headers
const fileStat = await fsOperations.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();
return;
}
// For GET requests, send the file content
if ( fileStat.is_dir ) {
res.status(400).end( 'Cannot GET a directory');
return;
}
const options = {};
if ( req.headers['range'] ) {
res.status(206);
options.range = req.headers['range'];
// Parse the Range header and set Content-Range
const rangeInfo = parseRangeHeader(req.headers['range']);
if ( rangeInfo ) {
const { start, end, isMultipart } = rangeInfo;
// For open-ended ranges, we need to calculate the actual end byte
let actualEnd = end;
let fileSize = null;
try {
fileSize = fileStat.size;
if ( end === null ) {
actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
}
} catch( _error ) {
// If we can't get file size, we'll let the storage layer handle it
// and not set Content-Range header
actualEnd = null;
fileSize = null;
}
if ( actualEnd !== null ) {
const totalSize = fileSize !== null ? fileSize : '*';
const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
res.set('Content-Range', contentRange);
}
// If this was a multipart request, modify the range header to only include the first range
if ( isMultipart ) {
req.headers['range'] = end !== null ? `bytes=${start}-${end}` : `bytes=${start}-`;
}
}
}
const stream = await fsOperations.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');
});
} catch( error ) {
console.error('HEAD or GET error:', error);
res.status(500).end( 'Internal Server Error');
}
};
@@ -0,0 +1,103 @@
import { createLock, getFileLocks, getLocksIfValid, refreshLock } from '../lockStore.mjs';
import { escapeXml } from '../utils.mjs';
/**
*
* @param {string} lockToken
* @param {string} lockScope
* @param {string} filePath
* @returns
*/
const getLockResponse = ( lockToken, lockScope, filePath ) => {
return `<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:">
<D:lockdiscovery>
<D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:${lockScope}/></D:lockscope>
<D:depth>0</D:depth>
<D:owner>
<D:href>webdav-user</D:href>
</D:owner>
<D:timeout>Second-7200</D:timeout>
<D:locktoken>
<D:href>${lockToken}</D:href>
</D:locktoken>
<D:lockroot>
<D:href>/dav${escapeXml(encodeURI(filePath))}</D:href>
</D:lockroot>
</D:activelock>
</D:lockdiscovery>
</D:prop>`;
};
/**
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {string} filePath
* @param {import('../../../filesystem/FSNodeContext')} fileNode
* @param {string} headerLockToken
* @returns
*/
export const LOCK = async ( req, res, filePath, fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const exists = await fileNode.exists();
const lockScope = req.body.lockinfo?.lockscope?.[0]?.shared ? 'shared' : 'exclusive';
const lockType = req.body.lockinfo?.locktype?.[0]?.write ? 'write' : null;
const existingFileFromLock = (await getLocksIfValid(...servicesForLocks, headerLockToken)).pop();
// Check if the resource exists
if ( !exists ) {
// handle non exsiting child folder if lock is present to refresh parent
if ( existingFileFromLock && filePath.startsWith(existingFileFromLock.path) ) {
filePath = existingFileFromLock.path;
}
// Though technically the resource does not exist, we'll make a lock so that other's can't write to it technically.
}
const locksOnFile = await getFileLocks(...servicesForLocks, filePath);
// handle exclusive locks if theres any lock in place
if (
lockScope === 'exclusive' &&
locksOnFile?.length &&
( !headerLockToken || existingFileFromLock?.path !== `${filePath}` )
) {
res.status(423).end( 'Locked: Resource already locked');
return;
}
// handle shared locks
if (
locksOnFile?.length &&
locksOnFile?.find(( lock ) => lock.lockScope === '')
&& (
!headerLockToken || existingFileFromLock?.path !== `${filePath}`)
) {
res.status(423).end( 'Locked: Resource already locked');
return;
}
// Generate a UUID lock token
const lockToken = headerLockToken
? await refreshLock(...servicesForLocks, headerLockToken, filePath)
: await createLock(...servicesForLocks, filePath, lockScope, lockType);
// Set proper headers for WebDAV XML response
res.set({
'Content-Type': 'application/xml; charset=utf-8',
...( headerLockToken && lockScope !== 'shared' ? {} : { 'Lock-Token': `<${lockToken}>` } ),
DAV: '1, 2',
'MS-Author-Via': 'DAV',
});
// Return lock response
const lockResponse = getLockResponse(lockToken, lockScope, filePath);
res.status(!exists ? 201 : 200);
res.end(lockResponse);
} catch( error ) {
console.error('LOCK error:', error);
res.status(500).end( 'Internal Server Error');
}
};
@@ -0,0 +1,90 @@
import path from 'path';
import { NodePathSelector } from '../../../filesystem/node/selectors.js';
import { hasWritePermissionInDAV } from '../lockStore.mjs';
import { fsOperations } from '../utils.mjs';
/**
* @type {import('./method.mjs').HandlerFunction}
*/
export const MKCOL = async ( req, res, filePath, fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const hasDestinationWriteAccess = await hasWritePermissionInDAV(...servicesForLocks, filePath, headerLockToken);
const exists = await fileNode?.exists();
// 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;
}
if ( !hasDestinationWriteAccess ){
// DAV lock in place blocking write to this file
res.status(423).end( 'Locked: No write access to destination');
return;
}
// Get parent directory node
const svc_fs = fileNode.services.get('filesystem');
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 fsOperations.stat(parentNode);
if ( !parentStat.is_dir ) {
res.status(409).end( 'Conflict');
return;
}
// Create the directory
await fsOperations.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 {
console.error('MKCOL error:', error);
res.status(500).end( 'Internal Server Error');
}
}
};
@@ -0,0 +1,118 @@
import path from 'path';
import { NodePathSelector } from '../../../filesystem/node/selectors.js';
import { hasWritePermissionInDAV } from '../lockStore.mjs';
import { fsOperations } from '../utils.mjs';
/**
* MOVE method handler
* @type {import('./method.mjs').HandlerFunction}
*/
export const MOVE = async ( req, res, filePath, fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const hasSourceWriteAccess = await hasWritePermissionInDAV(...servicesForLocks, filePath, headerLockToken);
const svc_fs = req.services.get('filesystem');
const exists = await fileNode?.exists();
// 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.slice(4); // Remove '/dav' prefix
if ( !destinationPath.startsWith('/') ) {
destinationPath = `/${destinationPath}`;
}
} catch {
res.status(400).end( 'Bad Request: Invalid destination URI');
return;
}
destinationPath = decodeURI(destinationPath);
const hasDestinationWriteAccess = hasWritePermissionInDAV(destinationPath, headerLockToken);
// 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 fsOperations.stat(destParentNode);
if ( !destParentStat.is_dir ) {
res.status(409).end( 'Conflict: Destination parent is not a directory');
return;
}
if ( !hasSourceWriteAccess || !hasDestinationWriteAccess ) {
// DAV lock in place blocking write to this file
res.status(423).end( 'Locked: No write access to source or destination');
return;
}
// Perform the move operation
await fsOperations.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 {
console.error('LOCK error:', error);
res.status(500).end( 'Internal Server Error');
}
}
};
@@ -0,0 +1,14 @@
export const OPTIONS = async (_req, res) => {
res.set({
'Allow': 'OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK',
'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();
};
@@ -0,0 +1,177 @@
import { escapeXml, fsOperations } from '../utils.mjs';
const getProperMimeType = ( originalType, filename ) => {
if ( originalType && originalType !== 'application/octet-stream' ) {
return originalType;
}
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';
}
};
const convertToWebDAVPropfindXML = ( fsEntry ) => {
const isDirectory = fsEntry.is_dir;
const lastModified = new Date(fsEntry.modified * 1000).toUTCString();
const createdDate = new Date(fsEntry.created * 1000).toISOString();
let href = fsEntry.path;
if ( isDirectory && !href.endsWith('/') ) {
href += '/';
}
const xml = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dav${escapeXml(encodeURI(href))}</D:href>
<D:propstat>
<D:prop>
<D:displayname>${escapeXml(fsEntry.name)}</D:displayname>
<D:getlastmodified>${lastModified}</D:getlastmodified>
<D:creationdate>${createdDate}</D:creationdate>
${
isDirectory
? '<D:resourcetype><D:collection/></D:resourcetype>'
: `<D:resourcetype/>
<D:getcontentlength>${fsEntry.size || 0}</D:getcontentlength>
<D:getcontenttype>${escapeXml(getProperMimeType(fsEntry.type, fsEntry.name))}</D:getcontenttype>`
}
<D:getetag>"${fsEntry.uid}-${Math.floor(fsEntry.modified)}"</D:getetag>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:ishidden>0</D:ishidden>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return xml;
};
const 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();
let href = fsEntry.path;
if ( isDirectory && !href.endsWith('/') ) {
href += '/';
}
return ` <D:response>
<D:href>/dav${escapeXml(encodeURI(href))}</D:href>
<D:propstat>
<D:prop>
<D:displayname>${escapeXml(fsEntry.name)}</D:displayname>
<D:getlastmodified>${lastModified}</D:getlastmodified>
<D:creationdate>${createdDate}</D:creationdate>
${
isDirectory
? '<D:resourcetype><D:collection/></D:resourcetype>'
: `<D:resourcetype/>
<D:getcontentlength>${fsEntry.size || 0}</D:getcontentlength>
<D:getcontenttype>${escapeXml(getProperMimeType(fsEntry.type, fsEntry.name))}</D:getcontenttype>`
}
<D:getetag>"${fsEntry.uid}-${Math.floor(fsEntry.modified)}"</D:getetag>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:ishidden>0</D:ishidden>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
})
.join( '\n');
return `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
${responses}
</D:multistatus>`;
};
export const PROPFIND = async ( req, res, filePath, fileNode, _headerLockToken ) => {
try {
res.set({
'Content-Type': 'application/xml; charset=utf-8',
DAV: '1, 2',
'MS-Author-Via': 'DAV',
});
const exists = await fileNode?.exists();
// Handle special case for /dav/ root - return static response with only admin folder
if ( filePath === '/' || filePath === '' ) {
const stat = await fsOperations.stat(fileNode);
const entries = await fsOperations.readdir(fileNode);
res.status(207);
res.end(convertMultipleToWebDAVPropfindXML(stat, entries));
return;
}
// Check if file exists
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 fsOperations.stat(fileNode);
if ( stat.is_dir && depth !== '0' ) {
const entries = await fsOperations.readdir(fileNode);
res.status(207);
res.end(convertMultipleToWebDAVPropfindXML(stat, entries));
} else {
res.status(207);
res.end(convertToWebDAVPropfindXML(stat));
}
} catch( error ) {
console.error('PROPFIND error:', error);
res.status(500).end( 'Internal Server Error');
}
};
@@ -0,0 +1,52 @@
// WebDAV PROPPATCH handler for Puter
import { hasWritePermissionInDAV } from '../lockStore.mjs';
import { escapeXml } from '../utils.mjs';
const getStubResponse = ( filePath ) => `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dav${escapeXml(encodeURI(filePath))}</D:href>
<D:propstat>
<D:prop/>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
/**
* Handles the WebDAV PROPPATCH method.
* Always returns a generic success response (no extended attributes supported) unless locked, which fails but doesn't matter anyway.
*
* @param {object} req - Express request object
* @param {object} res - Express response object
* @param {string} filePath - Path to the target file
* @param {object} fileNode - File node object (unused in stub)
* @param {string} headerLockToken - Lock token from headers (unused in stub)
*/
export const PROPPATCH = async ( req, res, filePath, _fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const hasDestinationWriteAccess = await hasWritePermissionInDAV(...servicesForLocks, filePath, headerLockToken);
if ( !hasDestinationWriteAccess ) {
// DAV lock in place blocking write to this file
res.status(423).end( 'Locked: No write access to destination');
return;
}
res.set({
'Content-Type': 'application/xml; charset=utf-8',
DAV: '1, 2',
'MS-Author-Via': 'DAV',
});
// Generic success response (no real property update)
const stubResponse = getStubResponse(filePath);
res.status(207);
res.end(stubResponse);
} catch( error ) {
// Log error to console (can be replaced with service logger if needed)
console.error('PROPPATCH error:', error);
res.status(500).end( 'Internal Server Error');
}
};
@@ -0,0 +1,109 @@
import path from 'path';
import { hasWritePermissionInDAV } from '../lockStore.mjs';
import { fsOperations } from '../utils.mjs';
/**
* @type {import('./method.mjs').HandlerFunction}
*/
export const PUT = async ( req, res, filePath, fileNode, headerLockToken ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const hasDestinationWriteAccess = await hasWritePermissionInDAV(...servicesForLocks, filePath, headerLockToken);
if ( !hasDestinationWriteAccess ){
// DAV lock in place blocking write to this file
res.status(423).end('Locked: No write access to destination');
return;
}
// macOS loves polluting webdav directories with metadata which would be stored regularly in HFS+ or APFS.
// We will 422 all of these, because no one actually wants to see them.
const fileName = path.basename(filePath);
if (
( req.headers['user-agent'] &&
req.headers['user-agent'].includes('Darwin/') &&
fileName.toLowerCase() === '.ds_store' ) ||
fileName.startsWith('._')
) {
res.writeHead(422, {
'Content-Type': 'application/xml; charset=utf-8',
});
res.end(`<?xml version="1.0" encoding="utf-8" ?>
<d:error xmlns:d="DAV:">
<d:valid-resourcename>macOS metadata files not permitted</d:valid-resourcename>
</d:error>`);
return;
}
// Handle Expect: 100-continue header
if ( req.headers.expect && req.headers.expect.toLowerCase() === '100-continue' ) {
res.writeContinue();
}
// Check Content-Length header to find length
// TODO: Allow partial uploads with Range header
// TODO: Allow uploads with no Content-Length
const contentLength = req.headers['content-length'] || req.headers['x-expected-entity-length']; // x-expected-entity-length is used by macOS Finder for some reason
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 = await fileNode.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 fsOperations.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 {
console.error('PUT error:', error);
res.status(500).end( 'Internal Server Error');
}
}
};
@@ -0,0 +1,39 @@
import { deleteLock, extractHeaderToken, getLocksIfValid } from '../lockStore.mjs';
/**
* @type {import('./method.mjs').HandlerFunction}
*/
export const UNLOCK = async ( req, res, filePath, fileNode ) => {
try {
const servicesForLocks = [req.services.get('su'), req.services.get('puter-kvstore').as('puter-kvstore')];
const exists = await fileNode?.exists();
// Check if the resource exists
if ( !exists ) {
res.status(204).end();
return;
}
// Check for Lock-Token header (normally required for UNLOCK)
const lockTokenHeader = req.headers['lock-token'];
const { headerLockToken } = extractHeaderToken(lockTokenHeader);
if ( !headerLockToken ) {
res.status(400).end( 'Bad Request: Lock-Token header required');
return;
}
const existingFileFromLock = (await getLocksIfValid(...servicesForLocks, headerLockToken)).pop();
if ( existingFileFromLock ) {
if ( existingFileFromLock.path === filePath ) {
deleteLock(...servicesForLocks, headerLockToken, filePath);
return res.status(204).end(); // 204 No Content for successful unlock
}
return res.status(403).end(); // 403 Forbidden - lock token does not match
} else {
return res.status(409).end(); // 409 Conflict - no lock present
}
} catch( error ) {
console.error('UNLOCK error:', error);
res.status(500).end( 'Internal Server Error');
}
};
@@ -0,0 +1,27 @@
/**
* @typedef {import('express').Request & {services: import('../../BaseService.js')}} Request
* @typedef {import('express').Response} Response
* @typedef {import('../../../filesystem/FSNodeContext')} FSNodeContext
*/
/**
* @typedef {(req: Request, res: Response, filePath: string, fileNode: FSNodeContext, headerLockToken: string) => Promise<void>} HandlerFunction
*/
/**
* @type {HandlerFunction}
*/
export const unsupportedMethodHandler = async (
req,
res,
_filePath,
_fileNode,
_headerLockToken ) => {
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');
};
@@ -0,0 +1,30 @@
import { COPY } from './COPY.mjs';
import { DELETE } from './DELETE.mjs';
import { HEAD_GET } from './HEAD_GET.mjs';
import { LOCK } from './LOCK.mjs';
import { MKCOL } from './MKCOL.mjs';
import { MOVE } from './MOVE.mjs';
import { OPTIONS } from './OPTIONS.mjs';
import { PROPFIND } from './PROPFIND.mjs';
import { PROPPATCH } from './PROPPATCH.mjs';
import { PUT } from './PUT.mjs';
import { UNLOCK } from './UNLOCK.mjs';
/**
* Map of HTTP methods to their corresponding handler functions.
* @type {Record<string, import('./method.mjs').HandlerFunction>}
*/
export const davMethodMap = {
HEAD: HEAD_GET,
GET: HEAD_GET,
LOCK,
UNLOCK,
COPY,
MOVE,
DELETE,
PROPFIND,
PUT,
MKCOL,
PROPPATCH,
OPTIONS,
};
+170
View File
@@ -0,0 +1,170 @@
import { HLCopy } from '../../filesystem/hl_operations/hl_copy.js';
import { HLMkdir } from '../../filesystem/hl_operations/hl_mkdir.js';
import { HLMove } from '../../filesystem/hl_operations/hl_move.js';
import { HLReadDir } from '../../filesystem/hl_operations/hl_readdir.js';
import { HLRemove } from '../../filesystem/hl_operations/hl_remove.js';
import { HLStat } from '../../filesystem/hl_operations/hl_stat.js';
import { HLWrite } from '../../filesystem/hl_operations/hl_write.js';
import { LLRead } from '../../filesystem/ll_operations/ll_read.js';
import { Context } from '../../util/context.js';
/**
* Small utility function to escape XML
*
* @param {string} text
* @returns
*/
export const escapeXml = ( text ) => {
if ( typeof text !== 'string' ) return text;
return text
.replace(/&/g, '&amp;')
.replace( /</g, '&lt;')
.replace( />/g, '&gt;')
.replace( /"/g, '&quot;')
.replace( /'/g, '&#39;');
};
// 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.
export const fsOperations = {
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
});
},
};
export const 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';
}
};
File diff suppressed because it is too large Load Diff
+51 -24
View File
@@ -19,7 +19,7 @@ const gui_cache_keys = [
];
class KV{
MAX_KEY_SIZE = 1024;
MAX_VALUE_SIZE = 400 * 1024;
MAX_VALUE_SIZE = 399 * 1024;
/**
* Creates a new instance with the given authentication token, API origin, and app ID,
@@ -50,7 +50,7 @@ class KV{
args: {
key: gui_cache_keys,
},
auth_token: this.authToken
auth_token: this.authToken,
}),
});
const arr_values = await resp.json();
@@ -95,14 +95,25 @@ class KV{
}
/**
* Resolves to 'true' on success, or rejects with an error on failure
*
* `key` cannot be undefined or null.
* `key` size cannot be larger than 1mb.
* `value` size cannot be larger than 10mb.
* `expireAt` is a timestamp in sec since epoch. If provided, the key will expire at the given time.
* @typedef {function(key: string, value: any, expireAt?: number): Promise<boolean>} SetFunction
* Resolves to 'true' on success, or rejects with an error on failure.
* @param {string} key - Cannot be undefined or null. Cannot be larger than 1KB.
* @param {any} value - Cannot be larger than 399KB.
* @param {number} [expireAt] - Optional expiration time for the key. Note that clients with a clock that is not in sync with the server may experience issues with this method.
* @memberof KV
*/
/** @type {SetFunction} */
set = utils.make_driver_method(['key', 'value', 'expireAt'], 'puter-kvstore', undefined, 'set', {
/**
*
* @param {object} args
* @param {string} args.key
* @param {any} args.value
* @param {number} [args.expireAt]
* @memberof [KV]
* @returns
*/
preprocess: (args) => {
// key cannot be undefined or null
if ( args.key === undefined || args.key === null ){
@@ -110,11 +121,11 @@ class KV{
}
// key size cannot be larger than MAX_KEY_SIZE
if ( args.key.length > this.MAX_KEY_SIZE ){
throw { message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' };
throw { message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' };
}
// value size cannot be larger than MAX_VALUE_SIZE
if ( args.value && args.value.length > this.MAX_VALUE_SIZE ){
throw { message: 'Value size cannot be larger than ' + this.MAX_VALUE_SIZE, code: 'value_too_large' };
throw { message: `Value size cannot be larger than ${this.MAX_VALUE_SIZE}`, code: 'value_too_large' };
}
return args;
},
@@ -143,7 +154,7 @@ class KV{
preprocess: (args) => {
// key size cannot be larger than MAX_KEY_SIZE
if ( args.key.length > this.MAX_KEY_SIZE ){
throw ({ message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' });
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
return args;
@@ -166,7 +177,7 @@ class KV{
// key size cannot be larger than MAX_KEY_SIZE
if ( options.key.length > this.MAX_KEY_SIZE ){
throw ({ message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' });
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
return utils.make_driver_method(['key'], 'puter-kvstore', undefined, 'incr').call(this, options);
@@ -185,33 +196,49 @@ class KV{
// key size cannot be larger than MAX_KEY_SIZE
if ( options.key.length > this.MAX_KEY_SIZE ){
throw ({ message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' });
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
return utils.make_driver_method(['key'], 'puter-kvstore', undefined, 'decr').call(this, options);
};
expire = async (...args) => {
/**
* Set a time to live (in seconds) on a key. After the time to live has expired, the key will be deleted.
* Prefer this over expireAt if you want timestamp to be set by the server, to avoid issues with clock drift.
* @param {string} key - The key to set the expiration on.
* @param {number} ttl - The ttl
* @memberof [KV]
* @returns
*/
expire = async (key, ttl) => {
let options = {};
options.key = args[0];
options.ttl = args[1];
options.key = key;
options.ttl = ttl;
// key size cannot be larger than MAX_KEY_SIZE
if ( options.key.length > this.MAX_KEY_SIZE ){
throw ({ message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' });
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
return utils.make_driver_method(['key', 'ttl'], 'puter-kvstore', undefined, 'expire').call(this, options);
};
expireAt = async (...args) => {
/**
*
* Set the expiration for a key as a UNIX timestamp (in seconds). After the time has passed, the key will be deleted.
* Note that clients with a clock that is not in sync with the server may experience issues with this method.
* @param {string} key - The key to set the expiration on.
* @param {number} timestamp - The timestamp (in seconds since epoch) when the key will expire.
* @memberof [KV]
* @returns
*/
expireAt = async (key, timestamp) => {
let options = {};
options.key = args[0];
options.timestamp = args[1];
options.key = key;
options.timestamp = timestamp;
// key size cannot be larger than MAX_KEY_SIZE
if ( options.key.length > this.MAX_KEY_SIZE ){
throw ({ message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' });
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
return utils.make_driver_method(['key', 'timestamp'], 'puter-kvstore', undefined, 'expireAt').call(this, options);
@@ -223,7 +250,7 @@ class KV{
preprocess: (args) => {
// key size cannot be larger than this.MAX_KEY_SIZE
if ( args.key.length > this.MAX_KEY_SIZE ){
throw ({ message: 'Key size cannot be larger than ' + this.MAX_KEY_SIZE, code: 'key_too_large' });
throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' });
}
return args;
@@ -292,7 +319,7 @@ function globMatch(pattern, str) {
.replace(/\\\]/g, ']') // Replace ] with ]
.replace(/\\\^/g, '^'); // Replace ^ with ^
let re = new RegExp('^' + regexPattern + '$');
let re = new RegExp(`^${regexPattern}$`);
return re.test(str);
}