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

{formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))}
- +
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)}`; - } - - return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}`; -} - -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

\n

Mighty!

`, - }, - ]; - - 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 }); +};