Add WebDav server, fully functional!

Some more code cleanup.
This commit is contained in:
Bruno Bernardino
2024-04-06 19:43:34 +01:00
parent 541df3fb77
commit 265c52a7e5
11 changed files with 424 additions and 149 deletions

View File

@@ -1,4 +1,5 @@
import { join } from 'std/path/join.ts';
import { lookup } from 'mrmime';
import { getFilesRootPath } from '/lib/config.ts';
import { Directory, DirectoryFile } from '/lib/types.ts';
@@ -168,22 +169,9 @@ export async function getFile(
try {
const contents = await Deno.readFile(join(rootPath, name));
let contentType = 'application/octet-stream';
// NOTE: Detecting based on extension is not accurate, but installing a dependency like `npm:file-types` just for this seems unnecessary
const extension = name.split('.').slice(-1).join('').toLowerCase();
if (extension === 'jpg' || extension === 'jpeg') {
contentType = 'image/jpeg';
} else if (extension === 'png') {
contentType = 'image/png';
} else if (extension === 'svg') {
contentType = 'image/svg+xml';
} else if (extension === 'pdf') {
contentType = 'application/pdf';
} else if (extension === 'txt' || extension === 'md') {
contentType = 'text/plain';
}
const contentType = lookup(extension) || 'application/octet-stream';
return {
success: true,

View File

@@ -213,79 +213,6 @@ export function convertObjectToFormData(formDataObject: Record<string, any>): Fo
return formData;
}
function writeXmlTag(tagName: string, value: any, attributes?: Record<string, any>) {
const attributesXml = attributes
? Object.keys(attributes || {}).map((attributeKey) => `${attributeKey}="${escapeHtml(attributes[attributeKey])}"`)
.join(' ')
: '';
if (Array.isArray(value)) {
if (value.length === 0) {
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`;
}
const xmlLines: string[] = [];
for (const valueItem of value) {
xmlLines.push(writeXmlTag(tagName, valueItem));
}
return xmlLines.join('\n');
}
if (typeof value === 'object') {
if (Object.keys(value).length === 0) {
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`;
}
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${convertObjectToDavXml(value)}</${tagName}>`;
}
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}</${tagName}>`;
}
export function convertObjectToDavXml(davObject: Record<string, any>, isInitial = false): string {
const xmlLines: string[] = [];
if (isInitial) {
xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
}
for (const key of Object.keys(davObject)) {
if (key.endsWith('_attributes')) {
continue;
}
xmlLines.push(writeXmlTag(key, davObject[key], davObject[`${key}_attributes`]));
}
return xmlLines.join('\n');
}
function addLeadingZero(number: number) {
if (number < 10) {
return `0${number}`;
}
return number.toString();
}
export function buildRFC822Date(dateString: string) {
const dayStrings = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const timeStamp = Date.parse(dateString);
const date = new Date(timeStamp);
const day = dayStrings[date.getDay()];
const dayNumber = addLeadingZero(date.getUTCDate());
const month = monthStrings[date.getUTCMonth()];
const year = date.getUTCFullYear();
const time = `${addLeadingZero(date.getUTCHours())}:${addLeadingZero(date.getUTCMinutes())}:00`;
return `${day}, ${dayNumber} ${month} ${year} ${time} +0000`;
}
export const capitalizeWord = (string: string) => {
return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`;
};

View File

@@ -1,7 +1,6 @@
import { assertEquals } from 'std/assert/assert_equals.ts';
import {
convertFormDataToObject,
convertObjectToDavXml,
convertObjectToFormData,
escapeHtml,
generateHash,
@@ -247,48 +246,3 @@ Deno.test('that convertObjectToFormData works', () => {
assertEquals(convertFormDataToObject(output), convertFormDataToObject(test.expected));
}
});
Deno.test('that convertObjectToDavXml works', () => {
const tests: { input: Record<string, any>; expected: string }[] = [
{
input: {
url: 'https://bewcloud.com',
},
expected: `<url>https://bewcloud.com</url>`,
},
{
input: {
a: 'Website',
a_attributes: {
href: 'https://bewcloud.com',
target: '_blank',
},
},
expected: `<a href="https://bewcloud.com" target="_blank">Website</a>`,
},
{
input: {
article: {
p: [
{
strong: 'Indeed',
},
{
i: {},
},
'Mighty!',
],
},
article_attributes: {
class: 'center',
},
},
expected: `<article class="center"><p><strong>Indeed</strong></p>\n<p><i /></p>\n<p>Mighty!</p></article>`,
},
];
for (const test of tests) {
const result = convertObjectToDavXml(test.input);
assertEquals(result, test.expected);
}
});

180
lib/utils/webdav.ts Normal file
View File

@@ -0,0 +1,180 @@
import { join } from 'std/path/join.ts';
import { lookup } from 'mrmime';
export function getProperDestinationPath(url: string) {
return decodeURIComponent(new URL(url).pathname.slice(1));
}
export function addDavPrefixToKeys(object: Record<string, any>, prefix = 'D:'): Record<string, any> {
if (Array.isArray(object)) {
return object.map((item) => addDavPrefixToKeys(item));
}
if (typeof object === 'object' && object !== null) {
return Object.entries(object).reduce((reducedObject, [key, value]) => {
const prefixedKey = key === 'xml' || key.startsWith('#') || key.startsWith('@') ? key : `${prefix}${key}`;
reducedObject[prefixedKey] = addDavPrefixToKeys(value);
return reducedObject;
}, {} as Record<string, any>);
}
return object;
}
export function getPropertyNames(xml: Record<string, any>): string[] {
const propFindElement = xml['D:propfind'] || xml.propfind;
if (!propFindElement) {
return [];
}
const propElement = propFindElement['D:prop'] || propFindElement.prop;
if (!propElement) {
return [];
}
const propertyNames: string[] = [];
for (const key in propElement) {
if (Object.hasOwn(propElement, key)) {
propertyNames.push(key.replace('D:', ''));
}
}
return propertyNames;
}
function encodeFilePath(filePath: string) {
const encoded = encodeURIComponent(filePath)
.replace(/%2F/g, '/')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29');
return encoded;
}
async function getFilePaths(
path: string,
depth?: number | null,
): Promise<string[]> {
const filePaths: string[] = [];
try {
const stat = await Deno.stat(path);
if (stat.isFile) {
filePaths.push(path);
} else if (stat.isDirectory || stat.isSymlink) {
if (depth === 0) {
filePaths.push(`${path}/`);
} else {
filePaths.push(`${path}/`);
const directoryEntries = Deno.readDir(path);
for await (const entry of directoryEntries) {
const entryPath = [path, entry.name]
.filter(Boolean)
.join('/')
.replaceAll('//', '/');
filePaths.push(entry.isDirectory || entry.isSymlink ? `${entryPath}/` : entryPath);
if (entry.isDirectory && (depth === 1 || depth === null)) {
const nestedResources = await getFilePaths(
entryPath,
depth ? depth - 1 : depth,
);
filePaths.push(...nestedResources);
}
}
}
}
} catch (error) {
console.error(error);
}
return [...new Set(filePaths)];
}
export async function buildPropFindResponse(
properties: string[],
rootPath: string,
queryPath: string,
depth: number | null = null,
): Promise<Record<string, any>> {
const filePaths = await getFilePaths(join(rootPath, queryPath), depth);
const response: Record<string, any> = {
xml: {
'@version': '1.0',
'@encoding': 'UTF-8',
},
multistatus: {
'@xmlns:D': 'DAV:',
response: [],
},
};
for (const filePath of filePaths) {
try {
const stat = await Deno.stat(filePath);
const isDirectory = stat.isDirectory || stat.isSymlink;
const prop: Record<string, any> = {};
const notFound: Record<string, any> = {};
for (const propKey of properties) {
switch (propKey) {
case 'displayname':
prop.displayname = isDirectory ? filePath : filePath.split('/').pop();
break;
case 'getcontentlength':
if (!isDirectory) {
prop.getcontentlength = (stat.size ?? 0)?.toString();
}
break;
case 'getcontenttype':
if (!isDirectory) {
prop.getcontenttype = lookup(filePath);
}
break;
case 'resourcetype':
prop.resourcetype = isDirectory ? { collection: { '@xmlns:D': 'DAV:' } } : {};
break;
case 'getlastmodified':
prop.getlastmodified = stat.mtime?.toUTCString() || '';
break;
case 'creationdate':
prop.creationdate = stat.birthtime?.toUTCString() || '';
break;
case 'getetag':
prop.etag = stat.mtime?.toUTCString();
break;
}
if (typeof prop[propKey] === 'undefined') {
if (propKey.startsWith('s:')) {
notFound[propKey.slice(2)] = { '@xmlns:s': 'SAR:' };
} else {
notFound[propKey] = '';
}
}
}
const davFileName = filePath.replace(join(rootPath, queryPath), queryPath);
const davFilePath = `/dav${(davFileName.startsWith('/') ? '' : '/')}${davFileName}`.replaceAll('//', '/');
response.multistatus.response.push({
href: encodeFilePath(davFilePath),
propstat: [
{ prop, status: 'HTTP/1.1 200 OK' },
{ prop: notFound, status: 'HTTP/1.1 404 Not Found' },
],
});
} catch (error) {
console.error(error);
}
}
return addDavPrefixToKeys(response);
}