mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
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:
co-authored by
Neal Shah
parent
7bfa85d4fe
commit
d70d412115
+45
-80
@@ -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,
|
||||
|
||||
Generated
+1128
-1451
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace( /</g, '<')
|
||||
.replace( />/g, '>')
|
||||
.replace( /"/g, '"')
|
||||
.replace( /'/g, ''');
|
||||
};
|
||||
|
||||
// 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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user