diff --git a/README.md b/README.md
index ddf2df0..6b7e730 100644
--- a/README.md
+++ b/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
diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx
index 44767f7..69c7793 100644
--- a/components/files/MainFiles.tsx
+++ b/components/files/MainFiles.tsx
@@ -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
+
+ WebDav URL:{' '}
+ {baseUrl}/dav
+
+
([
['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
- Change your Sync password
+ Change your WebDav password
diff --git a/lib/data/files.ts b/lib/data/files.ts
index 69a2d36..0bece3c 100644
--- a/lib/data/files.ts
+++ b/lib/data/files.ts
@@ -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,
diff --git a/lib/utils/misc.ts b/lib/utils/misc.ts
index 1103168..3bd7561 100644
--- a/lib/utils/misc.ts
+++ b/lib/utils/misc.ts
@@ -213,79 +213,6 @@ export function convertObjectToFormData(formDataObject: Record): Fo
return formData;
}
-function writeXmlTag(tagName: string, value: any, attributes?: Record) {
- 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, isInitial = false): string {
- const xmlLines: string[] = [];
-
- if (isInitial) {
- xmlLines.push(``);
- }
-
- 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)}`;
};
diff --git a/lib/utils/misc_test.ts b/lib/utils/misc_test.ts
index 453460f..df7a31d 100644
--- a/lib/utils/misc_test.ts
+++ b/lib/utils/misc_test.ts
@@ -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; expected: string }[] = [
- {
- input: {
- url: 'https://bewcloud.com',
- },
- expected: `https://bewcloud.com`,
- },
- {
- input: {
- a: 'Website',
- a_attributes: {
- href: 'https://bewcloud.com',
- target: '_blank',
- },
- },
- expected: `Website`,
- },
- {
- input: {
- article: {
- p: [
- {
- strong: 'Indeed',
- },
- {
- i: {},
- },
- 'Mighty!',
- ],
- },
- article_attributes: {
- class: 'center',
- },
- },
- expected: `Indeed
\n
\nMighty!
`,
- },
- ];
-
- for (const test of tests) {
- const result = convertObjectToDavXml(test.input);
- assertEquals(result, test.expected);
- }
-});
diff --git a/lib/utils/webdav.ts b/lib/utils/webdav.ts
new file mode 100644
index 0000000..c16901d
--- /dev/null
+++ b/lib/utils/webdav.ts
@@ -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, prefix = 'D:'): Record {
+ 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);
+ }
+
+ return object;
+}
+
+export function getPropertyNames(xml: Record): 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 {
+ 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> {
+ const filePaths = await getFilePaths(join(rootPath, queryPath), depth);
+
+ const response: Record = {
+ 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 = {};
+ const notFound: Record = {};
+
+ 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);
+}
diff --git a/routes/_middleware.tsx b/routes/_middleware.tsx
index 7f77890..0302850 100644
--- a/routes/_middleware.tsx
+++ b/routes/_middleware.tsx
@@ -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()) || '');
- console.info(`Response`, response.headers);
- console.info(`Status`, response.status);
- }
return response;
},
diff --git a/routes/dav.tsx b/routes/dav.tsx
new file mode 100644
index 0000000..dc74eb7
--- /dev/null
+++ b/routes/dav.tsx
@@ -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 = 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;
+
+ // 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 = {
+ 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 });
+};