From 7530c4d32ad725c7a2db4af5a85f903296556636 Mon Sep 17 00:00:00 2001 From: EthRousseau Date: Thu, 26 Feb 2026 21:15:59 -0500 Subject: [PATCH] webdav support --- CLAUDE.md | 7 + api/api/openapi.yaml | 3 + api/docs/Bundle.md | 26 ++ api/model_bundle.go | 36 ++ api/ts/dist/AllApi.d.ts | 1 + api/ts/generated/api.ts | 1 + api/ts/generated/docs/Bundle.md | 2 + cmd/weblens/main.go | 2 +- docs/docs.go | 3 + docs/swagger.json | 3 + docs/swagger.yaml | 2 + go.mod | 12 +- go.sum | 29 +- models/featureflags/feature_flag_model.go | 3 + models/file/file.go | 19 +- modules/cryptography/user.go | 12 +- modules/log/log.go | 55 +-- routers/api/v1/tower/rest_admin.go | 2 + routers/api/v1/webdav.go | 111 ++++- routers/router/middleware.go | 12 +- routers/router/router.go | 9 +- routers/startup.go | 27 +- services/webdavFs.go | 410 +++++++++++++----- .../weblens-nuxt/pages/settings/dev.vue | 45 +- 24 files changed, 629 insertions(+), 203 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 41d330ca..e9701cfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,13 @@ The test script will always output coverage information when run. To check code make cover ``` +# Generated code + +**NEVER edit generated files directly.** The TypeScript API SDK in `api/ts/generated/` and `api/ts/dist/` is auto-generated from Go swagger annotations. To update it: + +1. Make changes to the Go source code (structs, swagger annotations, etc.) +2. Run `make swag` to regenerate the TypeScript API SDK + # Code style and linting Weblens follows standard Go code style conventions. To ensure your code adheres to these conventions, please run the following commands after making changes to go code: diff --git a/api/api/openapi.yaml b/api/api/openapi.yaml index 30d80627..31e61e39 100644 --- a/api/api/openapi.yaml +++ b/api/api/openapi.yaml @@ -2264,11 +2264,14 @@ components: example: auth.allow_registrations: true media.hdir_processing_enabled: true + webdav.enabled: true properties: auth.allow_registrations: type: boolean media.hdir_processing_enabled: type: boolean + webdav.enabled: + type: boolean type: object CreateFolderBody: example: diff --git a/api/docs/Bundle.md b/api/docs/Bundle.md index 9360515a..466cd7de 100644 --- a/api/docs/Bundle.md +++ b/api/docs/Bundle.md @@ -6,6 +6,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **AuthAllowRegistrations** | Pointer to **bool** | | [optional] **MediaHdirProcessingEnabled** | Pointer to **bool** | | [optional] +**WebdavEnabled** | Pointer to **bool** | | [optional] ## Methods @@ -76,6 +77,31 @@ SetMediaHdirProcessingEnabled sets MediaHdirProcessingEnabled field to given val HasMediaHdirProcessingEnabled returns a boolean if a field has been set. +### GetWebdavEnabled + +`func (o *Bundle) GetWebdavEnabled() bool` + +GetWebdavEnabled returns the WebdavEnabled field if non-nil, zero value otherwise. + +### GetWebdavEnabledOk + +`func (o *Bundle) GetWebdavEnabledOk() (*bool, bool)` + +GetWebdavEnabledOk returns a tuple with the WebdavEnabled field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetWebdavEnabled + +`func (o *Bundle) SetWebdavEnabled(v bool)` + +SetWebdavEnabled sets WebdavEnabled field to given value. + +### HasWebdavEnabled + +`func (o *Bundle) HasWebdavEnabled() bool` + +HasWebdavEnabled returns a boolean if a field has been set. + [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/api/model_bundle.go b/api/model_bundle.go index b4652860..afdb07ca 100644 --- a/api/model_bundle.go +++ b/api/model_bundle.go @@ -21,6 +21,7 @@ var _ MappedNullable = &Bundle{} type Bundle struct { AuthAllowRegistrations *bool `json:"auth.allow_registrations,omitempty"` MediaHdirProcessingEnabled *bool `json:"media.hdir_processing_enabled,omitempty"` + WebdavEnabled *bool `json:"webdav.enabled,omitempty"` } // NewBundle instantiates a new Bundle object @@ -104,6 +105,38 @@ func (o *Bundle) SetMediaHdirProcessingEnabled(v bool) { o.MediaHdirProcessingEnabled = &v } +// GetWebdavEnabled returns the WebdavEnabled field value if set, zero value otherwise. +func (o *Bundle) GetWebdavEnabled() bool { + if o == nil || IsNil(o.WebdavEnabled) { + var ret bool + return ret + } + return *o.WebdavEnabled +} + +// GetWebdavEnabledOk returns a tuple with the WebdavEnabled field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Bundle) GetWebdavEnabledOk() (*bool, bool) { + if o == nil || IsNil(o.WebdavEnabled) { + return nil, false + } + return o.WebdavEnabled, true +} + +// HasWebdavEnabled returns a boolean if a field has been set. +func (o *Bundle) HasWebdavEnabled() bool { + if o != nil && !IsNil(o.WebdavEnabled) { + return true + } + + return false +} + +// SetWebdavEnabled gets a reference to the given bool and assigns it to the WebdavEnabled field. +func (o *Bundle) SetWebdavEnabled(v bool) { + o.WebdavEnabled = &v +} + func (o Bundle) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -120,6 +153,9 @@ func (o Bundle) ToMap() (map[string]interface{}, error) { if !IsNil(o.MediaHdirProcessingEnabled) { toSerialize["media.hdir_processing_enabled"] = o.MediaHdirProcessingEnabled } + if !IsNil(o.WebdavEnabled) { + toSerialize["webdav.enabled"] = o.WebdavEnabled + } return toSerialize, nil } diff --git a/api/ts/dist/AllApi.d.ts b/api/ts/dist/AllApi.d.ts index c00cbf98..95d5d602 100644 --- a/api/ts/dist/AllApi.d.ts +++ b/api/ts/dist/AllApi.d.ts @@ -142,6 +142,7 @@ interface BackupInfo { interface Bundle { 'auth.allow_registrations'?: boolean; 'media.hdir_processing_enabled'?: boolean; + 'webdav.enabled'?: boolean; } interface CreateFolderBody { 'children'?: Array; diff --git a/api/ts/generated/api.ts b/api/ts/generated/api.ts index 668f96f2..1b92ef84 100644 --- a/api/ts/generated/api.ts +++ b/api/ts/generated/api.ts @@ -43,6 +43,7 @@ export interface BackupInfo { export interface Bundle { 'auth.allow_registrations'?: boolean; 'media.hdir_processing_enabled'?: boolean; + 'webdav.enabled'?: boolean; } export interface CreateFolderBody { 'children'?: Array; diff --git a/api/ts/generated/docs/Bundle.md b/api/ts/generated/docs/Bundle.md index 3032eb2e..0e481242 100644 --- a/api/ts/generated/docs/Bundle.md +++ b/api/ts/generated/docs/Bundle.md @@ -7,6 +7,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **auth_allow_registrations** | **boolean** | | [optional] [default to undefined] **media_hdir_processing_enabled** | **boolean** | | [optional] [default to undefined] +**webdav_enabled** | **boolean** | | [optional] [default to undefined] ## Example @@ -16,6 +17,7 @@ import { Bundle } from './api'; const instance: Bundle = { auth_allow_registrations, media_hdir_processing_enabled, + webdav_enabled, }; ``` diff --git a/cmd/weblens/main.go b/cmd/weblens/main.go index 83242d37..ff0fcc8d 100644 --- a/cmd/weblens/main.go +++ b/cmd/weblens/main.go @@ -19,7 +19,7 @@ func main() { cnf.DoFileDiscovery = true // Initialize logger - logger := log.NewZeroLogger() + logger := log.NewZeroLogger(log.CreateOpts{Level: cnf.LogLevel}) // Capture interrupt signals to allow for graceful shutdown. // The returned context will be canceled on interrupt. diff --git a/docs/docs.go b/docs/docs.go index 92938dd0..c0292254 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3081,6 +3081,9 @@ const docTemplate = `{ }, "media.hdir_processing_enabled": { "type": "boolean" + }, + "webdav.enabled": { + "type": "boolean" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index e5b9967d..af6a36e4 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3079,6 +3079,9 @@ }, "media.hdir_processing_enabled": { "type": "boolean" + }, + "webdav.enabled": { + "type": "boolean" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 68be6d3e..40f9d948 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -49,6 +49,8 @@ definitions: type: boolean media.hdir_processing_enabled: type: boolean + webdav.enabled: + type: boolean type: object CreateFolderBody: properties: diff --git a/go.mod b/go.mod index dd21c0d1..03d8ff85 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/lithammer/fuzzysearch v1.1.8 - github.com/pkg/errors v0.9.1 github.com/posener/wstest v1.2.0 github.com/rs/zerolog v1.33.0 github.com/saracen/fastzip v0.1.11 @@ -20,7 +19,8 @@ require ( github.com/u2takey/ffmpeg-go v0.5.0 github.com/viccon/sturdyc v1.1.5 go.mongodb.org/mongo-driver v1.17.2 - golang.org/x/crypto v0.32.0 + golang.org/x/crypto v0.48.0 + golang.org/x/net v0.50.0 ) replace github.com/ethanrous/weblens/api v0.0.0 => ./api/ @@ -51,9 +51,9 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1f0909e8..fc490c05 100644 --- a/go.sum +++ b/go.sum @@ -73,7 +73,6 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -123,26 +122,26 @@ gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -155,8 +154,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -167,16 +166,16 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/models/featureflags/feature_flag_model.go b/models/featureflags/feature_flag_model.go index a8cbb2c1..531d901d 100644 --- a/models/featureflags/feature_flag_model.go +++ b/models/featureflags/feature_flag_model.go @@ -25,12 +25,15 @@ const ( AllowRegistrations FlagKey = "auth.allow_registrations" // EnableHDIR controls whether HDIR (high-dynamic-range image rendering) is enabled. EnableHDIR FlagKey = "media.hdir_processing_enabled" + // EnableWebDAV controls whether WebDAV file access is enabled. + EnableWebDAV FlagKey = "webdav.enabled" ) // Bundle represents the application feature flag document. type Bundle struct { AllowRegistrations bool `bson:"auth.allow_registrations" json:"auth.allow_registrations"` EnableHDIR bool `bson:"media.hdir_processing_enabled" json:"media.hdir_processing_enabled"` + EnableWebDAV bool `bson:"webdav.enabled" json:"webdav.enabled"` } // @name Bundle // Default returns the default flags diff --git a/models/file/file.go b/models/file/file.go index c924e6e3..c01180f1 100644 --- a/models/file/file.go +++ b/models/file/file.go @@ -117,8 +117,12 @@ type WeblensFileImpl struct { // size in bytes of the file on the disk size atomic.Int64 + // writeHead is the current offset for read/write operations on the file. This is used to keep track of where the next read/write should occur. writeHead int64 + // writeHeadLock is a RWMutex to protect concurrent access to the writeHead field. + writeHeadLock sync.RWMutex + // If this file is a directory, these are the files that are housed by this directory. childLock sync.RWMutex @@ -333,7 +337,17 @@ func (f *WeblensFileImpl) Read(p []byte) (n int, err error) { fp, err := os.Open(f.portablePath.ToAbsolute()) if err != nil { - return 0, err + return -1, err + } + + defer fp.Close() //nolint:errcheck + + f.writeHeadLock.RLock() + defer f.writeHeadLock.RUnlock() + + _, err = fp.Seek(f.writeHead, io.SeekStart) + if err != nil { + return -1, err } return fp.Read(p) @@ -363,6 +377,9 @@ func (f *WeblensFileImpl) Readdir(count int) ([]fs.FileInfo, error) { // Seek sets the offset for the next read or write operation. func (f *WeblensFileImpl) Seek(offset int64, whence int) (int64, error) { + f.writeHeadLock.Lock() + defer f.writeHeadLock.Unlock() + switch whence { case io.SeekStart: f.writeHead = offset diff --git a/modules/cryptography/user.go b/modules/cryptography/user.go index f393e8ec..dddc42e3 100644 --- a/modules/cryptography/user.go +++ b/modules/cryptography/user.go @@ -13,6 +13,13 @@ import ( const BcryptDifficultyCtxKey = "bcryptDifficulty" const bcryptDefaultDifficulty = 11 +// HashUserPasswordDifficulty hashes a user password using bcrypt with the specified difficulty level. +func HashUserPasswordDifficulty(password string, difficulty int) ([]byte, error) { + difficulty = max(difficulty, bcrypt.MinCost) + + return bcrypt.GenerateFromPassword([]byte(password), difficulty) +} + // HashUserPassword hashes a user password using bcrypt. func HashUserPassword(ctx context.Context, password string) (string, error) { // For testing, we can set the bcrypt difficulty in the context @@ -23,7 +30,10 @@ func HashUserPassword(ctx context.Context, password string) (string, error) { bcryptDifficulty = bcryptDefaultDifficulty } - passHashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptDifficulty) + passHashBytes, err := HashUserPasswordDifficulty(password, bcryptDifficulty) + if err != nil { + return "", err + } return string(passHashBytes), err } diff --git a/modules/log/log.go b/modules/log/log.go index 649610a1..d4648a93 100644 --- a/modules/log/log.go +++ b/modules/log/log.go @@ -77,17 +77,17 @@ func NopLogger() zerolog.Logger { func NewZeroLogger(opts ...CreateOpts) *zerolog.Logger { o := compileCreateLogOpts(opts...) - loggerMu.RLock() - - if logger.GetLevel() != zerolog.Disabled && len(opts) == 0 { - l := logger.With().Logger() - - loggerMu.RUnlock() - - return &l - } - - loggerMu.RUnlock() + // loggerMu.RLock() + // + // if logger.GetLevel() != zerolog.Disabled && len(opts) == 0 { + // l := logger.With().Logger() + // + // loggerMu.RUnlock() + // + // return &l + // } + // + // loggerMu.RUnlock() outputLocation := os.Stdout @@ -133,24 +133,7 @@ func NewZeroLogger(opts ...CreateOpts) *zerolog.Logger { // Create a new logger instance with the specified output, log level, and build version context log := zerolog.New(logWriter).Level(level).With().Timestamp().Caller().Str("weblens_build_version", wlVersion).Logger() - // If no options are provided, set the global loggers - if len(opts) == 0 { - zerolog.SetGlobalLevel(config.LogLevel) - - loggerMu.Lock() - - // Set as our "global" logger - logger = log - - // Set as the zerolog global logger - zlog.Logger = log - - loggerMu.Unlock() - - log.Info().Msgf("Weblens logger initialized [%s][%s]", log.GetLevel(), config.LogFormat) - } else { - log.Debug().Msgf("Created new Weblens logger [%s][%s]", log.GetLevel(), config.LogFormat) - } + log.Trace().CallerSkipFrame(1).Msgf("Created new Weblens logger [%s][%s]", log.GetLevel(), config.LogFormat) return &log } @@ -171,17 +154,19 @@ func SetLogLevel(level zerolog.Level) { // The returned logger is safe to use concurrently and can have // UpdateContext called on it without racing with other goroutines. func GlobalLogger() *zerolog.Logger { - loggerMu.RLock() + loggerMu.Lock() + defer loggerMu.Unlock() if logger.GetLevel() == zerolog.Disabled { - loggerMu.RUnlock() + cnf := config.GetConfig() + logger = *NewZeroLogger(CreateOpts{Level: cnf.LogLevel}) - NewZeroLogger() + zerolog.SetGlobalLevel(cnf.LogLevel) - loggerMu.RLock() - } + zlog.Logger = logger - defer loggerMu.RUnlock() + logger.Debug().Msgf("Initialized global logger at level [%s]", logger.GetLevel()) + } // Use With().Logger() to create a copy // that doesn't share the context buffer diff --git a/routers/api/v1/tower/rest_admin.go b/routers/api/v1/tower/rest_admin.go index c82d89ab..bc7031db 100644 --- a/routers/api/v1/tower/rest_admin.go +++ b/routers/api/v1/tower/rest_admin.go @@ -115,6 +115,8 @@ func SetFlags(ctx ctxservice.RequestContext) { cnf.AllowRegistrations = param.ConfigValue.(bool) case featureflags.EnableHDIR: cnf.EnableHDIR = param.ConfigValue.(bool) + case featureflags.EnableWebDAV: + cnf.EnableWebDAV = param.ConfigValue.(bool) default: ctx.Error(http.StatusBadRequest, wlerrors.Errorf("Unknown feature flag: %s", param.ConfigKey)) } diff --git a/routers/api/v1/webdav.go b/routers/api/v1/webdav.go index 081282a0..1368fd1d 100644 --- a/routers/api/v1/webdav.go +++ b/routers/api/v1/webdav.go @@ -1,15 +1,102 @@ package v1 -import "net/http" - -// webdavOptions handles OPTIONS requests for WebDAV protocol discovery and capabilities negotiation. -func webdavOptions(w http.ResponseWriter, _ *http.Request) { - w.Header().Set( - "Allow", - "OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, ORDERPATCH", - ) - w.Header().Set( - "DAV", - "1, 2, ordered-collections", - ) +import ( + "net/http" + "strings" + "sync" + "time" + + "github.com/ethanrous/weblens/models/featureflags" + user_model "github.com/ethanrous/weblens/models/user" + "github.com/ethanrous/weblens/modules/wlerrors" + "github.com/ethanrous/weblens/routers/router" + "github.com/ethanrous/weblens/services" + "github.com/ethanrous/weblens/services/ctxservice" + "golang.org/x/net/webdav" +) + +// WebDAVRouter returns an http.Handler that serves WebDAV requests. +// It checks the WebDAV feature flag, authenticates via HTTP Basic Auth, +// and scopes file access to the authenticated user's home directory. +func WebDAVRouter(appCtx ctxservice.AppContext) router.HandlerFunc { + davFS := &services.WebdavFs{FileService: appCtx.FileService} + + davHandler := &webdav.Handler{ + Prefix: "", + FileSystem: davFS, + LockSystem: webdav.NewMemLS(), + Logger: func(_ *http.Request, err error) { + if err != nil { + appCtx.Log().Error().Err(err).Msg("WebDAV request error") + } + }, + } + + return router.HandlerFunc(getWebDAVHandlerFunc(davHandler)) +} + +// FIXME: This is AWFUL. I cannot imagine the security implications of this, but I cannot think of +// another way to stop the webdav from waiting ~2s EVERY request while it bcrypts the password. +var userCache = make(map[string]*user_model.User) +var userCacheLock = sync.RWMutex{} + +func getWebDAVHandlerFunc(davHandler *webdav.Handler) func(ctxservice.RequestContext) { + return func(ctx ctxservice.RequestContext) { + // Check feature flag + flags, err := featureflags.GetFlags(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, err) + + return + } + + if !flags.EnableWebDAV { + ctx.JSON(http.StatusServiceUnavailable, "WebDAV is disabled") + + return + } + + // HTTP Basic Auth + username, password, ok := ctx.Req.BasicAuth() + if !ok { + ctx.SetHeader("WWW-Authenticate", `Basic realm="Weblens WebDAV"`) + ctx.Error(http.StatusUnauthorized, wlerrors.Errorf("Unauthorized")) + + return + } + + var user *user_model.User + + var found bool + + userCacheLock.Lock() + + // FIXME: BAD VERY BAD SO VERY BAD. RAINBOW TABLE ATTACK IMMINENT. But better than being slow, I guess?? + cacheKey := username + password + strings.Split(ctx.Req.RemoteAddr, ":")[0] // Include IP to mitigate attacks + if user, found = userCache[cacheKey]; !found { + user, err = user_model.GetUserByUsername(ctx, username) + if err != nil { + time.Sleep(2 * time.Second) // Mitigate rainbow table attacks by adding a delay on failed lookups + } + + if err != nil || !user.CheckLogin(password) { + ctx.SetHeader("WWW-Authenticate", `Basic realm="Weblens WebDAV"`) + ctx.Error(http.StatusUnauthorized, wlerrors.Errorf("Unauthorized")) + + return + } + + ctx.Log().Info().Str("username", username).Msg("Authenticated WebDAV user") + + userCache[cacheKey] = user + } + + userCacheLock.Unlock() + + // Inject user into context for the filesystem adapter + newCtx := services.WithWebDAVUser(ctx, user) + + ctx.Req.URL.Path = strings.TrimPrefix(ctx.Req.URL.Path, "/webdav") + davHandler.ServeHTTP(ctx.W, ctx.Req.WithContext(newCtx)) + } } diff --git a/routers/router/middleware.go b/routers/router/middleware.go index 9b5e0355..fe39f9c3 100644 --- a/routers/router/middleware.go +++ b/routers/router/middleware.go @@ -217,13 +217,13 @@ func CORSMiddleware(next Handler) Handler { "Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, Content-Range, Cookie", ) - ctx.SetHeader("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE") + ctx.SetHeader("Access-Control-Allow-Methods", "POST, HEAD, OPTIONS, GET, PUT, PATCH, DELETE") - if ctx.Req.Method == http.MethodOptions { - ctx.Status(http.StatusNoContent) - - return - } + // if ctx.Req.Method == http.MethodOptions { + // ctx.Status(http.StatusNoContent) + // + // return + // } next.ServeHTTP(ctx) }) diff --git a/routers/router/router.go b/routers/router/router.go index 9e923ea9..a092c1d6 100644 --- a/routers/router/router.go +++ b/routers/router/router.go @@ -48,7 +48,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Mount attaches a sub-router at the specified prefix with optional middleware. func (r *Router) Mount(prefix string, h ...any) { - subRouter := h[len(h)-1].(http.Handler) + subRouter, ok := h[len(h)-1].(http.Handler) + if !ok { + if handlr, ok := h[len(h)-1].(HandlerFunc); ok { + subRouter = toStdHandlerFunc(handlr) + } else { + panic(wlerrors.Errorf("last argument to Mount must be an http.Handler but got %T", h[len(h)-1])) + } + } r.chi.With(r.middlewares...).With(parseMiddlewares(h[:len(h)-1]...)...).Mount(r.prefix+prefix, subRouter) } diff --git a/routers/startup.go b/routers/startup.go index 28080686..c239a403 100644 --- a/routers/startup.go +++ b/routers/startup.go @@ -27,6 +27,7 @@ import ( "github.com/ethanrous/weblens/services/jobs" "github.com/ethanrous/weblens/services/notify" _ "github.com/ethanrous/weblens/services/user" // Required to register user service routes + "github.com/go-chi/chi/v5" "github.com/rs/zerolog" ) @@ -41,11 +42,11 @@ type StartupOpts struct { Started chan context_service.AppContext } -func startupRecover() { +func startupRecover(appCtx context_service.AppContext) { if r := recover(); r != nil { err := wlerrors.Errorf("%v", r) - log.GlobalLogger().Fatal().Stack().Err(err).Msgf("Startup panicked") + appCtx.Log().Error().Stack().Err(err).Msgf("Startup panicked") } } @@ -110,7 +111,14 @@ func Start(opts StartupOpts) error { // Run startup hooks to initialize services and perform setup tasks. // This essentially boots up the entire app. + prevAppCtx := appCtx + appCtx, router, err := startServices(appCtx, cnf) + if err == nil && router == nil { + appCtx = prevAppCtx + err = wlerrors.New("startServices returned nil router") + } + if err != nil { // If we fail to start up, kill all the services that may have started, and exit. logger.Error().Stack().Err(err).Msg("Failed to start services") @@ -154,7 +162,7 @@ func Start(opts StartupOpts) error { // startServices initializes all application services and configures the HTTP router. func startServices(appCtx context_service.AppContext, cnf config.Provider) (context_service.AppContext, *router.Router, error) { - defer startupRecover() + defer startupRecover(appCtx) r := router.NewRouter() @@ -212,17 +220,26 @@ func startServices(appCtx context_service.AppContext, cnf config.Provider) (cont return appCtx, nil, err } + chi.RegisterMethod("PROPFIND") + chi.RegisterMethod("MKCOL") + chi.RegisterMethod("COPY") + chi.RegisterMethod("MOVE") + chi.RegisterMethod("LOCK") + chi.RegisterMethod("UNLOCK") + // Install middlewares r.Use( context_service.AppContexter(appCtx), router.CORSMiddleware, + router.LoggerMiddlewares(), + router.Recoverer, ) // Install routes - r.Mount("/api/v1/", router.LoggerMiddlewares(), router.Recoverer, v1.Routes(appCtx)) + r.Mount("/api/v1/", v1.Routes(appCtx)) - r.Use(router.Recoverer) r.Mount("/docs", v1.Docs()) + r.Mount("/webdav/", v1.WebDAVRouter(appCtx)) r.Mount("/", web.UIRoutes(web.NewMemFs(appCtx, cnf))) return appCtx, r, nil diff --git a/services/webdavFs.go b/services/webdavFs.go index d0252639..97e8fa6f 100644 --- a/services/webdavFs.go +++ b/services/webdavFs.go @@ -1,104 +1,312 @@ // Package services provides WebDAV filesystem implementation for Weblens. package services -// import "golang.org/x/net/webdav" - -// var _ webdav.FileSystem = (*WebdavFs)(nil) - -// WebdavFs is a placeholder type for WebDAV filesystem functionality. -type WebdavFs struct{} - -// func (w WebdavFs) Mkdir(ctx context.Context, name string, perm os.FileMode) error { -// unescapeName, err := url.QueryUnescape(name) -// if err != nil { -// return err -// } -// -// parent, err := w.WeblensFs.PathToFile(filepath.Dir(unescapeName)) -// if err != nil { -// return err -// } -// -// // TODO: add event -// _, err = w.WeblensFs.CreateFile(parent, filepath.Base(unescapeName), nil) -// if err != nil { -// return err -// } -// -// return nil -// } -// -// func (w WebdavFs) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { -// unescapeName, err := url.QueryUnescape(name) -// if err != nil { -// return nil, err -// } -// -// // filename := filepath.Base(unescapeName) -// // if strings.HasPrefix(filename, "._") { -// // filename = filename[2:] -// // unescapeName = filepath.Dir(unescapeName) + "/" + filename -// // } -// // if unescapeName == "." { -// // unescapeName = "/" -// // } -// -// f, err := w.WeblensFs.PathToFile(unescapeName) -// if err != nil { -// return nil, err -// } -// return f, nil -// } -// -// func (w WebdavFs) RemoveAll(ctx context.Context, name string) error { -// -// panic("implement me") -// } -// -// func (w WebdavFs) Rename(ctx context.Context, oldName, newName string) error { -// unescapeOldName, err := url.QueryUnescape(oldName) -// if err != nil { -// return err -// } -// unescapeNewName, err := url.QueryUnescape(newName) -// if err != nil { -// return err -// } -// -// oldFile, err := w.WeblensFs.PathToFile(unescapeOldName) -// if err != nil { -// return err -// } -// -// newParent, err := w.WeblensFs.PathToFile(filepath.Dir(unescapeNewName)) -// if err != nil { -// return err -// } -// -// err = w.WeblensFs.MoveFiles([]*fileTree.WeblensFileImpl{oldFile}, newParent, "USERS", w.Caster) -// if err != nil { -// return err -// } -// -// return nil -// } -// -// func (w WebdavFs) Stat(ctx context.Context, name string) (os.FileInfo, error) { -// unescapeName, err := url.QueryUnescape(name) -// if err != nil { -// return nil, err -// } -// -// filename := filepath.Base(unescapeName) -// if strings.HasPrefix(filename, "._") { -// filename = filename[2:] -// unescapeName = filepath.Dir(unescapeName) + "/" + filename -// } -// -// f, err := w.WeblensFs.PathToFile(unescapeName) -// if err != nil { -// return nil, err -// } -// -// return f, nil -// } +import ( + "context" + iofs "io/fs" + "os" + "path" + "strings" + "time" + + file_model "github.com/ethanrous/weblens/models/file" + user_model "github.com/ethanrous/weblens/models/user" + "github.com/ethanrous/weblens/modules/fs" + "github.com/ethanrous/weblens/modules/log" + "github.com/ethanrous/weblens/modules/wlerrors" + "golang.org/x/net/webdav" +) + +// Ensure WebdavFs implements the webdav.FileSystem interface. +var _ webdav.FileSystem = (*WebdavFs)(nil) + +type webdavUserKeyType struct{} + +var webdavUserKey = webdavUserKeyType{} + +// WithWebDAVUser returns a context carrying the authenticated WebDAV user. +func WithWebDAVUser(ctx context.Context, user *user_model.User) context.Context { + return context.WithValue(ctx, webdavUserKey, user) +} + +func webdavUser(ctx context.Context) *user_model.User { + u, _ := ctx.Value(webdavUserKey).(*user_model.User) + + return u +} + +// WebdavFs implements webdav.FileSystem by delegating all operations +// to the Weblens file service. Each request is scoped to the +// authenticated user's home directory. +type WebdavFs struct { + FileService file_model.Service +} + +// resolvePath translates a WebDAV path (e.g. "/photos/img.jpg") to a +// portable Weblens filepath (e.g. "USERS:username/photos/img.jpg"). +// The WebDAV root "/" maps to the user's home folder. +func resolvePath(webdavPath string, user *user_model.User, isDir bool) (fs.Filepath, error) { + webdavPath = strings.TrimPrefix(webdavPath, "/webdav/") + + if strings.Contains(webdavPath, file_model.UserTrashDirName) { + return fs.Filepath{}, os.ErrPermission + } + + cleaned := path.Clean(webdavPath) + if cleaned == "." || cleaned == "/" { + // User's home directory + return fs.BuildFilePath(file_model.UsersTreeKey, user.Username+"/"), nil + } + + // Strip leading slash + rel := strings.TrimPrefix(cleaned, "/") + + suffix := "" + if isDir { + suffix = "/" + } + + return fs.BuildFilePath(file_model.UsersTreeKey, user.Username+"/"+rel+suffix), nil +} + +// Stat returns file info for the named file, delegating to the file service. +func (w *WebdavFs) Stat(ctx context.Context, name string) (os.FileInfo, error) { + user := webdavUser(ctx) + if user == nil { + return nil, os.ErrPermission + } + + return w.lookupFile(ctx, name, user) +} + +// OpenFile opens or creates a file. For existing files it returns a wrapper; +// when O_CREATE is set and the file doesn't exist, it creates a new one. +func (w *WebdavFs) OpenFile(ctx context.Context, name string, flag int, _ os.FileMode) (webdav.File, error) { + start := time.Now() + user := webdavUser(ctx) + if user == nil { + return nil, os.ErrPermission + } + + f, lookupErr := w.lookupFile(ctx, name, user) + if lookupErr != nil { + return nil, lookupErr + // // File doesn't exist + // if flag&os.O_CREATE != 0 { + // // Create new file + // parentPath, err := resolvePath(path.Dir(name), user, true) + // if err != nil { + // return nil, err + // } + // + // parent, err := w.FileService.GetFileByFilepath(ctx, parentPath) + // if err != nil { + // return nil, wlerrors.Errorf("WebDav OpenFile failed to find parent directory at %s: %w", parentPath, file_model.ErrFileNotFound) + // } + // + // newFile, err := w.FileService.CreateFile(ctx, parent, path.Base(name)) + // if err != nil { + // return nil, err + // } + // + // return &webdavFile{file: newFile, fs: w, ctx: ctx}, nil + // } + // + // return nil, wlerrors.Errorf("WebDav OpenFile failed to lookup file at %s: %w", name, file_model.ErrFileNotFound) + } + + log.FromContext(ctx).Debug().Msgf("WebDAV OpenFile lookup for %s took %s", name, time.Since(start)) + return &webdavFile{file: f, fs: w, ctx: ctx}, nil +} + +// Mkdir creates a new directory via the file service. +func (w *WebdavFs) Mkdir(ctx context.Context, name string, _ os.FileMode) error { + log.FromContext(ctx).Debug().Msgf("WebDAV Mkdir called for %s", name) + user := webdavUser(ctx) + if user == nil { + return os.ErrPermission + } + + parentPath, err := resolvePath(path.Dir(name), user, true) + if err != nil { + log.FromContext(ctx).Error().Stack().Err(err).Msgf("WebDAV Mkdir failed to resolve parent path for %s", name) + + return err + } + + parent, err := w.FileService.GetFileByFilepath(ctx, parentPath) + if err != nil { + log.FromContext(ctx).Error().Stack().Err(err).Msgf("WebDAV Mkdir failed to find parent directory at %s", parentPath) + + return os.ErrNotExist + } + + _, err = w.FileService.CreateFolder(ctx, parent, path.Base(name)) + if err != nil { + log.FromContext(ctx).Error().Stack().Err(err).Msgf("WebDAV Mkdir failed for %s", name) + + return err + } + + return nil +} + +// RemoveAll deletes a file or directory via the file service. +func (w *WebdavFs) RemoveAll(ctx context.Context, name string) error { + user := webdavUser(ctx) + if user == nil { + return os.ErrPermission + } + + f, err := w.lookupFile(ctx, name, user) + if err != nil { + return err + } + + return w.FileService.DeleteFiles(ctx, f) +} + +// Rename moves and/or renames a file via the file service. +func (w *WebdavFs) Rename(ctx context.Context, oldName, newName string) error { + user := webdavUser(ctx) + if user == nil { + return os.ErrPermission + } + + f, err := w.lookupFile(ctx, oldName, user) + if err != nil { + return err + } + + oldDir := path.Dir(oldName) + newDir := path.Dir(newName) + oldBase := path.Base(oldName) + newBase := path.Base(newName) + + // If parents differ, move the file first + if oldDir != newDir { + destPath, err := resolvePath(newDir, user, true) + if err != nil { + return err + } + + dest, err := w.FileService.GetFileByFilepath(ctx, destPath) + if err != nil { + return os.ErrNotExist + } + + err = w.FileService.MoveFiles(ctx, []*file_model.WeblensFileImpl{f}, dest) + if err != nil { + return err + } + } + + // If names differ, rename + if oldBase != newBase { + return w.FileService.RenameFile(ctx, f, newBase) + } + + return nil +} + +func (w *WebdavFs) lookupFile(ctx context.Context, name string, user *user_model.User) (*file_model.WeblensFileImpl, error) { + fp, err := resolvePath(name, user, true) + if err != nil { + return nil, err + } + + f, err := w.FileService.GetFileByFilepath(ctx, fp) + if err == nil { + return f, nil + } + + // Try as file (no trailing slash) + fp, err = resolvePath(name, user, false) + if err != nil { + return nil, err + } + + f, err = w.FileService.GetFileByFilepath(ctx, fp) + if err != nil { + return nil, wlerrors.Errorf("WebDav Could not lookup file at %s: %w", fp, file_model.ErrFileNotFound) + } + + return f, nil +} + +// webdavFile wraps a WeblensFileImpl to implement the webdav.File interface. +// It lazily opens an os.File for read/seek operations on non-directory files. +// The ctx field preserves the request context for operations like Readdir +// that need the AppContext but don't receive a context parameter. +type webdavFile struct { + file *file_model.WeblensFileImpl + fs *WebdavFs + ctx context.Context + osFile *os.File +} + +var _ webdav.File = (*webdavFile)(nil) + +// Close closes the underlying os.File if one was opened. +func (wf *webdavFile) Close() error { + return nil +} + +// Read reads from the underlying file. +func (wf *webdavFile) Read(p []byte) (int, error) { + if wf.file.IsDir() { + return 0, os.ErrInvalid + } + + return wf.file.Read(p) +} + +// Readdir returns directory entries, filtering out the trash directory. +func (wf *webdavFile) Readdir(count int) ([]iofs.FileInfo, error) { + if !wf.file.IsDir() { + return nil, os.ErrInvalid + } + + children, err := wf.fs.FileService.GetChildren(wf.ctx, wf.file) + if err != nil { + return nil, err + } + + infos := make([]iofs.FileInfo, 0, len(children)) + for _, child := range children { + // Filter out the trash directory + if child.Name() == file_model.UserTrashDirName { + continue + } + + infos = append(infos, child) + } + + if count > 0 && count < len(infos) { + infos = infos[:count] + } + + return infos, nil +} + +// Seek seeks within the underlying os.File (lazily opened). +func (wf *webdavFile) Seek(offset int64, whence int) (int64, error) { + log.GlobalLogger().Debug().Msgf("webdavFile Seek called with offset=%d, whence=%d", offset, whence) + + if wf.file.IsDir() { + return 0, os.ErrInvalid + } + + return wf.file.Seek(offset, whence) + // return wf.osFile.Seek(offset, whence) +} + +// Stat returns file info for the wrapped file. +func (wf *webdavFile) Stat() (iofs.FileInfo, error) { + return wf.file, nil +} + +// Write writes data to the file via the Weblens file model. +func (wf *webdavFile) Write(p []byte) (int, error) { + return 0, wlerrors.New("WebDAV write operations are not supported") +} diff --git a/weblens-vue/weblens-nuxt/pages/settings/dev.vue b/weblens-vue/weblens-nuxt/pages/settings/dev.vue index ccff489b..d7c8ee70 100644 --- a/weblens-vue/weblens-nuxt/pages/settings/dev.vue +++ b/weblens-vue/weblens-nuxt/pages/settings/dev.vue @@ -22,22 +22,28 @@ @click="scanAllMedia" /> - - - +
+ + + + +
@@ -79,6 +85,7 @@ import { CancelTask } from '~/api/FileBrowserApi' import Divider from '~/components/atom/Divider.vue' import Table from '~/components/atom/Table.vue' import WeblensButton from '~/components/atom/WeblensButton.vue' +import WeblensCheckbox from '~/components/atom/WeblensCheckbox.vue' import { TableType, type TableColumn } from '~/types/table' const towerStore = useTowerStore() @@ -172,12 +179,12 @@ const { data: featureFlags, refresh: refreshFeatureFlags } = useAsyncData('featu return res.data }) -async function enableHDIR(enable: boolean) { +async function setFlag(key: string, value: boolean) { return useWeblensAPI() .FeatureFlagsAPI.setFlags([ { - configKey: 'media.hdir_processing_enabled', - configValue: enable as unknown as object, + configKey: key, + configValue: value as unknown as object, }, ]) .then(() => {