Add WebDav server, fully functional!
Some more code cleanup.
This commit is contained in:
12
README.md
12
README.md
@@ -56,15 +56,17 @@ $ make build # generates all static files for production deploy
|
||||
|
||||
Just push to the `main` branch.
|
||||
|
||||
## Tentative Roadmap:
|
||||
## Tentative Roadmap for a v1 beta:
|
||||
|
||||
- [x] Dashboard with URLs and Notes
|
||||
- [x] News
|
||||
- [x] Files UI
|
||||
- [ ] Desktop app for selective file sync (WebDav or potentially just `rclone` or `rsync`)
|
||||
- [ ] Mobile app for offline file sync
|
||||
- [ ] Add notes support for mobile app
|
||||
- [ ] Add photos/sync support for mobile client
|
||||
- [x] WebDav Server
|
||||
- [ ] Desktop app for selective file sync (`rclone` via WebDav)
|
||||
- [ ] Mobile app for offline file view (WebDav client)
|
||||
- [ ] Add photo auto-uplod support for mobile client
|
||||
- [ ] Add notes view support for mobile app
|
||||
- [ ] Add notes edit support for mobile app
|
||||
- [ ] Notes UI
|
||||
- [ ] Photos UI
|
||||
- [ ] Address `TODO:`s in code
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { baseUrl } from '/lib/utils/misc.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
|
||||
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
|
||||
@@ -499,6 +500,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>WebDav URL:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav</code>
|
||||
</section>
|
||||
|
||||
<CreateDirectoryModal
|
||||
isOpen={isNewDirectoryModalOpen.value}
|
||||
onClickSave={onClickSaveDirectory}
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
|
||||
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
||||
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||
import * as $dashboard from './routes/dashboard.tsx';
|
||||
import * as $dav from './routes/dav.tsx';
|
||||
import * as $files from './routes/files.tsx';
|
||||
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
|
||||
import * as $index from './routes/index.tsx';
|
||||
@@ -63,6 +64,7 @@ const manifest = {
|
||||
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
|
||||
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||
'./routes/dashboard.tsx': $dashboard,
|
||||
'./routes/dav.tsx': $dav,
|
||||
'./routes/files.tsx': $files,
|
||||
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
|
||||
'./routes/index.tsx': $index,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"imports": {
|
||||
"/": "./",
|
||||
"./": "./",
|
||||
"xml": "https://deno.land/x/xml@2.1.3/mod.ts",
|
||||
"mrmime": "https://deno.land/x/mrmime@v2.0.0/mod.ts",
|
||||
|
||||
"fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const actionWords = new Map<Action, string>([
|
||||
['change-email', 'change email'],
|
||||
['verify-change-email', 'change email'],
|
||||
['change-password', 'change password'],
|
||||
['change-dav-password', 'change Sync password'],
|
||||
['change-dav-password', 'change WebDav password'],
|
||||
['delete-account', 'delete account'],
|
||||
]);
|
||||
|
||||
@@ -83,11 +83,11 @@ function formFields(action: Action, formData: FormData) {
|
||||
} else if (action === 'change-dav-password') {
|
||||
fields.push({
|
||||
name: 'new-dav-password',
|
||||
label: 'New Sync Password',
|
||||
label: 'New WebDav Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
description: 'Alternative password used for Sync access and/or HTTP Basic Auth.',
|
||||
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
|
||||
});
|
||||
} else if (action === 'delete-account') {
|
||||
fields.push({
|
||||
@@ -148,12 +148,12 @@ export default function Settings({ formData: formDataObject, error, notice }: Se
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your Sync password</h2>
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your WebDav password</h2>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-secondary' type='submit'>Change Sync password</button>
|
||||
<button class='button-secondary' type='submit'>Change WebDav password</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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
180
lib/utils/webdav.ts
Normal 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);
|
||||
}
|
||||
@@ -61,12 +61,6 @@ export const handler = [
|
||||
const response = await context.next();
|
||||
|
||||
console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`);
|
||||
if (request.url.includes('/dav/')) {
|
||||
console.info(`Request`, request.headers);
|
||||
console.info((await request.clone().text()) || '<No Body>');
|
||||
console.info(`Response`, response.headers);
|
||||
console.info(`Status`, response.status);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
220
routes/dav.tsx
Normal file
220
routes/dav.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Handler, RouteConfig } from 'fresh/server.ts';
|
||||
import { join } from 'std/path/join.ts';
|
||||
import { parse, stringify } from 'xml';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getFilesRootPath } from '/lib/config.ts';
|
||||
import {
|
||||
addDavPrefixToKeys,
|
||||
buildPropFindResponse,
|
||||
getProperDestinationPath,
|
||||
getPropertyNames,
|
||||
} from '/lib/utils/webdav.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export const config: RouteConfig = {
|
||||
routeOverride: '/dav/:filePath*',
|
||||
};
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
let { filePath } = context.params;
|
||||
|
||||
if (!filePath) {
|
||||
filePath = '/';
|
||||
}
|
||||
|
||||
filePath = decodeURIComponent(filePath);
|
||||
|
||||
const rootPath = join(getFilesRootPath(), context.state.user.id);
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
const headers = new Headers({
|
||||
DAV: '1, 2',
|
||||
'Ms-Author-Via': 'DAV',
|
||||
Allow: 'OPTIONS, DELETE, PROPFIND',
|
||||
'Content-Length': '0',
|
||||
Date: new Date().toUTCString(),
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
try {
|
||||
const stat = await Deno.stat(join(rootPath, filePath));
|
||||
|
||||
if (stat) {
|
||||
const contents = await Deno.readFile(join(rootPath, filePath));
|
||||
|
||||
return new Response(contents, { status: 200 });
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
try {
|
||||
await Deno.remove(join(rootPath, filePath));
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
const contentLengthString = request.headers.get('content-length');
|
||||
const contentLength = contentLengthString ? parseInt(contentLengthString, 10) : null;
|
||||
const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.body;
|
||||
|
||||
try {
|
||||
const newFile = await Deno.open(join(rootPath, filePath), {
|
||||
create: true,
|
||||
write: true,
|
||||
truncate: true,
|
||||
});
|
||||
|
||||
await body?.pipeTo(newFile.writable);
|
||||
|
||||
return new Response('Created', { status: 201 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'COPY') {
|
||||
const newFilePath = request.headers.get('destination');
|
||||
if (newFilePath) {
|
||||
try {
|
||||
await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
|
||||
return new Response('Created', { status: 201 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
if (request.method === 'MOVE') {
|
||||
const newFilePath = request.headers.get('destination');
|
||||
if (newFilePath) {
|
||||
try {
|
||||
await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
|
||||
return new Response('Created', { status: 201 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'MKCOL') {
|
||||
try {
|
||||
await Deno.mkdir(join(rootPath, filePath), { recursive: true });
|
||||
return new Response('Created', { status: 201 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'LOCK') {
|
||||
const depthString = request.headers.get('depth');
|
||||
const depth = depthString ? parseInt(depthString, 10) : null;
|
||||
const xml = await request.clone().text();
|
||||
const parsedXml = parse(xml) as Record<string, any>;
|
||||
|
||||
// TODO: This should create an actual lock, not just "pretend", and have it checked when fetching a directory/file
|
||||
const lockToken = crypto.randomUUID();
|
||||
|
||||
const responseXml: Record<string, any> = {
|
||||
xml: {
|
||||
'@version': '1.0',
|
||||
'@encoding': 'UTF-8',
|
||||
},
|
||||
prop: {
|
||||
'@xmlns:D': 'DAV:',
|
||||
lockdiscovery: {
|
||||
activelock: {
|
||||
locktype: { write: null },
|
||||
lockscope: { exclusive: null },
|
||||
depth,
|
||||
owner: {
|
||||
href: parsedXml['D:lockinfo']?.['D:owner']?.['D:href'],
|
||||
},
|
||||
timeout: 'Second-600',
|
||||
locktoken: { href: lockToken },
|
||||
lockroot: { href: filePath },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const responseString = stringify(addDavPrefixToKeys(responseXml));
|
||||
|
||||
return new Response(responseString, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Lock-Token': `<${lockToken}>`,
|
||||
'Content-Length': responseString.length.toString(),
|
||||
Date: new Date().toUTCString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'UNLOCK') {
|
||||
// TODO: This should release an actual lock, not just "pretend"
|
||||
// const lockToken = request.headers.get('Lock-Token');
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { Date: new Date().toUTCString() },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'PROPFIND') {
|
||||
const depthString = request.headers.get('depth');
|
||||
const depth = depthString ? parseInt(depthString, 10) : null;
|
||||
const xml = await request.clone().text();
|
||||
|
||||
const parsedXml = parse(xml);
|
||||
|
||||
const properties = getPropertyNames(parsedXml);
|
||||
const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth);
|
||||
|
||||
return responseXml['D:multistatus']['D:response'].length === 0
|
||||
? new Response('Not Found', {
|
||||
status: 404,
|
||||
headers: new Headers({ 'Content-Type': 'text/plain; charset=utf-8' }),
|
||||
})
|
||||
: new Response(stringify(responseXml), {
|
||||
status: 207,
|
||||
headers: new Headers({
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Allow-Methods': '*',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 405 });
|
||||
};
|
||||
Reference in New Issue
Block a user