diff --git a/lib/dav/webdav/factory.v b/lib/dav/webdav/factory.v index 44638dd7..cdda55ff 100644 --- a/lib/dav/webdav/factory.v +++ b/lib/dav/webdav/factory.v @@ -31,8 +31,8 @@ pub fn new_server(args ServerArgs) !&Server { } // register middlewares for all routes - server.use(handler: server.auth_middleware) server.use(handler: middleware_log_request) + server.use(handler: server.auth_middleware) server.use(handler: middleware_log_response, after: true) return server } diff --git a/lib/dav/webdav/middleware_auth.v b/lib/dav/webdav/middleware_auth.v index caa585e8..3408a6b3 100644 --- a/lib/dav/webdav/middleware_auth.v +++ b/lib/dav/webdav/middleware_auth.v @@ -1,33 +1,37 @@ module webdav +import time import encoding.base64 +import freeflowuniverse.herolib.core.texttools fn (server &Server) auth_middleware(mut ctx Context) bool { + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return false } + // return true auth_header := ctx.get_header(.authorization) or { ctx.res.set_status(.unauthorized) - ctx.res.header.add(.www_authenticate, 'Basic realm="WebDAV Server"') - ctx.send_response_to_client('text', 'unauthorized') + ctx.set_header(.www_authenticate, 'Basic realm="/"') + ctx.send_response_to_client('', '') return false } if auth_header == '' { ctx.res.set_status(.unauthorized) - ctx.res.header.add(.www_authenticate, 'Basic realm="WebDAV Server"') - ctx.send_response_to_client('text', 'unauthorized') + ctx.set_header(.www_authenticate, 'Basic realm="/"') + ctx.send_response_to_client('', '') return false } if !auth_header.starts_with('Basic ') { ctx.res.set_status(.unauthorized) - ctx.res.header.add(.www_authenticate, 'Basic realm="WebDAV Server"') - ctx.send_response_to_client('text', 'unauthorized') + ctx.set_header(.www_authenticate, 'Basic realm="/"') + ctx.send_response_to_client('', '') return false } auth_decoded := base64.decode_str(auth_header[6..]) split_credentials := auth_decoded.split(':') if split_credentials.len != 2 { ctx.res.set_status(.unauthorized) - ctx.res.header.add(.www_authenticate, 'Basic realm="WebDAV Server"') + ctx.set_header(.www_authenticate, 'Basic realm="/"') ctx.send_response_to_client('', '') return false } @@ -36,14 +40,14 @@ fn (server &Server) auth_middleware(mut ctx Context) bool { if user := server.user_db[username] { if user != hashed_pass { ctx.res.set_status(.unauthorized) - ctx.res.header.add(.www_authenticate, 'Basic realm="WebDAV Server"') - ctx.send_response_to_client('text', 'unauthorized') + ctx.set_header(.www_authenticate, 'Basic realm="/"') + ctx.send_response_to_client('', '') return false } return true } ctx.res.set_status(.unauthorized) - ctx.res.header.add(.www_authenticate, 'Basic realm="WebDAV Server"') - ctx.send_response_to_client('text', 'unauthorized') + ctx.set_header(.www_authenticate, 'Basic realm="/"') + ctx.send_response_to_client('', '') return false } diff --git a/lib/dav/webdav/model_property.v b/lib/dav/webdav/model_property.v index c8ab7672..616025b2 100644 --- a/lib/dav/webdav/model_property.v +++ b/lib/dav/webdav/model_property.v @@ -10,101 +10,220 @@ import veb // Property represents a WebDAV property pub interface Property { - xml() string - xml_name() string + xml() xml.XMLNodeContents +// xml_name() string +// to_xml_node() xml.XMLNode +// } } type DisplayName = string +type GetETag = string type GetLastModified = string type GetContentType = string type GetContentLength = string +type QuotaAvailableBytes = u64 +type QuotaUsedBytes = u64 +type QuotaUsed = u64 +type Quota = u64 type ResourceType = bool type CreationDate = string type SupportedLock = string type LockDiscovery = string -fn (p []Property) xml() string { - return ' - ${p.map(it.xml()).join_lines()} - HTTP/1.1 200 OK - ' -} +// fn (p []Property) xml() string { +// return ' +// ${p.map(it.xml()).join_lines()} +// HTTP/1.1 200 OK +// ' +// } -fn (p DisplayName) xml() string { - return '${p}' -} - -fn (p DisplayName) xml_name() string { - return '' -} - -fn (p GetLastModified) xml() string { - return '${p}' -} - -fn (p GetLastModified) xml_name() string { - return '' -} - -fn (p GetContentType) xml() string { - return '${p}' -} - -fn (p GetContentType) xml_name() string { - return '' -} - -fn (p GetContentLength) xml() string { - return '${p}' -} - -fn (p GetContentLength) xml_name() string { - return '' -} - -fn (p ResourceType) xml() string { - return if p { - '' - } else { - '' +fn (p []Property) xml() xml.XMLNode { + return xml.XMLNode{ + name: 'D:propstat' + children: [ + xml.XMLNode{ + name: 'D:prop' + children: p.map(it.xml()) + }, + xml.XMLNode{ + name: 'D:status' + children: [xml.XMLNodeContents('HTTP/1.1 200 OK')] + } + ] } } -fn (p ResourceType) xml_name() string { - return '' +fn (p DisplayName) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:displayname' + children: [xml.XMLNodeContents(p)] + } } -fn (p CreationDate) xml() string { - return '${p}' +fn (p GetETag) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:getetag' + children: [xml.XMLNodeContents(p)] + } } -fn (p CreationDate) xml_name() string { - return '' +fn (p GetLastModified) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:getlastmodified' + children: [xml.XMLNodeContents(p)] + } } -fn (p SupportedLock) xml() string { - return ' - - - - - - - - - ' +fn (p GetContentType) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:getcontenttype' + children: [xml.XMLNodeContents(p)] + } } -fn (p SupportedLock) xml_name() string { - return '' +fn (p GetContentLength) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:getcontentlength' + children: [xml.XMLNodeContents(p)] + } } -fn (p LockDiscovery) xml() string { - return '${p}' +fn (p QuotaAvailableBytes) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:quota-available-bytes' + children: [xml.XMLNodeContents(p.str())] + } } -fn (p LockDiscovery) xml_name() string { - return '' +fn (p QuotaUsedBytes) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:quota-used-bytes' + children: [xml.XMLNodeContents(p.str())] + } +} + +fn (p Quota) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:quota' + children: [xml.XMLNodeContents(p.str())] + } +} + +fn (p QuotaUsed) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:quotaused' + children: [xml.XMLNodeContents(p.str())] + } +} + +fn (p ResourceType) xml() xml.XMLNodeContents { + if p { + // If it's a collection, add the collection element as a child + mut children := []xml.XMLNodeContents{} + children << xml.XMLNode{ + name: 'D:collection' + } + + return xml.XMLNode{ + name: 'D:resourcetype' + children: children + } + } else { + // If it's not a collection, return an empty resourcetype element + return xml.XMLNode{ + name: 'D:resourcetype' + children: []xml.XMLNodeContents{} + } + } +} + +fn (p CreationDate) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:creationdate' + children: [xml.XMLNodeContents(p)] + } +} + +fn (p SupportedLock) xml() xml.XMLNodeContents { + // Create children for the supportedlock node + mut children := []xml.XMLNodeContents{} + + // First lockentry - exclusive + mut lockscope1_children := []xml.XMLNodeContents{} + lockscope1_children << xml.XMLNode{ + name: 'D:exclusive' + } + + lockscope1 := xml.XMLNode{ + name: 'D:lockscope' + children: lockscope1_children + } + + mut locktype1_children := []xml.XMLNodeContents{} + locktype1_children << xml.XMLNode{ + name: 'D:write' + } + + locktype1 := xml.XMLNode{ + name: 'D:locktype' + children: locktype1_children + } + + mut lockentry1_children := []xml.XMLNodeContents{} + lockentry1_children << lockscope1 + lockentry1_children << locktype1 + + lockentry1 := xml.XMLNode{ + name: 'D:lockentry' + children: lockentry1_children + } + + // Second lockentry - shared + mut lockscope2_children := []xml.XMLNodeContents{} + lockscope2_children << xml.XMLNode{ + name: 'D:shared' + } + + lockscope2 := xml.XMLNode{ + name: 'D:lockscope' + children: lockscope2_children + } + + mut locktype2_children := []xml.XMLNodeContents{} + locktype2_children << xml.XMLNode{ + name: 'D:write' + } + + locktype2 := xml.XMLNode{ + name: 'D:locktype' + children: locktype2_children + } + + mut lockentry2_children := []xml.XMLNodeContents{} + lockentry2_children << lockscope2 + lockentry2_children << locktype2 + + lockentry2 := xml.XMLNode{ + name: 'D:lockentry' + children: lockentry2_children + } + + // Add both lockentries to children + children << lockentry1 + children << lockentry2 + + // Return the supportedlock node + return xml.XMLNode{ + name: 'D:supportedlock' + children: children + } +} + +fn (p LockDiscovery) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:lockdiscovery' + children: [xml.XMLNodeContents(p)] + } } fn format_iso8601(t time.Time) string { diff --git a/lib/dav/webdav/model_propfind.v b/lib/dav/webdav/model_propfind.v index 6531d8cd..71e64272 100644 --- a/lib/dav/webdav/model_propfind.v +++ b/lib/dav/webdav/model_propfind.v @@ -60,7 +60,7 @@ pub fn parse_propfind_xml(req http.Request) !PropfindRequest { return error('Invalid PROPFIND request: root element must be propfind') } - mut typ := PropfindType.invalid + mut typ := PropfindType.allprop mut props := []string{} // Check for allprop, propname, or prop elements @@ -120,23 +120,58 @@ pub fn parse_depth(depth_str string) Depth { } // Response represents a WebDAV response for a resource -pub struct Response { +pub struct PropfindResponse { pub: href string found_props []Property not_found_props []Property } -fn (r Response) xml() string { - return '\n${r.href} - ${r.found_props.map(it.xml()).join_lines()}HTTP/1.1 200 OK - ' +fn (r PropfindResponse) xml() xml.XMLNodeContents { + return xml.XMLNode{ + name: 'D:response' + children: [ + xml.XMLNode{ + name: 'D:href' + children: [xml.XMLNodeContents(r.href)] + }, + xml.XMLNode{ + name: 'D:propstat' + children: [ + xml.XMLNode{ + name: 'D:prop' + children: r.found_props.map(it.xml()) + }, + xml.XMLNode{ + name: 'D:status' + children: [xml.XMLNodeContents('HTTP/1.1 200 OK')] + } + ] + } + ] + } } // generate_propfind_response generates a PROPFIND response XML string from Response structs -pub fn (r []Response) xml () string { - return '\n - ${r.map(it.xml()).join_lines()}\n' +pub fn (r []PropfindResponse) xml() string { + // Create multistatus root node + multistatus_node := xml.XMLNode{ + name: 'D:multistatus' + attributes: { + 'xmlns:D': 'DAV:' + } + children: r.map(it.xml()) + } + + // Create a new XML document with the root node + doc := xml.XMLDocument{ + version: '1.0' + root: multistatus_node + } + + // Generate XML string + doc.validate() or {panic('this should never happen ${err}')} + return format_xml(doc.str()) } fn get_file_content_type(path string) string { @@ -149,3 +184,54 @@ fn get_file_content_type(path string) string { return content_type } + +// parse_xml takes an XML string and returns a cleaned version with whitespace removed between tags +pub fn format_xml(xml_str string) string { + mut result := '' + mut i := 0 + mut in_tag := false + mut content_start := 0 + + // Process the string character by character + for i < xml_str.len { + ch := xml_str[i] + + // Start of a tag + if ch == `<` { + // If we were collecting content between tags, process it + if !in_tag && i > content_start { + // Get the content between tags and trim whitespace + content := xml_str[content_start..i].trim_space() + result += content + } + + in_tag = true + result += '<' + } + // End of a tag + else if ch == `>` { + in_tag = false + result += '>' + content_start = i + 1 + } + // Inside a tag - preserve all characters including whitespace + else if in_tag { + result += ch.ascii_str() + } + // Outside a tag - only add non-whitespace or handle whitespace in content + else if !in_tag { + // We'll collect and process this content when we reach the next tag + // or at the end of the string + } + + i++ + } + + // Handle any remaining content at the end of the string + if !in_tag && content_start < xml_str.len { + content := xml_str[content_start..].trim_space() + result += content + } + + return result +} diff --git a/lib/dav/webdav/server.v b/lib/dav/webdav/server.v index 05bcf8ea..57f67d22 100644 --- a/lib/dav/webdav/server.v +++ b/lib/dav/webdav/server.v @@ -1,34 +1,35 @@ module webdav import time +import freeflowuniverse.herolib.core.texttools import freeflowuniverse.herolib.ui.console import encoding.xml import net.urllib +import net +import net.http.chunked import veb import log import strings @[head] pub fn (server &Server) index(mut ctx Context) veb.Result { - ctx.set_custom_header('DAV', '1,2') or { return ctx.server_error(err.msg()) } - ctx.set_header(.allow, 'OPTIONS, PROPFIND, MKCOL, GET, HEAD, POST, PUT, DELETE, COPY, MOVE') - ctx.set_custom_header('MS-Author-Via', 'DAV') or { return ctx.server_error(err.msg()) } - ctx.set_header(.access_control_allow_origin, '*') - ctx.set_header(.access_control_allow_methods, 'OPTIONS, PROPFIND, MKCOL, GET, HEAD, POST, PUT, DELETE, COPY, MOVE') - ctx.set_header(.access_control_allow_headers, 'Authorization, Content-Type') ctx.set_header(.content_length, '0') + ctx.set_custom_header('DAV', '1,2') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Allow', 'OPTIONS, HEAD, GET, PROPFIND, DELETE, COPY, MOVE, PROPPATCH, LOCK, UNLOCK') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('MS-Author-Via', 'DAV') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'WsgiDAV-compatible WebDAV Server') or { return ctx.server_error(err.msg()) } return ctx.ok('') } @['/:path...'; options] pub fn (server &Server) options(mut ctx Context, path string) veb.Result { - ctx.set_custom_header('DAV', '1,2') or { return ctx.server_error(err.msg()) } - ctx.set_header(.allow, 'OPTIONS, PROPFIND, MKCOL, GET, HEAD, POST, PUT, DELETE, COPY, MOVE') - ctx.set_custom_header('MS-Author-Via', 'DAV') or { return ctx.server_error(err.msg()) } - ctx.set_header(.access_control_allow_origin, '*') - ctx.set_header(.access_control_allow_methods, 'OPTIONS, PROPFIND, MKCOL, GET, HEAD, POST, PUT, DELETE, COPY, MOVE') - ctx.set_header(.access_control_allow_headers, 'Authorization, Content-Type') ctx.set_header(.content_length, '0') + ctx.set_custom_header('DAV', '1,2') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Allow', 'OPTIONS, HEAD, GET, PROPFIND, DELETE, COPY, MOVE, PROPPATCH, LOCK, UNLOCK') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('MS-Author-Via', 'DAV') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'WsgiDAV-compatible WebDAV Server') or { return ctx.server_error(err.msg()) } return ctx.ok('') } @@ -69,9 +70,11 @@ pub fn (mut server Server) lock(mut ctx Context, path string) veb.Result { return ctx.text('Resource is already locked by a different owner.') } - // log.debug('[WebDAV] Received lock result ${lock_result.xml()}') + // Set WsgiDAV-like headers ctx.res.set_status(.ok) - ctx.set_custom_header('Lock-Token', '${lock_result.token}') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Lock-Token', 'opaquelocktoken:${lock_result.token}') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } // Create a proper WebDAV lock response return ctx.send_response_to_client('application/xml', lock_result.xml()) @@ -81,16 +84,21 @@ pub fn (mut server Server) lock(mut ctx Context, path string) veb.Result { pub fn (mut server Server) unlock(mut ctx Context, path string) veb.Result { resource := ctx.req.url token_ := ctx.get_custom_header('Lock-Token') or { return ctx.server_error(err.msg()) } + // Handle the opaquelocktoken: prefix that WsgiDAV uses token := token_.trim_string_left('<').trim_string_right('>') + .trim_string_left('opaquelocktoken:') if token.len == 0 { console.print_stderr('Unlock failed: `Lock-Token` header required.') ctx.res.set_status(.bad_request) - return ctx.text('Lock failed: `Owner` header missing.') + return ctx.text('Lock failed: `Lock-Token` header missing or invalid.') } if server.lock_manager.unlock_with_token(resource, token) { + // Add WsgiDAV-like headers + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } ctx.res.set_status(.no_content) - return ctx.text('Lock successfully released') + return ctx.text('') } console.print_stderr('Resource is not locked or token mismatch.') @@ -106,9 +114,15 @@ pub fn (mut server Server) get_file(mut ctx Context, path string) veb.Result { return ctx.server_error(err.msg()) } ext := path.all_after_last('.') - content_type := veb.mime_types['.${ext}'] or { 'text/plain' } - // ctx.res.header.set(.content_length, file_data.len.str()) - // ctx.res.set_status(.ok) + content_type := veb.mime_types['.${ext}'] or { 'text/plain; charset=utf-8' } + + // Add WsgiDAV-like headers + ctx.set_header(.content_length, file_data.len.str()) + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_header(.accept_ranges, 'bytes') + ctx.set_custom_header('ETag', '"${path}-${time.now().unix()}"') or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Last-Modified', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + return ctx.send_response_to_client(content_type, file_data.bytestr()) } @@ -125,7 +139,6 @@ pub fn (mut server Server) exists(mut ctx Context, path string) veb.Result { return ctx.server_error('Failed to set DAV header: ${err}') } ctx.set_header(.content_length, '0') // HEAD request, so no body - // ctx.set_header(.date, time.now().as_utc().format_rfc1123()) // Correct UTC date format // ctx.set_header(.content_type, 'application/xml') // XML is common for WebDAV metadata ctx.set_custom_header('Allow', 'OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, MKCOL, PUT, DELETE, COPY, MOVE, LOCK, UNLOCK') or { return ctx.server_error('Failed to set Allow header: ${err}') @@ -134,7 +147,7 @@ pub fn (mut server Server) exists(mut ctx Context, path string) veb.Result { ctx.set_custom_header('Cache-Control', 'no-cache, no-store, must-revalidate') or { return ctx.server_error('Failed to set Cache-Control header: ${err}') } - ctx.set_custom_header('Last-Modified', time.now().as_utc().format()) or { + ctx.set_custom_header('Last-Modified', texttools.format_rfc1123(time.utc())) or { return ctx.server_error('Failed to set Last-Modified header: ${err}') } ctx.res.set_version(.v1_1) @@ -149,6 +162,11 @@ pub fn (mut server Server) delete(mut ctx Context, path string) veb.Result { return ctx.server_error(err.msg()) } + // Add WsgiDAV-like headers + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } + + server.vfs.print() or {panic(err)} // Return success response return ctx.no_content() } @@ -168,15 +186,25 @@ pub fn (mut server Server) copy(mut ctx Context, path string) veb.Result { } destination_path_str := destination_url.path + // Check if destination exists + destination_exists := server.vfs.exists(destination_path_str) + server.vfs.copy(path, destination_path_str) or { - log.set_level(.debug) - - println('[WebDAV] Failed to copy: ${err}') + log.error('[WebDAV] Failed to copy: ${err}') return ctx.server_error(err.msg()) } - ctx.res.set_status(.ok) - return ctx.text('HTTP 200: Successfully copied entry: ${path}') + // Add WsgiDAV-like headers + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } + + // Return 201 Created if the destination was created, 204 No Content if it was overwritten + if destination_exists { + return ctx.no_content() + } else { + ctx.res.set_status(.created) + return ctx.text('') + } } @['/:path...'; move] @@ -194,14 +222,22 @@ pub fn (mut server Server) move(mut ctx Context, path string) veb.Result { } destination_path_str := destination_url.path + // Check if destination exists + destination_exists := server.vfs.exists(destination_path_str) + log.info('[WebDAV] ${@FN} from ${path} to ${destination_path_str}') server.vfs.move(path, destination_path_str) or { log.error('Failed to move: ${err}') return ctx.server_error(err.msg()) } - ctx.res.set_status(.ok) - return ctx.text('HTTP 200: Successfully copied entry: ${path}') + // Add WsgiDAV-like headers + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } + + // Return 204 No Content for successful move operations (WsgiDAV behavior) + ctx.res.set_status(.no_content) + return ctx.text('') } @['/:path...'; mkcol] @@ -217,29 +253,277 @@ pub fn (mut server Server) mkcol(mut ctx Context, path string) veb.Result { return ctx.server_error(err.msg()) } + // Add WsgiDAV-like headers + ctx.set_header(.content_type, 'text/html; charset=utf-8') + ctx.set_header(.content_length, '0') + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } + ctx.res.set_status(.created) - return ctx.text('HTTP 201: Created') + return ctx.text('') } @['/:path...'; put] fn (mut server Server) create_or_update(mut ctx Context, path string) veb.Result { - if server.vfs.exists(path) { + // Check if parent directory exists (RFC 4918 9.7.1: A PUT that would result in the creation of a resource + // without an appropriately scoped parent collection MUST fail with a 409 Conflict) + parent_path := path.all_before_last('/') + if parent_path != '' && !server.vfs.exists(parent_path) { + log.error('[WebDAV] Parent directory ${parent_path} does not exist for ${path}') + ctx.res.set_status(.conflict) + return ctx.text('HTTP 409: Conflict - Parent collection does not exist') + } + + is_update := server.vfs.exists(path) + if is_update { + log.debug('[WebDAV] ${path} exists, updating') if fs_entry := server.vfs.get(path) { + log.debug('[WebDAV] Got FSEntry ${fs_entry}') + // RFC 4918 9.7.2: PUT for Collections - A PUT request to an existing collection MAY be treated as an error if fs_entry.is_dir() { - console.print_stderr('Cannot PUT to a directory: ${path}') + log.error('[WebDAV] Cannot PUT to a directory: ${path}') ctx.res.set_status(.method_not_allowed) - return ctx.text('HTTP 405: Method Not Allowed') + ctx.set_header(.allow, 'OPTIONS, PROPFIND, MKCOL, GET, HEAD, DELETE') + return ctx.text('HTTP 405: Method Not Allowed - Cannot PUT to a collection') } } else { - return ctx.server_error('failed to get FS Entry ${path}: ${err.msg()}') + log.error('[WebDAV] Failed to get FS Entry for ${path}\n${err.msg()}') + return ctx.server_error('Failed to get FS Entry ${path}: ${err.msg()}') } } else { - server.vfs.file_create(path) or { return ctx.server_error(err.msg()) } + log.debug('[WebDAV] ${path} does not exist, creating') + server.vfs.file_create(path) or { + log.error('[WebDAV] Failed to create file ${path}: ${err.msg()}') + return ctx.server_error('Failed to create file: ${err.msg()}') + } } - if ctx.req.data.len > 0 { - data := ctx.req.data.bytes() - server.vfs.file_write(path, data) or { return ctx.server_error(err.msg()) } - return ctx.ok('HTTP 200: Successfully wrote file: ${path}') + + // Process Content-Type if provided + content_type := ctx.req.header.get(.content_type) or { '' } + if content_type != '' { + log.debug('[WebDAV] Content-Type provided: ${content_type}') } - return ctx.ok('HTTP 200: Successfully created file: ${path}') -} \ No newline at end of file + + // Check if we have a Content-Length header + content_length_str := ctx.req.header.get(.content_length) or { '0' } + content_length := content_length_str.int() + log.debug('[WebDAV] Content-Length: ${content_length}') + + // Check for chunked transfer encoding + transfer_encoding := ctx.req.header.get_custom('Transfer-Encoding') or { '' } + is_chunked := transfer_encoding.to_lower().contains('chunked') + log.debug('[WebDAV] Transfer-Encoding: ${transfer_encoding}, is_chunked: ${is_chunked}') + + // Handle the file upload based on the request type + if is_chunked || content_length > 0 { + // Take over the connection to handle streaming data + ctx.takeover_conn() + + // Create a buffer for reading chunks + mut buffer := []u8{len: 8200} // 8KB buffer for reading chunks + mut total_bytes := 0 + mut all_data := []u8{} + + // Process any data that's already been read + if ctx.req.data.len > 0 { + all_data << ctx.req.data.bytes() + total_bytes += ctx.req.data.len + log.debug('[WebDAV] Added ${ctx.req.data.len} initial bytes from request data') + } + + // Read data in chunks from the connection + if is_chunked { + // For chunked encoding, we need to read until we get a zero-length chunk + log.info('[WebDAV] Reading chunked data for ${path}') + + // Write initial data to the file + if all_data.len > 0 { + server.vfs.file_write(path, all_data) or { + log.error('[WebDAV] Failed to write initial data to ${path}: ${err.msg()}') + // Send error response + ctx.res.set_status(.internal_server_error) + ctx.res.header.set(.content_type, 'text/plain') + ctx.res.header.set(.content_length, '${err.msg().len}') + ctx.conn.write(ctx.res.bytestr().bytes()) or {} + ctx.conn.write(err.msg().bytes()) or {} + ctx.conn.close() or {} + return veb.no_result() + } + } + + // Continue reading chunks from the connection + for { + // Read a chunk from the connection + n := ctx.conn.read(mut buffer) or { + if err.code() == net.err_timed_out_code { + log.info('[WebDAV] Connection timed out, finished reading') + break + } + log.error('[WebDAV] Error reading from connection: ${err}') + break + } + + if n <= 0 { + log.info('[WebDAV] Reached end of data stream') + break + } + + + // Process the chunk using the chunked module + chunk := buffer[..n].clone() + chunk_str := chunk.bytestr() + + // Try to decode the chunk if it looks like a valid chunked format + if chunk_str.contains('\r\n') { + log.debug('[WebDAV] Attempting to decode chunked data') + decoded := chunked.decode(chunk_str) or { + log.error('[WebDAV] Failed to decode chunked data: ${err}') + // If decoding fails, just use the raw chunk + server.vfs.file_concatenate(path, chunk) or { + log.error('[WebDAV] Failed to append chunk to ${path}: ${err.msg()}') + // Send error response + ctx.res.set_status(.internal_server_error) + ctx.res.header.set(.content_type, 'text/plain') + ctx.res.header.set(.content_length, '${err.msg().len}') + ctx.conn.write(ctx.res.bytestr().bytes()) or {} + ctx.conn.write(err.msg().bytes()) or {} + ctx.conn.close() or {} + return veb.no_result() + } + } + + // If decoding succeeds, write the decoded data + if decoded.len > 0 { + log.debug('[WebDAV] Successfully decoded chunked data: ${decoded.len} bytes') + server.vfs.file_concatenate(path, decoded.bytes()) or { + log.error('[WebDAV] Failed to append decoded chunk to ${path}: ${err.msg()}') + // Send error response + ctx.res.set_status(.internal_server_error) + ctx.res.header.set(.content_type, 'text/plain') + ctx.res.header.set(.content_length, '${err.msg().len}') + ctx.conn.write(ctx.res.bytestr().bytes()) or {} + ctx.conn.write(err.msg().bytes()) or {} + ctx.conn.close() or {} + return veb.no_result() + } + } + } else { + // If it doesn't look like chunked data, use the raw chunk + server.vfs.file_concatenate(path, chunk) or { + log.error('[WebDAV] Failed to append chunk to ${path}: ${err.msg()}') + // Send error response + ctx.res.set_status(.internal_server_error) + ctx.res.header.set(.content_type, 'text/plain') + ctx.res.header.set(.content_length, '${err.msg().len}') + ctx.conn.write(ctx.res.bytestr().bytes()) or {} + ctx.conn.write(err.msg().bytes()) or {} + ctx.conn.close() or {} + return veb.no_result() + } + } + + total_bytes += n + log.debug('[WebDAV] Read ${n} bytes, total: ${total_bytes}') + } + } else if content_length > 0 { + // For Content-Length uploads, read exactly that many bytes + log.info('[WebDAV] Reading ${content_length} bytes for ${path}') + mut remaining := content_length - all_data.len + + // Write initial data to the file + if all_data.len > 0 { + server.vfs.file_write(path, all_data) or { + log.error('[WebDAV] Failed to write initial data to ${path}: ${err.msg()}') + // Send error response + ctx.res.set_status(.internal_server_error) + ctx.res.header.set(.content_type, 'text/plain') + ctx.res.header.set(.content_length, '${err.msg().len}') + ctx.conn.write(ctx.res.bytestr().bytes()) or {} + ctx.conn.write(err.msg().bytes()) or {} + ctx.conn.close() or {} + return veb.no_result() + } + } + + // Continue reading until we've read all the content + for remaining > 0 { + // Adjust buffer size for the last chunk if needed + read_size := if remaining < buffer.len { remaining } else { buffer.len } + + // Read a chunk from the connection + n := ctx.conn.read(mut buffer[..read_size]) or { + if err.code() == net.err_timed_out_code { + log.info('[WebDAV] Connection timed out, finished reading') + break + } + log.error('[WebDAV] Error reading from connection: ${err}') + break + } + + if n <= 0 { + log.info('[WebDAV] Reached end of data stream') + break + } + + // Append the chunk to our file + chunk := buffer[..n].clone() + server.vfs.file_concatenate(path, chunk) or { + log.error('[WebDAV] Failed to append chunk to ${path}: ${err.msg()}') + // Send error response + ctx.res.set_status(.internal_server_error) + ctx.res.header.set(.content_type, 'text/plain') + ctx.res.header.set(.content_length, '${err.msg().len}') + ctx.conn.write(ctx.res.bytestr().bytes()) or {} + ctx.conn.write(err.msg().bytes()) or {} + return veb.no_result() + } + + total_bytes += n + remaining -= n + log.debug('[WebDAV] Read ${n} bytes, remaining: ${remaining}') + } + } + + log.info('[WebDAV] Successfully wrote ${total_bytes} bytes to ${path}') + + // Send success response + ctx.res.header.set(.content_type, 'text/html; charset=utf-8') + ctx.res.header.set(.content_length, '0') + ctx.res.header.set_custom('Date', texttools.format_rfc1123(time.utc())) or {} + ctx.res.header.set_custom('Server', 'veb WebDAV Server') or {} + + if is_update { + ctx.res.set_status(.no_content) // 204 No Content + } else { + ctx.res.set_status(.created) // 201 Created + } + + ctx.conn.write(ctx.res.bytestr().bytes()) or { + log.error('[WebDAV] Failed to write response: ${err}') + } + ctx.conn.close() or {} + + return veb.no_result() + } else { + // Empty PUT is still valid (creates empty file or replaces with empty content) + server.vfs.file_write(path, []u8{}) or { + log.error('[WebDAV] Failed to write empty data to ${path}: ${err.msg()}') + return ctx.server_error('Failed to write file: ${err.msg()}') + } + log.info('[WebDAV] Created empty file at ${path}') + + // Add WsgiDAV-like headers + ctx.set_header(.content_type, 'text/html; charset=utf-8') + ctx.set_header(.content_length, '0') + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'veb WebDAV Server') or { return ctx.server_error(err.msg()) } + + // Set appropriate status code based on whether this was a create or update + if is_update { + return ctx.no_content() + } else { + ctx.res.set_status(.created) + return ctx.text('') + } + } +} diff --git a/lib/dav/webdav/server_propfind.v b/lib/dav/webdav/server_propfind.v index befccb00..d0309277 100644 --- a/lib/dav/webdav/server_propfind.v +++ b/lib/dav/webdav/server_propfind.v @@ -7,6 +7,7 @@ import freeflowuniverse.herolib.vfs import freeflowuniverse.herolib.vfs.vfs_db import os import time +import freeflowuniverse.herolib.core.texttools import net.http import veb @@ -21,7 +22,7 @@ fn (mut server Server) propfind(mut ctx Context, path string) veb.Result { }) } - log.debug('[WebDAV] Propfind Request: ${propfind_req.typ} ${propfind_req.depth}') + log.debug('[WebDAV] Propfind Request: ${propfind_req.typ}') // Check if resource is locked if server.lock_manager.is_locked(ctx.req.url) { @@ -38,29 +39,64 @@ fn (mut server Server) propfind(mut ctx Context, path string) veb.Result { tag: 'resource-must-be-null' ) } - + responses := server.get_responses(entry, propfind_req, path) or { return ctx.server_error('Failed to get entry properties ${err}') } - // log.debug('[WebDAV] Propfind responses ${responses}') + + // Add WsgiDAV-like headers + ctx.set_header(.content_type, 'application/xml; charset=utf-8') + ctx.set_custom_header('Date', texttools.format_rfc1123(time.utc())) or { return ctx.server_error(err.msg()) } + ctx.set_custom_header('Server', 'WsgiDAV-compatible WebDAV Server') or { return ctx.server_error(err.msg()) } // Create multistatus response using the responses ctx.res.set_status(.multi_status) return ctx.send_response_to_client('application/xml', responses.xml()) } -// get_responses returns all properties for the given path and depth -fn (mut server Server) get_responses(entry vfs.FSEntry, req PropfindRequest, path string) ![]Response { - mut responses := []Response{} - - // path := server.vfs.get_path(entry)! +// returns the properties of a filesystem entry +fn (mut server Server) get_entry_property(entry &vfs.FSEntry, name string) !Property { + return match name { + 'creationdate' { Property(CreationDate(format_iso8601(entry.get_metadata().created_time()))) } + 'getetag' { Property(GetETag(entry.get_metadata().id.str())) } + 'resourcetype' { Property(ResourceType(entry.is_dir())) } + 'getlastmodified' { Property(GetLastModified(texttools.format_rfc1123(entry.get_metadata().modified_time()))) } + 'getcontentlength' { Property(GetContentLength(entry.get_metadata().size.str())) } + 'quota-available-bytes' { Property(QuotaAvailableBytes(16184098816)) } + 'quota-used-bytes' { Property(QuotaUsedBytes(16184098816)) } + 'quotaused' { Property(QuotaUsed(16184098816)) } + 'quota' { Property(Quota(16184098816)) } + else { panic('implement ${name}')} + } +} - // main entry response - responses << Response { - href: path - // not_found: entry.get_unfound_properties(req) - found_props: server.get_properties(entry) +// get_responses returns all properties for the given path and depth +fn (mut server Server) get_responses(entry vfs.FSEntry, req PropfindRequest, path string) ![]PropfindResponse { + mut responses := []PropfindResponse{} + + if req.typ == .prop { + mut properties := []Property{} + mut erronous_properties := map[int][]Property{} // properties that have errors indexed by error code + for name in req.props { + if property := server.get_entry_property(entry, name.trim_string_left('D:')) { + properties << property + } else { + // TODO: implement error reporting + } + } + // main entry response + responses << PropfindResponse { + href: if entry.is_dir() {'${path.trim_string_right("/")}/'} else {path} + // not_found: entry.get_unfound_properties(req) + found_props: properties + } + } else { + responses << PropfindResponse { + href: if entry.is_dir() {'${path.trim_string_right("/")}/'} else {path} + // not_found: entry.get_unfound_properties(req) + found_props: server.get_properties(entry) + } } if !entry.is_dir() || req.depth == .zero { @@ -84,12 +120,19 @@ fn (mut server Server) get_properties(entry &vfs.FSEntry) []Property { mut props := []Property{} metadata := entry.get_metadata() - // Display name props << DisplayName(metadata.name) - props << GetLastModified(format_iso8601(metadata.modified_time())) - props << GetContentType(if entry.is_dir() {'httpd/unix-directory'} else {get_file_content_type(entry.get_metadata().name)}) + props << GetLastModified(texttools.format_rfc1123(metadata.modified_time())) + + if entry.is_dir() { + props << QuotaAvailableBytes(16184098816) + props << QuotaUsedBytes(16184098816) + } else { + props << GetContentType(if entry.is_dir() {'httpd/unix-directory'} else {get_file_content_type(entry.get_metadata().name)}) + } props << ResourceType(entry.is_dir()) + // props << SupportedLock('') + // props << LockDiscovery('') // Content length (only for files) if !entry.is_dir() { diff --git a/lib/dav/webdav/templates/lock_response.xml b/lib/dav/webdav/templates/lock_response.xml index ca49a5ef..506007a2 100644 --- a/lib/dav/webdav/templates/lock_response.xml +++ b/lib/dav/webdav/templates/lock_response.xml @@ -1,6 +1,6 @@ - + @@ -10,7 +10,7 @@ Second-@{l.timeout} - @{l.token} + opaquelocktoken:@{l.token} @{l.resource} diff --git a/lib/vfs/interface.v b/lib/vfs/interface.v index 170f4803..85b65ad1 100644 --- a/lib/vfs/interface.v +++ b/lib/vfs/interface.v @@ -12,6 +12,7 @@ mut: file_create(path string) !FSEntry file_read(path string) ![]u8 file_write(path string, data []u8) ! + file_concatenate(path string, data []u8) ! file_delete(path string) ! // Directory operations @@ -34,6 +35,8 @@ mut: // FSEntry Operations get_path(entry &FSEntry) !string + + print() ! // Cleanup operation destroy() ! diff --git a/lib/vfs/vfs_db/database_get.v b/lib/vfs/vfs_db/database_get.v index 8014996e..99e97653 100644 --- a/lib/vfs/vfs_db/database_get.v +++ b/lib/vfs/vfs_db/database_get.v @@ -164,9 +164,9 @@ pub fn (mut fs DatabaseVFS) root_get_as_dir() !&Directory { id: fs.get_next_id() file_type: .directory name: '' - created_at: time.now().unix() - modified_at: time.now().unix() - accessed_at: time.now().unix() + created_at: time.utc().unix() + modified_at: time.utc().unix() + accessed_at: time.utc().unix() mode: 0o755 // default directory permissions owner: 'user' // TODO: get from system group: 'user' // TODO: get from system diff --git a/lib/vfs/vfs_db/encode_test.v b/lib/vfs/vfs_db/encode_test.v index d393fdbc..b24fdf6b 100644 --- a/lib/vfs/vfs_db/encode_test.v +++ b/lib/vfs/vfs_db/encode_test.v @@ -48,7 +48,7 @@ fn test_directory_encoder_decoder() ! { fn test_file_encoder_decoder() ! { println('Testing encoding/decoding files...') - current_time := time.now().unix() + current_time := time.utc().unix() file := File{ metadata: vfs.Metadata{ id: u32(current_time) diff --git a/lib/vfs/vfs_db/vfs_directory.v b/lib/vfs/vfs_db/vfs_directory.v index 3e7bc9ea..70efd5b5 100644 --- a/lib/vfs/vfs_db/vfs_directory.v +++ b/lib/vfs/vfs_db/vfs_directory.v @@ -144,7 +144,10 @@ pub fn (mut fs DatabaseVFS) directory_rm(mut dir Directory, name string) ! { // delete file chunks in data_db for id in file.chunk_ids { log.debug('[DatabaseVFS] Deleting chunk ${id}') - fs.db_data.delete(id)! + fs.db_data.delete(id) or { + log.error('Failed to delete chunk ${id}: ${err}') + return error('Failed to delete chunk ${id}: ${err}') + } } log.debug('[DatabaseVFS] Deleting file metadata ${file.metadata.id}') @@ -288,15 +291,26 @@ pub fn (mut fs DatabaseVFS) directory_copy(mut dir Directory, args_ CopyDirArgs) found = true if entry is File { mut file_entry := entry as File + + mut file_data := []u8{} + // log.debug('[DatabaseVFS] Got database chunk ids ${chunk_ids}') + for id in file_entry.chunk_ids { + // there were chunk ids stored with file so file has data + if chunk_bytes := fs.db_data.get(id) { + file_data << chunk_bytes + } else { + return error('Failed to fetch file data: ${err}') + } + } + mut new_file := File{ - ...file_entry, metadata: Metadata{...file_entry.metadata, id: fs.get_next_id() name: args.dst_entry_name } parent_id: args.dst_parent_dir.metadata.id } - fs.save_entry(new_file)! + fs.save_file(new_file, file_data)! args.dst_parent_dir.children << new_file.metadata.id fs.save_entry(args.dst_parent_dir)! return args.dst_parent_dir diff --git a/lib/vfs/vfs_db/vfs_implementation.v b/lib/vfs/vfs_db/vfs_implementation.v index 4fdd18ef..435e02c5 100644 --- a/lib/vfs/vfs_db/vfs_implementation.v +++ b/lib/vfs/vfs_db/vfs_implementation.v @@ -2,6 +2,7 @@ module vfs_db import freeflowuniverse.herolib.vfs import freeflowuniverse.herolib.core.texttools +import arrays import log import os import time @@ -49,7 +50,6 @@ pub fn (mut self DatabaseVFS) file_read(path_ string) ![]u8 { pub fn (mut self DatabaseVFS) file_write(path_ string, data []u8) ! { path := texttools.path_fix(path_) - if mut entry := self.get_entry(path) { if mut entry is File { log.info('[DatabaseVFS] Writing ${data.len} bytes to ${path}') @@ -59,7 +59,7 @@ pub fn (mut self DatabaseVFS) file_write(path_ string, data []u8) ! { } else { panic('handle error') } - } else { + } else { self.file_create(path) or { return error('Failed to create file: ${err}') } @@ -67,6 +67,62 @@ pub fn (mut self DatabaseVFS) file_write(path_ string, data []u8) ! { } } +pub fn (mut self DatabaseVFS) file_concatenate(path_ string, data []u8) ! { + path := texttools.path_fix(path_) + if data.len == 0 { + return // Nothing to append + } + + if mut entry := self.get_entry(path) { + if mut entry is File { + log.info('[DatabaseVFS] Appending ${data.len} bytes to ${path}') + + // Split new data into chunks of 64 KB + chunks := arrays.chunk(data, (64 * 1024) - 1) + mut chunk_ids := entry.chunk_ids.clone() // Start with existing chunk IDs + + // Add new chunks + for chunk in chunks { + chunk_id := self.db_data.set(data: chunk) or { + return error('Failed to save file data chunk: ${err}') + } + chunk_ids << chunk_id + log.debug('[DatabaseVFS] Added chunk ${chunk_id} to ${path}') + } + + // Update the file with new chunk IDs and updated size + updated_file := File{ + metadata: vfs.Metadata{ + ...entry.metadata + size: entry.metadata.size + u64(data.len) + modified_at: time.now().unix() + } + chunk_ids: chunk_ids + parent_id: entry.parent_id + } + + // Encode the file with all its metadata + metadata_bytes := updated_file.encode() + + // Save the metadata_bytes to metadata_db + metadata_db_id := self.db_metadata.set(data: metadata_bytes) or { + return error('Failed to save file metadata on id:${entry.metadata.id}: ${err}') + } + + self.id_table[entry.metadata.id] = metadata_db_id + } else { + return error('Not a file: ${path}') + } + } else { + // If file doesn't exist, create it first + self.file_create(path) or { + return error('Failed to create file: ${err}') + } + // Then write data to it + self.file_write(path, data)! + } +} + pub fn (mut self DatabaseVFS) file_delete(path string) ! { log.info('[DatabaseVFS] Deleting file ${path}') parent_path := os.dir(path)