diff --git a/api/model/snapshot_model.go b/api/model/snapshot_model.go index 4d318d4aa..6de1afd17 100644 --- a/api/model/snapshot_model.go +++ b/api/model/snapshot_model.go @@ -67,3 +67,11 @@ type ImageProps struct { Width int `json:"width"` Height int `json:"height"` } + +type S3Reference struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Size int64 `json:"size"` + SnapshotID string `json:"snapshotId"` + ContentType string `json:"contentType"` +} diff --git a/api/router/file_router.go b/api/router/file_router.go index 37627a848..e614f86fc 100644 --- a/api/router/file_router.go +++ b/api/router/file_router.go @@ -85,6 +85,8 @@ func (r *FileRouter) AppendNonJWTRoutes(g fiber.Router) { g.Get("/:id/original:ext", r.DownloadOriginal) g.Get("/:id/preview:ext", r.DownloadPreview) g.Get("/:id/thumbnail:ext", r.DownloadThumbnail) + g.Post("/create_from_s3", r.CreateFromS3) + g.Patch("/:id/patch_from_s3", r.PatchFromS3) } // Create godoc @@ -159,7 +161,7 @@ func (r *FileRouter) Create(c *fiber.Ctx) error { } } }(tmpPath) - file, err = r.fileSvc.Store(file.ID, tmpPath, userID) + file, err = r.fileSvc.Store(file.ID, service.StoreOptions{Path: &tmpPath}, userID) if err != nil { return err } @@ -226,7 +228,7 @@ func (r *FileRouter) Patch(c *fiber.Ctx) error { } } }(tmpPath) - file, err = r.fileSvc.Store(file.ID, tmpPath, userID) + file, err = r.fileSvc.Store(file.ID, service.StoreOptions{Path: &tmpPath}, userID) if err != nil { return err } @@ -377,11 +379,11 @@ func (r *FileRouter) List(c *fiber.Ctx) error { SortOrder: sortOrder, } if query != "" { - bytes, err := base64.StdEncoding.DecodeString(query + strings.Repeat("=", (4-len(query)%4)%4)) + b, err := base64.StdEncoding.DecodeString(query + strings.Repeat("=", (4-len(query)%4)%4)) if err != nil { return errorpkg.NewInvalidQueryParamError("query") } - if err := json.Unmarshal(bytes, &opts.Query); err != nil { + if err := json.Unmarshal(b, &opts.Query); err != nil { return errorpkg.NewInvalidQueryParamError("query") } res, err = r.fileSvc.Search(id, opts, userID) @@ -917,6 +919,200 @@ func (r *FileRouter) DownloadThumbnail(c *fiber.Ctx) error { return c.Send(b) } +// CreateFromS3 godoc +// +// @Summary Create from S3 +// @Description Create from S3 +// @Tags Files +// @Id files_create_from_s3 +// @Accept x-www-form-urlencoded +// @Produce json +// @Param api_key query string true "API Key" +// @Param access_token query string true "Access Token" +// @Param workspace_id query string true "Workspace ID" +// @Param parent_id query string false "Parent ID" +// @Param name query string false "Name" +// @Param s3_key query string true "S3 Key" +// @Param s3_bucket query string true "S3 Bucket" +// @Param size query string true "Size" +// @Success 200 {object} service.File +// @Failure 404 {object} errorpkg.ErrorResponse +// @Failure 400 {object} errorpkg.ErrorResponse +// @Failure 500 {object} errorpkg.ErrorResponse +// @Router /files/create_from_s3 [post] +func (r *FileRouter) CreateFromS3(c *fiber.Ctx) error { + apiKey := c.Query("api_key") + if apiKey == "" { + return errorpkg.NewMissingQueryParamError("api_key") + } + if apiKey != r.config.Security.APIKey { + return errorpkg.NewInvalidAPIKeyError() + } + accessToken := c.Query("access_token") + if accessToken == "" { + return errorpkg.NewMissingQueryParamError("access_token") + } + userID, err := r.getUserIDFromAccessToken(accessToken) + if err != nil { + return c.SendStatus(http.StatusNotFound) + } + workspaceID := c.Query("workspace_id") + if workspaceID == "" { + return errorpkg.NewMissingQueryParamError("workspace_id") + } + parentID := c.Query("parent_id") + if parentID == "" { + workspace, err := r.workspaceSvc.Find(workspaceID, userID) + if err != nil { + return err + } + parentID = workspace.RootID + } + name := c.Query("name") + if name == "" { + return errorpkg.NewMissingQueryParamError("name") + } + s3Key := c.Query("s3_key") + if s3Key == "" { + return errorpkg.NewMissingQueryParamError("s3_key") + } + s3Bucket := c.Query("s3_bucket") + if s3Bucket == "" { + return errorpkg.NewMissingQueryParamError("s3_bucket") + } + snapshotID := c.Query("snapshot_id") + if snapshotID == "" { + return errorpkg.NewMissingQueryParamError("snapshot_id") + } + contentType := c.Query("content_type") + if contentType == "" { + return errorpkg.NewMissingQueryParamError("content_type") + } + var size int64 + if c.Query("size") == "" { + return errorpkg.NewMissingQueryParamError("size") + } + size, err = strconv.ParseInt(c.Query("size"), 10, 64) + if err != nil { + return err + } + ok, err := r.workspaceSvc.HasEnoughSpaceForByteSize(workspaceID, size) + if err != nil { + return err + } + if !*ok { + return errorpkg.NewStorageLimitExceededError() + } + file, err := r.fileSvc.Create(service.FileCreateOptions{ + Name: name, + Type: model.FileTypeFile, + ParentID: &parentID, + WorkspaceID: workspaceID, + }, userID) + if err != nil { + return err + } + file, err = r.fileSvc.Store(file.ID, service.StoreOptions{ + S3Reference: &model.S3Reference{ + Key: s3Key, + Bucket: s3Bucket, + SnapshotID: snapshotID, + Size: size, + ContentType: contentType, + }, + }, userID) + if err != nil { + return err + } + return c.Status(http.StatusCreated).JSON(file) +} + +// PatchFromS3 godoc +// +// @Summary Patch from S3 +// @Description Patch from S3 +// @Tags Files +// @Id files_patch_from_s3 +// @Accept x-www-form-urlencoded +// @Produce json +// @Param api_key query string true "API Key" +// @Param access_token query string true "Access Token" +// @Param s3_key query string true "S3 Key" +// @Param s3_bucket query string true "S3 Bucket" +// @Param size query string true "Size" +// @Param id path string true "ID" +// @Success 200 {object} service.File +// @Failure 404 {object} errorpkg.ErrorResponse +// @Failure 400 {object} errorpkg.ErrorResponse +// @Failure 500 {object} errorpkg.ErrorResponse +// @Router /files/{id}/patch_from_s3 [patch] +func (r *FileRouter) PatchFromS3(c *fiber.Ctx) error { + apiKey := c.Query("api_key") + if apiKey == "" { + return errorpkg.NewMissingQueryParamError("api_key") + } + if apiKey != r.config.Security.APIKey { + return errorpkg.NewInvalidAPIKeyError() + } + accessToken := c.Query("access_token") + if accessToken == "" { + return errorpkg.NewMissingQueryParamError("access_token") + } + userID, err := r.getUserIDFromAccessToken(accessToken) + if err != nil { + return c.SendStatus(http.StatusNotFound) + } + files, err := r.fileSvc.Find([]string{c.Params("id")}, userID) + if err != nil { + return err + } + file := files[0] + s3Key := c.Query("s3_key") + if s3Key == "" { + return errorpkg.NewMissingQueryParamError("s3_key") + } + s3Bucket := c.Query("s3_bucket") + if s3Bucket == "" { + return errorpkg.NewMissingQueryParamError("s3_bucket") + } + var size int64 + if c.Query("size") == "" { + return errorpkg.NewMissingQueryParamError("size") + } + size, err = strconv.ParseInt(c.Query("size"), 10, 64) + if err != nil { + return err + } + snapshotID := c.Query("snapshot_id") + if snapshotID == "" { + return errorpkg.NewMissingQueryParamError("snapshot_id") + } + contentType := c.Query("content_type") + if contentType == "" { + return errorpkg.NewMissingQueryParamError("content_type") + } + ok, err := r.workspaceSvc.HasEnoughSpaceForByteSize(file.WorkspaceID, size) + if err != nil { + return err + } + if !*ok { + return errorpkg.NewStorageLimitExceededError() + } + file, err = r.fileSvc.Store(file.ID, service.StoreOptions{ + S3Reference: &model.S3Reference{ + Key: s3Key, + Bucket: s3Bucket, + SnapshotID: snapshotID, + Size: size, + ContentType: contentType, + }, + }, userID) + if err != nil { + return err + } + return c.JSON(file) +} + func (r *FileRouter) getUserIDFromAccessToken(accessToken string) (string, error) { token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { diff --git a/api/service/file_service.go b/api/service/file_service.go index d550516a7..8c5ca53ce 100644 --- a/api/service/file_service.go +++ b/api/service/file_service.go @@ -201,7 +201,12 @@ func (svc *FileService) validateParent(id string, userID string) error { return nil } -func (svc *FileService) Store(id string, path string, userID string) (*File, error) { +type StoreOptions struct { + S3Reference *model.S3Reference + Path *string +} + +func (svc *FileService) Store(id string, opts StoreOptions, userID string) (*File, error) { file, err := svc.fileRepo.Find(id) if err != nil { return nil, err @@ -214,7 +219,39 @@ func (svc *FileService) Store(id string, path string, userID string) (*File, err if err != nil { return nil, err } - snapshotID := helper.NewID() + var snapshotID string + var size int64 + var path string + var original model.S3Object + var bucket string + var contentType string + if opts.S3Reference == nil { + snapshotID = helper.NewID() + path = *opts.Path + stat, err := os.Stat(*opts.Path) + if err != nil { + return nil, err + } + size = stat.Size() + original = model.S3Object{ + Bucket: workspace.GetBucket(), + Key: snapshotID + "/original" + strings.ToLower(filepath.Ext(path)), + Size: helper.ToPtr(size), + } + bucket = workspace.GetBucket() + contentType = infra.DetectMimeFromPath(path) + } else { + snapshotID = opts.S3Reference.SnapshotID + path = opts.S3Reference.Key + size = opts.S3Reference.Size + original = model.S3Object{ + Bucket: opts.S3Reference.Bucket, + Key: opts.S3Reference.Key, + Size: helper.ToPtr(size), + } + bucket = opts.S3Reference.Bucket + contentType = opts.S3Reference.ContentType + } snapshot := repo.NewSnapshot() snapshot.SetID(snapshotID) snapshot.SetVersion(latestVersion + 1) @@ -228,18 +265,11 @@ func (svc *FileService) Store(id string, path string, userID string) (*File, err if err = svc.snapshotRepo.MapWithFile(snapshotID, id); err != nil { return nil, err } - stat, err := os.Stat(path) - if err != nil { - return nil, err - } - exceedsProcessingLimit := stat.Size() > helper.MegabyteToByte(svc.fileIdent.GetProcessingLimitMB(path)) - original := model.S3Object{ - Bucket: workspace.GetBucket(), - Key: snapshotID + "/original" + strings.ToLower(filepath.Ext(path)), - Size: helper.ToPtr(stat.Size()), - } - if err = svc.s3.PutFile(original.Key, path, infra.DetectMimeFromPath(path), workspace.GetBucket(), minio.PutObjectOptions{}); err != nil { - return nil, err + exceedsProcessingLimit := size > helper.MegabyteToByte(svc.fileIdent.GetProcessingLimitMB(path)) + if opts.S3Reference == nil { + if err = svc.s3.PutFile(original.Key, path, contentType, bucket, minio.PutObjectOptions{}); err != nil { + return nil, err + } } snapshot.SetOriginal(&original) if exceedsProcessingLimit { diff --git a/docker-compose.yml b/docker-compose.yml index c1a6d194a..2004266d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -146,6 +146,8 @@ services: - PORT=8082 - IDP_URL=http://idp:8081 - API_URL=http://api:8080 + - REDIS_ADDRESS=redis:6379 + - S3_URL=minio:9000 healthcheck: test: wget --quiet --spider http://127.0.0.1:8082/v2/health || exit 1 depends_on: diff --git a/webdav/.dockerignore b/webdav/.dockerignore deleted file mode 100644 index b512c09d4..000000000 --- a/webdav/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/webdav/.editorconfig b/webdav/.editorconfig deleted file mode 100644 index 1ed453a37..000000000 --- a/webdav/.editorconfig +++ /dev/null @@ -1,10 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true - -[*.{js,json,yml}] -charset = utf-8 -indent_style = space -indent_size = 2 diff --git a/webdav/.env b/webdav/.env index c1fd7eddd..12330704c 100644 --- a/webdav/.env +++ b/webdav/.env @@ -1,3 +1,19 @@ PORT=8082 + +# Security +SECURITY_API_KEY="7znl9Zd8F!4lRZA43lEQb757mCy" + +# URLs IDP_URL="http://127.0.0.1:8081" API_URL="http://127.0.0.1:8080" + +# S3 +S3_URL="127.0.0.1:9000" +S3_ACCESS_KEY="voltaserve" +S3_SECRET_KEY="voltaserve" +S3_REGION="us-east-1" +S3_SECURE=false + +# Redis +REDIS_ADDRESS="127.0.0.1:6379" +REDIS_DB=0 \ No newline at end of file diff --git a/webdav/.gitattributes b/webdav/.gitattributes index af3ad1281..920af94ba 100644 --- a/webdav/.gitattributes +++ b/webdav/.gitattributes @@ -1,4 +1 @@ -/.yarn/** linguist-vendored -/.yarn/releases/* binary -/.yarn/plugins/**/* binary -/.pnp.* binary linguist-generated +docs/** linguist-detectable=false \ No newline at end of file diff --git a/webdav/.gitignore b/webdav/.gitignore index 329d75c8d..65112857f 100644 --- a/webdav/.gitignore +++ b/webdav/.gitignore @@ -1,2 +1,5 @@ -node_modules -.env.local \ No newline at end of file +/.env.local +/voltaserve +__debug_bin* +/*.tsv +/.air diff --git a/webdav/.prettierrc.json b/webdav/.prettierrc.json deleted file mode 100644 index e07dba3e3..000000000 --- a/webdav/.prettierrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "plugins": ["@trivago/prettier-plugin-sort-imports"], - "singleQuote": true, - "semi": false, - "quoteProps": "preserve", - "importOrder": [ - "^@/infra/env$", - "", - "^@/(.*)$", - "^[./]" - ], - "importOrderSeparation": false -} diff --git a/webdav/Dockerfile b/webdav/Dockerfile index 08c7f9a59..4db03b29c 100644 --- a/webdav/Dockerfile +++ b/webdav/Dockerfile @@ -8,18 +8,22 @@ # by the GNU Affero General Public License v3.0 only, included in the file # licenses/AGPL.txt. -FROM oven/bun:1-alpine +FROM golang:1.22-alpine AS builder -WORKDIR /app +WORKDIR /build + +COPY . . + +RUN go mod download +RUN go build -o voltaserve-webdav -COPY src ./src -COPY .env . -COPY package.json . -COPY bun.lockb . -COPY tsconfig.json . +FROM golang:1.22-alpine AS runner + +WORKDIR /app -RUN bun install --frozen-lockfile --production +COPY --from=builder /build/voltaserve-webdav ./voltaserve-webdav +COPY --from=builder /build/.env ./.env -ENTRYPOINT ["bun", "run", "start"] +ENTRYPOINT ["./voltaserve-webdav"] EXPOSE 8082 diff --git a/webdav/README.md b/webdav/README.md index e40fb478a..ba718dabe 100644 --- a/webdav/README.md +++ b/webdav/README.md @@ -1,33 +1,25 @@ # Voltaserve WebDAV -Install dependencies: +Install [golangci-lint](https://github.com/golangci/golangci-lint). -```shell -bun i -``` +Install [swag](https://github.com/swaggo/swag). Run for development: ```shell -bun run dev +go run . ``` -Run for production: +Build binary: ```shell -bun run start +go build . ``` Lint code: ```shell -bun run lint -``` - -Format code: - -```shell -bun run format +golangci-lint run ``` Build Docker image: diff --git a/webdav/bun.lockb b/webdav/bun.lockb deleted file mode 100755 index cbc2c6dbd..000000000 Binary files a/webdav/bun.lockb and /dev/null differ diff --git a/webdav/cache/workspace_cache.go b/webdav/cache/workspace_cache.go new file mode 100644 index 000000000..8023ca330 --- /dev/null +++ b/webdav/cache/workspace_cache.go @@ -0,0 +1,51 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package cache + +import ( + "encoding/json" + "voltaserve/infra" +) + +type WorkspaceCache struct { + redis *infra.RedisManager + keyPrefix string +} + +func NewWorkspaceCache() *WorkspaceCache { + return &WorkspaceCache{ + redis: infra.NewRedisManager(), + keyPrefix: "workspace:", + } +} + +type Workspace struct { + ID string `json:"id," gorm:"column:id;size:36"` + Name string `json:"name" gorm:"column:name;size:255"` + StorageCapacity int64 `json:"storageCapacity" gorm:"column:storage_capacity"` + RootID string `json:"rootId" gorm:"column:root_id;size:36"` + OrganizationID string `json:"organizationId" gorm:"column:organization_id;size:36"` + Bucket string `json:"bucket" gorm:"column:bucket;size:255"` + CreateTime string `json:"createTime" gorm:"column:create_time"` + UpdateTime *string `json:"updateTime,omitempty" gorm:"column:update_time"` +} + +func (c *WorkspaceCache) Get(id string) (*Workspace, error) { + value, err := c.redis.Get(c.keyPrefix + id) + if err != nil { + return nil, err + } + var res Workspace + if err = json.Unmarshal([]byte(value), &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/webdav/client/api_client.go b/webdav/client/api_client.go new file mode 100644 index 000000000..493959e92 --- /dev/null +++ b/webdav/client/api_client.go @@ -0,0 +1,513 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package client + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "voltaserve/config" + "voltaserve/helper" + "voltaserve/infra" +) + +const ( + FileTypeFile = "file" + FileTypeFolder = "folder" +) + +type APIClient struct { + config *config.Config + token *infra.Token +} + +func NewAPIClient(token *infra.Token) *APIClient { + return &APIClient{ + token: token, + config: config.GetConfig(), + } +} + +type File struct { + ID string `json:"id"` + WorkspaceID string `json:"workspaceId"` + Name string `json:"name"` + Type string `json:"type"` + ParentID string `json:"parentId"` + Permission string `json:"permission"` + IsShared bool `json:"isShared"` + Snapshot *Snapshot `json:"snapshot,omitempty"` + CreateTime string `json:"createTime"` + UpdateTime *string `json:"updateTime,omitempty"` +} + +type Snapshot struct { + Version int `json:"version"` + Original *Download `json:"original,omitempty"` + Preview *Download `json:"preview,omitempty"` + OCR *Download `json:"ocr,omitempty"` + Text *Download `json:"text,omitempty"` + Thumbnail *Thumbnail `json:"thumbnail,omitempty"` +} + +type Download struct { + Extension string `json:"extension"` + Size int `json:"size"` + Image *ImageProps `json:"image,omitempty"` +} + +type ImageProps struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type Thumbnail struct { + Base64 string `json:"base64"` + Width int `json:"width"` + Height int `json:"height"` +} + +type FileCreateFolderOptions struct { + Type string + WorkspaceID string + ParentID string + Name string +} + +func (cl *APIClient) CreateFolder(opts FileCreateFolderOptions) (*File, error) { + params := url.Values{} + params.Set("type", opts.Type) + params.Set("workspace_id", opts.WorkspaceID) + if opts.ParentID != "" { + params.Set("parent_id", opts.ParentID) + } + params.Set("name", opts.Name) + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/files?%s", cl.config.APIURL, params.Encode()), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var file File + if err = json.Unmarshal(body, &file); err != nil { + return nil, err + } + return &file, nil +} + +type S3Reference struct { + Bucket string + Key string + SnapshotID string + Size int64 + ContentType string +} + +type FileCreateFromS3Options struct { + Type string + WorkspaceID string + ParentID string + Name string + S3Reference S3Reference +} + +func (cl *APIClient) CreateFileFromS3(opts FileCreateFromS3Options) (*File, error) { + body, err := json.Marshal(opts) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", + fmt.Sprintf("%s/v2/files/create_from_s3?api_key=%s&access_token=%s&workspace_id=%s&parent_id=%s&name=%s&s3_key=%s&s3_bucket=%s&snapshot_id=%s&content_type=%s&size=%d", + cl.config.APIURL, + cl.config.Security.APIKey, + cl.token.AccessToken, + opts.WorkspaceID, + opts.ParentID, + opts.Name, + opts.S3Reference.Key, + opts.S3Reference.Bucket, + opts.S3Reference.SnapshotID, + opts.S3Reference.ContentType, + opts.S3Reference.Size, + ), + bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + return + } + }(res.Body) + var file File + if err = json.Unmarshal(body, &file); err != nil { + return nil, err + } + return &file, nil +} + +type FilePatchFromS3Options struct { + ID string + Name string + S3Reference S3Reference +} + +func (cl *APIClient) PatchFileFromS3(opts FilePatchFromS3Options) (*File, error) { + body, err := json.Marshal(opts) + if err != nil { + return nil, err + } + req, err := http.NewRequest("PATCH", + fmt.Sprintf("%s/v2/files/%s/patch_from_s3?api_key=%s&access_token=%s&name=%s&s3_key=%s&s3_bucket=%s&snapshot_id=%s&content_type=%s&size=%d", + cl.config.APIURL, + opts.ID, + cl.config.Security.APIKey, + cl.token.AccessToken, + opts.Name, + opts.S3Reference.Key, + opts.S3Reference.Bucket, + opts.S3Reference.SnapshotID, + opts.S3Reference.ContentType, + opts.S3Reference.Size, + ), + bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + if err := Body.Close(); err != nil { + infra.GetLogger().Error(err.Error()) + return + } + }(res.Body) + var file File + if err = json.Unmarshal(body, &file); err != nil { + return nil, err + } + return &file, nil +} + +func (cl *APIClient) GetFileByPath(path string) (*File, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/files?path=%s", cl.config.APIURL, helper.EncodeURIComponent(path)), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var file File + if err = json.Unmarshal(body, &file); err != nil { + return nil, err + } + return &file, nil +} + +func (cl *APIClient) ListFilesByPath(path string) ([]File, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/files/list?path=%s", cl.config.APIURL, helper.EncodeURIComponent(path)), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var files []File + if err = json.Unmarshal(body, &files); err != nil { + return nil, err + } + return files, nil +} + +type FileCopyOptions struct { + IDs []string `json:"ids"` +} + +func (cl *APIClient) CopyFile(id string, opts FileCopyOptions) ([]File, error) { + b, err := json.Marshal(opts) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/files/%s/copy", cl.config.APIURL, id), bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var files []File + if err = json.Unmarshal(body, &files); err != nil { + return nil, err + } + return files, nil +} + +type FileMoveOptions struct { + IDs []string `json:"ids"` +} + +func (cl *APIClient) MoveFile(id string, opts FileMoveOptions) error { + body, err := json.Marshal(opts) + if err != nil { + return err + } + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/files/%s/move", cl.config.APIURL, id), bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + return cl.successfulResponseOrThrow(resp) +} + +type FileRenameOptions struct { + Name string `json:"name"` +} + +func (cl *APIClient) PatchFileName(id string, opts FileRenameOptions) (*File, error) { + b, err := json.Marshal(opts) + if err != nil { + return nil, err + } + req, err := http.NewRequest("PATCH", fmt.Sprintf("%s/v2/files/%s/name", cl.config.APIURL, id), bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var file File + if err = json.Unmarshal(body, &file); err != nil { + return nil, err + } + return &file, nil +} + +func (cl *APIClient) DeleteFile(id string) ([]string, error) { + b, err := json.Marshal(map[string][]string{"ids": {id}}) + if err != nil { + return nil, err + } + req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/v2/files", cl.config.APIURL), bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cl.token.AccessToken) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var ids []string + if err = json.Unmarshal(body, &ids); err != nil { + return nil, err + } + return ids, nil +} + +func (cl *APIClient) DownloadOriginal(file *File, outputPath string) error { + resp, err := http.Get(fmt.Sprintf("%s/v2/files/%s/original%s?access_token=%s", cl.config.APIURL, file.ID, file.Snapshot.Original.Extension, cl.token.AccessToken)) + if err != nil { + return err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer func(out *os.File) { + err := out.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(out) + _, err = io.Copy(out, resp.Body) + return err +} + +func (cl *APIClient) jsonResponseOrThrow(resp *http.Response) ([]byte, error) { + if strings.HasPrefix(resp.Header.Get("content-type"), "application/json") { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode > 299 { + var apiError infra.APIErrorResponse + if err = json.Unmarshal(body, &apiError); err != nil { + return nil, err + } + return nil, &infra.APIError{Value: apiError} + } else { + return body, nil + } + } else { + return nil, errors.New("unexpected response format") + } +} + +func (cl *APIClient) successfulResponseOrThrow(resp *http.Response) error { + if resp.StatusCode > 299 { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var apiError infra.APIErrorResponse + if err = json.Unmarshal(body, &apiError); err != nil { + return err + } + return &infra.APIError{Value: apiError} + } else { + return nil + } +} + +type HealthAPIClient struct { + config *config.Config +} + +func NewHealthAPIClient() *HealthAPIClient { + return &HealthAPIClient{ + config: config.GetConfig(), + } +} + +func (cl *HealthAPIClient) GetHealth() (string, error) { + resp, err := http.Get(fmt.Sprintf("%s/v2/health", cl.config.IdPURL)) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/webdav/client/idp_client.go b/webdav/client/idp_client.go new file mode 100644 index 000000000..6fd57550a --- /dev/null +++ b/webdav/client/idp_client.go @@ -0,0 +1,135 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package client + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "voltaserve/config" + "voltaserve/infra" +) + +const ( + GrantTypePassword = "password" + GrantTypeRefreshToken = "refresh_token" +) + +type TokenExchangeOptions struct { + GrantType string `json:"grant_type"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Locale string `json:"locale,omitempty"` +} + +type IdPClient struct { + config *config.Config +} + +func NewIdPClient() *IdPClient { + return &IdPClient{ + config: config.GetConfig(), + } +} + +func (cl *IdPClient) Exchange(options TokenExchangeOptions) (*infra.Token, error) { + form := url.Values{} + form.Set("grant_type", options.GrantType) + if options.Username != "" { + form.Set("username", options.Username) + } + if options.Password != "" { + form.Set("password", options.Password) + } + if options.RefreshToken != "" { + form.Set("refresh_token", options.RefreshToken) + } + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/token", cl.config.IdPURL), bytes.NewBufferString(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := cl.jsonResponseOrThrow(resp) + if err != nil { + return nil, err + } + var token infra.Token + if err = json.Unmarshal(body, &token); err != nil { + return nil, err + } + return &token, nil +} + +func (cl *IdPClient) jsonResponseOrThrow(resp *http.Response) ([]byte, error) { + if strings.HasPrefix(resp.Header.Get("content-type"), "application/json") { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode > 299 { + var idpError infra.IdPErrorResponse + err = json.Unmarshal(body, &idpError) + if err != nil { + return nil, err + } + return nil, &infra.IdPError{Value: idpError} + } else { + return body, nil + } + } else { + return nil, errors.New("unexpected response format") + } +} + +type HealthIdPClient struct { + config *config.Config +} + +func NewHealthIdPClient() *HealthIdPClient { + return &HealthIdPClient{ + config: config.GetConfig(), + } +} + +func (cl *HealthIdPClient) GetHealth() (string, error) { + resp, err := http.Get(fmt.Sprintf("%s/v2/health", cl.config.IdPURL)) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + infra.GetLogger().Error(err.Error()) + } + }(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/webdav/config/config.go b/webdav/config/config.go new file mode 100644 index 000000000..966bc9b06 --- /dev/null +++ b/webdav/config/config.go @@ -0,0 +1,97 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package config + +import ( + "os" + "strconv" +) + +type Config struct { + Port int + APIURL string + IdPURL string + S3 S3Config + Redis RedisConfig + Security SecurityConfig +} + +type S3Config struct { + URL string + AccessKey string + SecretKey string + Region string + Secure bool +} + +type RedisConfig struct { + Address string + Password string + DB int +} + +type SecurityConfig struct { + APIKey string `json:"api_key"` +} + +var config *Config + +func GetConfig() *Config { + if config == nil { + port, err := strconv.Atoi(os.Getenv("PORT")) + if err != nil { + panic(err) + } + config = &Config{ + Port: port, + } + readURLs(config) + readS3(config) + readRedis(config) + readSecurity(config) + } + return config +} + +func readURLs(config *Config) { + config.APIURL = os.Getenv("API_URL") + config.IdPURL = os.Getenv("IDP_URL") +} + +func readS3(config *Config) { + config.S3.URL = os.Getenv("S3_URL") + config.S3.AccessKey = os.Getenv("S3_ACCESS_KEY") + config.S3.SecretKey = os.Getenv("S3_SECRET_KEY") + config.S3.Region = os.Getenv("S3_REGION") + if len(os.Getenv("S3_SECURE")) > 0 { + v, err := strconv.ParseBool(os.Getenv("S3_SECURE")) + if err != nil { + panic(err) + } + config.S3.Secure = v + } +} + +func readRedis(config *Config) { + config.Redis.Address = os.Getenv("REDIS_ADDRESS") + config.Redis.Password = os.Getenv("REDIS_PASSWORD") + if len(os.Getenv("REDIS_DB")) > 0 { + v, err := strconv.ParseInt(os.Getenv("REDIS_DB"), 10, 32) + if err != nil { + panic(err) + } + config.Redis.DB = int(v) + } +} + +func readSecurity(config *Config) { + config.Security.APIKey = os.Getenv("SECURITY_API_KEY") +} diff --git a/webdav/go.mod b/webdav/go.mod new file mode 100644 index 000000000..e60039c5b --- /dev/null +++ b/webdav/go.mod @@ -0,0 +1,33 @@ +module voltaserve + +go 1.22 + +toolchain go1.22.2 + +require ( + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/speps/go-hashids/v2 v2.0.1 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.4 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.73 // indirect + github.com/redis/go-redis/v9 v9.5.3 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/stretchr/testify v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/webdav/go.sum b/webdav/go.sum new file mode 100644 index 000000000..511dbfef7 --- /dev/null +++ b/webdav/go.sum @@ -0,0 +1,54 @@ +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= +github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.73 h1:qr2vi96Qm7kZ4v7LLebjte+MQh621fFWnv93p12htEo= +github.com/minio/minio-go/v7 v7.0.73/go.mod h1:qydcVzV8Hqtj1VtEocfxbmVFa2siu6HGa+LDEPogjD8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= +github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/speps/go-hashids/v2 v2.0.1 h1:ViWOEqWES/pdOSq+C1SLVa8/Tnsd52XC34RY7lt7m4g= +github.com/speps/go-hashids/v2 v2.0.1/go.mod h1:47LKunwvDZki/uRVD6NImtyk712yFzIs3UF3KlHohGw= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/webdav/handler/handler.go b/webdav/handler/handler.go new file mode 100644 index 000000000..03ca84850 --- /dev/null +++ b/webdav/handler/handler.go @@ -0,0 +1,77 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "net/http" + "voltaserve/cache" + "voltaserve/client" + "voltaserve/infra" +) + +type Handler struct { + s3 *infra.S3Manager + workspaceCache *cache.WorkspaceCache +} + +func NewHandler() *Handler { + return &Handler{ + s3: infra.NewS3Manager(), + workspaceCache: cache.NewWorkspaceCache(), + } +} + +func (h *Handler) Dispatch(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "OPTIONS": + h.methodOptions(w, r) + case "GET": + h.methodGet(w, r) + case "HEAD": + h.methodHead(w, r) + case "PUT": + h.methodPut(w, r) + case "DELETE": + h.methodDelete(w, r) + case "MKCOL": + h.methodMkcol(w, r) + case "COPY": + h.methodCopy(w, r) + case "MOVE": + h.methodMove(w, r) + case "PROPFIND": + h.methodPropfind(w, r) + case "PROPPATCH": + h.methodProppatch(w, r) + default: + http.Error(w, "Method not implemented", http.StatusNotImplemented) + } +} + +func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) { + apiClient := client.NewHealthAPIClient() + apiHealth, err := apiClient.GetHealth() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + idpClient := client.NewHealthIdPClient() + idpHealth, err := idpClient.GetHealth() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if apiHealth == "OK" && idpHealth == "OK" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusServiceUnavailable) +} diff --git a/webdav/handler/method_copy.go b/webdav/handler/method_copy.go new file mode 100644 index 000000000..30832c2a0 --- /dev/null +++ b/webdav/handler/method_copy.go @@ -0,0 +1,73 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "net/http" + "path" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method copies a resource from a source URL to a destination URL. + +Example implementation: + +- Extract the source and destination paths from the headers or request body. +- Use fs.copyFile() to copy the file from the source to the destination. +- Set the response status code to 204 if successful or an appropriate error code if the source file is not found or encountered an error. +- Return the response. +*/ +func (h *Handler) methodCopy(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + sourcePath := helper.DecodeURIComponent(r.URL.Path) + targetPath := helper.DecodeURIComponent(helper.GetTargetPath(r)) + sourceFile, err := apiClient.GetFileByPath(sourcePath) + if err != nil { + infra.HandleError(err, w) + return + } + targetDir := helper.DecodeURIComponent(helper.Dirname(helper.GetTargetPath(r))) + targetFile, err := apiClient.GetFileByPath(targetDir) + if err != nil { + infra.HandleError(err, w) + return + } + if sourceFile.WorkspaceID != targetFile.WorkspaceID { + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write([]byte("Source and target files are in different workspaces")); err != nil { + return + } + } else { + clones, err := apiClient.CopyFile(targetFile.ID, client.FileCopyOptions{ + IDs: []string{sourceFile.ID}, + }) + if err != nil { + infra.HandleError(err, w) + return + } + if _, err = apiClient.PatchFileName(clones[0].ID, client.FileRenameOptions{ + Name: path.Base(targetPath), + }); err != nil { + infra.HandleError(err, w) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/webdav/handler/method_delete.go b/webdav/handler/method_delete.go new file mode 100644 index 000000000..5063ff15c --- /dev/null +++ b/webdav/handler/method_delete.go @@ -0,0 +1,48 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "net/http" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method deletes a resource identified by the URL. + +Example implementation: + +- Extract the file path from the URL. +- Use fs.unlink() to delete the file. +- Set the response status code to 204 if successful or an appropriate error code if the file is not found. +- Return the response. +*/ +func (h *Handler) methodDelete(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + file, err := apiClient.GetFileByPath(helper.DecodeURIComponent(r.URL.Path)) + if err != nil { + infra.HandleError(err, w) + return + } + if _, err = apiClient.DeleteFile(file.ID); err != nil { + infra.HandleError(err, w) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/webdav/handler/method_get.go b/webdav/handler/method_get.go new file mode 100644 index 000000000..53c229eb4 --- /dev/null +++ b/webdav/handler/method_get.go @@ -0,0 +1,125 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "github.com/google/uuid" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method retrieves the content of a resource identified by the URL. + +Example implementation: + +- Extract the file path from the URL. +- Create a read stream from the file and pipe it to the response stream. +- Set the response status code to 200 if successful or an appropriate error code if the file is not found. +- Return the response. +*/ +func (h *Handler) methodGet(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + filePath := helper.DecodeURIComponent(r.URL.Path) + file, err := apiClient.GetFileByPath(filePath) + if err != nil { + infra.HandleError(err, w) + return + } + outputPath := filepath.Join(os.TempDir(), uuid.New().String()) + err = apiClient.DownloadOriginal(file, outputPath) + if err != nil { + infra.HandleError(err, w) + return + } + stat, err := os.Stat(outputPath) + if err != nil { + infra.HandleError(err, w) + return + } + rangeHeader := r.Header.Get("Range") + if rangeHeader != "" { + rangeHeader = strings.Replace(rangeHeader, "bytes=", "", 1) + parts := strings.Split(rangeHeader, "-") + rangeStart, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + rangeStart = 0 + } + rangeEnd := stat.Size() - 1 + if len(parts) > 1 && parts[1] != "" { + rangeEnd, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil { + rangeEnd = stat.Size() - 1 + } + } + chunkSize := rangeEnd - rangeStart + 1 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rangeStart, rangeEnd, stat.Size())) + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Length", fmt.Sprintf("%d", chunkSize)) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusPartialContent) + file, err := os.Open(outputPath) + if err != nil { + infra.HandleError(err, w) + return + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + infra.HandleError(err, w) + } + }(file) + if _, err := file.Seek(rangeStart, 0); err != nil { + infra.HandleError(err, w) + return + } + if _, err := io.CopyN(w, file, chunkSize); err != nil { + return + } + if err := os.Remove(outputPath); err != nil { + return + } + } else { + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + file, err := os.Open(outputPath) + if err != nil { + infra.HandleError(err, w) + return + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + infra.HandleError(err, w) + } + }(file) + if _, err := io.Copy(w, file); err != nil { + return + } + if err := os.Remove(outputPath); err != nil { + return + } + } +} diff --git a/webdav/handler/method_head.go b/webdav/handler/method_head.go new file mode 100644 index 000000000..dad4f792c --- /dev/null +++ b/webdav/handler/method_head.go @@ -0,0 +1,49 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "net/http" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method is similar to GET but only retrieves the metadata of a resource, without returning the actual content. + +Example implementation: + +- Extract the file path from the URL. +- Retrieve the file metadata using fs.stat(). +- Set the response status code to 200 if successful or an appropriate error code if the file is not found. +- Set the Content-Length header with the file size. +- Return the response. +*/ +func (h *Handler) methodHead(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + filePath := helper.DecodeURIComponent(r.URL.Path) + file, err := apiClient.GetFileByPath(filePath) + if err != nil { + infra.HandleError(err, w) + return + } + if file.Type == client.FileTypeFile { + w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Snapshot.Original.Size)) + } + w.WriteHeader(http.StatusOK) +} diff --git a/webdav/handler/method_mkcol.go b/webdav/handler/method_mkcol.go new file mode 100644 index 000000000..976390f5d --- /dev/null +++ b/webdav/handler/method_mkcol.go @@ -0,0 +1,55 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "net/http" + "path" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method creates a new collection (directory) at the specified URL. + +Example implementation: + +- Extract the directory path from the URL. +- Use fs.mkdir() to create the directory. +- Set the response status code to 201 if created or an appropriate error code if the directory already exists or encountered an error. +- Return the response. +*/ +func (h *Handler) methodMkcol(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + directoryPath := helper.DecodeURIComponent(helper.Dirname(r.URL.Path)) + directory, err := apiClient.GetFileByPath(directoryPath) + if err != nil { + infra.HandleError(err, w) + return + } + if _, err = apiClient.CreateFolder(client.FileCreateFolderOptions{ + Type: client.FileTypeFolder, + WorkspaceID: directory.WorkspaceID, + ParentID: directory.ID, + Name: helper.DecodeURIComponent(path.Base(r.URL.Path)), + }); err != nil { + infra.HandleError(err, w) + return + } + w.WriteHeader(http.StatusCreated) +} diff --git a/webdav/handler/method_move.go b/webdav/handler/method_move.go new file mode 100644 index 000000000..537b4c770 --- /dev/null +++ b/webdav/handler/method_move.go @@ -0,0 +1,79 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "net/http" + "path" + "strings" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method moves or renames a resource from a source URL to a destination URL. + +Example implementation: + +- Extract the source and destination paths from the headers or request body. +- Use fs.rename() to move or rename the file from the source to the destination. +- Set the response status code to 204 if successful or an appropriate error code if the source file is not found or encountered an error. +- Return the response. +*/ +func (h *Handler) methodMove(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + sourcePath := helper.DecodeURIComponent(r.URL.Path) + targetPath := helper.DecodeURIComponent(helper.GetTargetPath(r)) + sourceFile, err := apiClient.GetFileByPath(sourcePath) + if err != nil { + infra.HandleError(err, w) + return + } + targetDir := helper.DecodeURIComponent(helper.Dirname(helper.GetTargetPath(r))) + targetFile, err := apiClient.GetFileByPath(targetDir) + if err != nil { + infra.HandleError(err, w) + return + } + if sourceFile.WorkspaceID != targetFile.WorkspaceID { + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write([]byte("Source and target files are in different workspaces")); err != nil { + infra.HandleError(err, w) + return + } + } else { + sourcePathParts := strings.Split(sourcePath, "/") + targetPathParts := strings.Split(targetPath, "/") + if len(sourcePathParts) == len(targetPathParts) && helper.Dirname(sourcePath) == helper.Dirname(targetPath) { + if _, err := apiClient.PatchFileName(sourceFile.ID, client.FileRenameOptions{ + Name: helper.DecodeURIComponent(path.Base(targetPath)), + }); err != nil { + infra.HandleError(err, w) + return + } + } else { + if err := apiClient.MoveFile(targetFile.ID, client.FileMoveOptions{ + IDs: []string{sourceFile.ID}, + }); err != nil { + infra.HandleError(err, w) + return + } + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/webdav/handler/method_options.go b/webdav/handler/method_options.go new file mode 100644 index 000000000..788722ee0 --- /dev/null +++ b/webdav/handler/method_options.go @@ -0,0 +1,29 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "net/http" +) + +/* +This method should respond with the allowed methods and capabilities of the server. + +Example implementation: + +- Set the response status code to 200. +- Set the Allow header to specify the supported methods, such as OPTIONS, GET, PUT, DELETE, etc. +- Return the response. +*/ +func (h *Handler) methodOptions(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH") + w.WriteHeader(http.StatusOK) +} diff --git a/webdav/handler/method_propfind.go b/webdav/handler/method_propfind.go new file mode 100644 index 000000000..0a3311c65 --- /dev/null +++ b/webdav/handler/method_propfind.go @@ -0,0 +1,141 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "net/http" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method retrieves properties and metadata of a resource. + +Example implementation: + +- Extract the file path from the URL. +- Use fs.stat() to retrieve the file metadata. +- Format the response body in the desired XML format with the properties and metadata. +- Set the response status code to 207 if successful or an appropriate error code if the file is not found or encountered an error. +- Set the Content-Type header to indicate the XML format. +- Return the response. +*/ +func (h *Handler) methodPropfind(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + apiClient := client.NewAPIClient(token) + file, err := apiClient.GetFileByPath(helper.DecodeURIComponent(r.URL.Path)) + if err != nil { + infra.HandleError(err, w) + return + } + if file.Type == client.FileTypeFile { + responseXml := fmt.Sprintf( + ` + + %s + + + + %d + %s + %s + + HTTP/1.1 200 OK + + + `, + helper.EncodeURIComponent(file.Name), + func() int { + if file.Type == client.FileTypeFile && file.Snapshot != nil && file.Snapshot.Original != nil { + return file.Snapshot.Original.Size + } + return 0 + }(), + helper.ToUTCString(&file.CreateTime), + helper.ToUTCString(file.UpdateTime), + ) + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + if _, err := w.Write([]byte(responseXml)); err != nil { + infra.HandleError(err, w) + return + } + } else if file.Type == client.FileTypeFolder { + responseXml := fmt.Sprintf( + ` + + %s + + + + 0 + %s + %s + + HTTP/1.1 200 OK + + `, + helper.EncodeURIComponent(r.URL.Path), + helper.ToUTCString(file.UpdateTime), + helper.ToUTCString(&file.CreateTime), + ) + list, err := apiClient.ListFilesByPath(helper.DecodeURIComponent(r.URL.Path)) + if err != nil { + infra.HandleError(err, w) + return + } + for _, item := range list { + itemXml := fmt.Sprintf( + ` + %s + + + %s + %d + %s + %s + + HTTP/1.1 200 OK + + `, + helper.EncodeURIComponent(r.URL.Path+item.Name), + func() string { + if item.Type == client.FileTypeFolder { + return "" + } + return "" + }(), + func() int { + if item.Type == client.FileTypeFile && item.Snapshot != nil && item.Snapshot.Original != nil { + return item.Snapshot.Original.Size + } + return 0 + }(), + helper.ToUTCString(item.UpdateTime), + helper.ToUTCString(&item.CreateTime), + ) + responseXml += itemXml + } + responseXml += `` + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + if _, err := w.Write([]byte(responseXml)); err != nil { + infra.HandleError(err, w) + return + } + } +} diff --git a/webdav/handler/method_proppatch.go b/webdav/handler/method_proppatch.go new file mode 100644 index 000000000..5baad26ad --- /dev/null +++ b/webdav/handler/method_proppatch.go @@ -0,0 +1,48 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "net/http" +) + +/* +This method updates the properties of a resource. + +Example implementation: + +- Parse the request body to extract the properties to be updated. +- Read the existing data from the file. +- Parse the existing properties. +- Merge the updated properties with the existing ones. +- Format the updated properties and store them back in the file. +- Set the response status code to 204 if successful or an appropriate error code if the file is not found or encountered an error. +- Return the response. + +In this example implementation, the handleProppatch() method first parses the XML +payload containing the properties to be updated. Then, it reads the existing data from the file, +parses the existing properties (assuming an XML format), +merges the updated properties with the existing ones, and formats +the properties back into the desired format (e.g., XML). + +Finally, the updated properties are written back to the file. +You can customize the parseProperties() and formatProperties() +functions to match the specific property format you are using in your WebDAV server. + +Note that this implementation assumes a simplified example and may require further +customization based on your specific property format and requirements. +*/ +func (h *Handler) methodProppatch(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotImplemented) + if _, err := w.Write([]byte(http.StatusText(http.StatusNotImplemented))); err != nil { + return + } +} diff --git a/webdav/handler/method_put.go b/webdav/handler/method_put.go new file mode 100644 index 000000000..0197c6969 --- /dev/null +++ b/webdav/handler/method_put.go @@ -0,0 +1,127 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package handler + +import ( + "fmt" + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "voltaserve/client" + "voltaserve/helper" + "voltaserve/infra" +) + +/* +This method creates or updates a resource with the provided content. + +Example implementation: + +- Extract the file path from the URL. +- Create a write stream to the file. +- Listen for the data event to write the incoming data to the file. +- Listen for the end event to indicate the completion of the write stream. +- Set the response status code to 201 if created or 204 if updated. +- Return the response. +*/ +func (h *Handler) methodPut(w http.ResponseWriter, r *http.Request) { + token, ok := r.Context().Value("token").(*infra.Token) + if !ok { + infra.HandleError(fmt.Errorf("missing token"), w) + return + } + name := helper.DecodeURIComponent(path.Base(r.URL.Path)) + if helper.IsMicrosoftOfficeLockFile(name) || helper.IsOpenOfficeOfficeLockFile(name) { + w.WriteHeader(http.StatusOK) + return + } + apiClient := client.NewAPIClient(token) + directory, err := apiClient.GetFileByPath(helper.DecodeURIComponent(helper.Dirname(r.URL.Path))) + if err != nil { + infra.HandleError(err, w) + return + } + outputPath := filepath.Join(os.TempDir(), uuid.New().String()) + ws, err := os.Create(outputPath) + if err != nil { + infra.HandleError(err, w) + return + } + defer func(ws *os.File) { + err := ws.Close() + if err != nil { + infra.HandleError(err, w) + } + }(ws) + _, err = io.Copy(ws, r.Body) + if err != nil { + infra.HandleError(err, w) + return + } + err = ws.Close() + if err != nil { + infra.HandleError(err, w) + return + } + workspace, err := h.workspaceCache.Get(helper.ExtractWorkspaceIDFromPath(r.URL.Path)) + if err != nil { + infra.HandleError(err, w) + return + } + snapshotID := helper.NewID() + key := snapshotID + "/original" + strings.ToLower(filepath.Ext(name)) + if err = h.s3.PutFile(key, outputPath, infra.DetectMimeFromPath(outputPath), workspace.Bucket, minio.PutObjectOptions{}); err != nil { + infra.HandleError(err, w) + return + } + stat, err := os.Stat(outputPath) + if err != nil { + infra.HandleError(err, w) + return + } + s3Reference := client.S3Reference{ + Bucket: workspace.Bucket, + Key: key, + SnapshotID: snapshotID, + Size: stat.Size(), + ContentType: infra.DetectMimeFromPath(outputPath), + } + existingFile, err := apiClient.GetFileByPath(r.URL.Path) + if err == nil { + if _, err = apiClient.PatchFileFromS3(client.FilePatchFromS3Options{ + ID: existingFile.ID, + Name: name, + S3Reference: s3Reference, + }); err != nil { + infra.HandleError(err, w) + return + } + w.WriteHeader(http.StatusCreated) + return + } else { + if _, err = apiClient.CreateFileFromS3(client.FileCreateFromS3Options{ + Type: client.FileTypeFile, + WorkspaceID: directory.WorkspaceID, + ParentID: directory.ID, + Name: name, + S3Reference: s3Reference, + }); err != nil { + infra.HandleError(err, w) + return + } + } + w.WriteHeader(http.StatusCreated) +} diff --git a/webdav/helper/id.go b/webdav/helper/id.go new file mode 100644 index 000000000..baf2f4580 --- /dev/null +++ b/webdav/helper/id.go @@ -0,0 +1,32 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package helper + +import ( + "time" + + "github.com/google/uuid" + "github.com/speps/go-hashids/v2" +) + +func NewID() string { + hd := hashids.NewData() + hd.Salt = uuid.NewString() + h, err := hashids.NewWithData(hd) + if err != nil { + panic(err) + } + id, err := h.EncodeInt64([]int64{time.Now().UTC().UnixNano()}) + if err != nil { + panic(err) + } + return id +} diff --git a/webdav/eslint.config.mjs b/webdav/helper/offlice-lock-files.go similarity index 60% rename from webdav/eslint.config.mjs rename to webdav/helper/offlice-lock-files.go index a75559711..364fd7202 100644 --- a/webdav/eslint.config.mjs +++ b/webdav/helper/offlice-lock-files.go @@ -8,20 +8,14 @@ // by the GNU Affero General Public License v3.0 only, included in the file // licenses/AGPL.txt. -import globals from 'globals' -import ts from 'typescript-eslint' +package helper -export default [ - ...ts.configs.recommended, - { - files: ['src/**/*.{ts}'], - }, - { - languageOptions: { - globals: globals.node, - }, - }, - { - ignores: ['*.js'], - }, -] +import "strings" + +func IsMicrosoftOfficeLockFile(name string) bool { + return strings.HasPrefix(name, "~$") +} + +func IsOpenOfficeOfficeLockFile(name string) bool { + return strings.HasPrefix(name, ".~lock.") && strings.HasSuffix(name, "#") +} diff --git a/webdav/helper/path.go b/webdav/helper/path.go new file mode 100644 index 000000000..3f4d0dcb6 --- /dev/null +++ b/webdav/helper/path.go @@ -0,0 +1,44 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package helper + +import ( + "net/http" + "net/url" + "path" + "strings" +) + +func GetTargetPath(req *http.Request) string { + destination := req.Header.Get("Destination") + if destination == "" { + return "" + } + /* Check if the destination header is a full URL */ + if strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://") { + parsedURL, err := url.Parse(destination) + if err != nil { + return "" + } + return parsedURL.Path + } + /* Extract the path from the destination header */ + startIndex := strings.Index(destination, req.Host) + len(req.Host) + if startIndex < len(req.Host) { + return "" + } + return destination[startIndex:] +} + +func Dirname(value string) string { + trimmedValue := strings.TrimSuffix(value, "/") + return path.Dir(trimmedValue) +} diff --git a/webdav/src/config/index.ts b/webdav/helper/time.go similarity index 62% rename from webdav/src/config/index.ts rename to webdav/helper/time.go index 268baa1f6..1ecddcb6c 100644 --- a/webdav/src/config/index.ts +++ b/webdav/helper/time.go @@ -8,6 +8,17 @@ // by the GNU Affero General Public License v3.0 only, included in the file // licenses/AGPL.txt. -export const PORT = process.env.PORT -export const IDP_URL = process.env.IDP_URL -export const API_URL = process.env.API_URL +package helper + +import "time" + +func ToUTCString(value *string) string { + if value == nil { + return "" + } + parsedTime, err := time.Parse(time.RFC3339, *value) + if err != nil { + return "" + } + return parsedTime.Format(time.RFC1123) +} diff --git a/webdav/src/infra/env.ts b/webdav/helper/token.go similarity index 69% rename from webdav/src/infra/env.ts rename to webdav/helper/token.go index 6703d8c36..937d7eff9 100644 --- a/webdav/src/infra/env.ts +++ b/webdav/helper/token.go @@ -8,11 +8,13 @@ // by the GNU Affero General Public License v3.0 only, included in the file // licenses/AGPL.txt. -import dotenv from 'dotenv' -import fs from 'fs' +package helper -if (fs.existsSync('.env.local')) { - dotenv.config({ path: '.env.local' }) -} else { - dotenv.config() +import ( + "time" + "voltaserve/infra" +) + +func NewExpiry(token *infra.Token) time.Time { + return time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) } diff --git a/webdav/helper/uri.go b/webdav/helper/uri.go new file mode 100644 index 000000000..3b82d20fc --- /dev/null +++ b/webdav/helper/uri.go @@ -0,0 +1,31 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package helper + +import ( + "net/url" + "strings" +) + +func DecodeURIComponent(value string) string { + res, err := url.PathUnescape(value) + if err != nil { + return "" + } + return res +} + +func EncodeURIComponent(value string) string { + encoded := url.QueryEscape(value) + encoded = strings.ReplaceAll(encoded, "%2F", "/") + encoded = strings.ReplaceAll(encoded, "+", "%20") + return encoded +} diff --git a/webdav/helper/workspace.go b/webdav/helper/workspace.go new file mode 100644 index 000000000..1cbbd0041 --- /dev/null +++ b/webdav/helper/workspace.go @@ -0,0 +1,22 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package helper + +import "strings" + +func ExtractWorkspaceIDFromPath(path string) string { + slashParts := strings.Split(strings.TrimPrefix(path, "/"), "/") + dashParts := strings.Split(slashParts[0], "-") + if len(dashParts) > 1 { + return dashParts[len(dashParts)-1] + } + return "" +} diff --git a/webdav/infra/error.go b/webdav/infra/error.go new file mode 100644 index 000000000..4106b53b8 --- /dev/null +++ b/webdav/infra/error.go @@ -0,0 +1,75 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package infra + +import ( + "errors" + "fmt" + "log" + "net/http" +) + +type IdPErrorResponse struct { + Code string `json:"code"` + Status int `json:"status"` + Message string `json:"message"` + UserMessage string `json:"userMessage"` + MoreInfo string `json:"moreInfo"` +} + +type IdPError struct { + Value IdPErrorResponse +} + +func (e *IdPError) Error() string { + return fmt.Sprintf("IdPError: %v", e.Value) +} + +type APIErrorResponse struct { + Code string `json:"code"` + Status int `json:"status"` + Message string `json:"message"` + UserMessage string `json:"userMessage"` + MoreInfo string `json:"moreInfo"` +} + +type APIError struct { + Value APIErrorResponse +} + +func (e *APIError) Error() string { + return fmt.Sprintf("APIError: %v", e.Value) +} + +func HandleError(err error, w http.ResponseWriter) { + var apiErr *APIError + var idpErr *IdPError + switch { + case errors.As(err, &apiErr): + w.WriteHeader(apiErr.Value.Status) + if _, err := w.Write([]byte(apiErr.Value.UserMessage)); err != nil { + GetLogger().Error(err) + return + } + case errors.As(err, &idpErr): + w.WriteHeader(idpErr.Value.Status) + if _, err := w.Write([]byte(idpErr.Value.UserMessage)); err != nil { + GetLogger().Error(err) + return + } + default: + w.WriteHeader(http.StatusInternalServerError) + if _, err := w.Write([]byte("Internal Server Error")); err != nil { + return + } + } + log.Println(err) +} diff --git a/webdav/infra/logger.go b/webdav/infra/logger.go new file mode 100644 index 000000000..d902e92c2 --- /dev/null +++ b/webdav/infra/logger.go @@ -0,0 +1,32 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package infra + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var logger *zap.SugaredLogger + +func GetLogger() *zap.SugaredLogger { + if logger == nil { + config := zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + config.DisableCaller = true + if l, err := config.Build(); err != nil { + panic(err) + } else { + logger = l.Sugar() + } + } + return logger +} diff --git a/webdav/src/helper/office-lock-files.ts b/webdav/infra/mime.go similarity index 63% rename from webdav/src/helper/office-lock-files.ts rename to webdav/infra/mime.go index c61e0a1fd..e58a2256c 100644 --- a/webdav/src/helper/office-lock-files.ts +++ b/webdav/infra/mime.go @@ -8,10 +8,14 @@ // by the GNU Affero General Public License v3.0 only, included in the file // licenses/AGPL.txt. -export function isMicrosoftOfficeLockFile(name: string) { - return name.startsWith('~$') -} +package infra + +import "github.com/gabriel-vasile/mimetype" -export function isOpenOfficeOfficeLockFile(name: string) { - return name.startsWith('.~lock.') && name.endsWith('#') +func DetectMimeFromPath(path string) string { + mime, err := mimetype.DetectFile(path) + if err != nil { + return "application/octet-stream" + } + return mime.String() } diff --git a/webdav/infra/redis.go b/webdav/infra/redis.go new file mode 100644 index 000000000..e60469d16 --- /dev/null +++ b/webdav/infra/redis.go @@ -0,0 +1,116 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package infra + +import ( + "context" + "github.com/redis/go-redis/v9" + "strings" + "voltaserve/config" +) + +type RedisManager struct { + config config.RedisConfig + client *redis.Client + clusterClient *redis.ClusterClient +} + +func NewRedisManager() *RedisManager { + return &RedisManager{ + config: config.GetConfig().Redis, + } +} + +func (mgr *RedisManager) Set(key string, value interface{}) error { + if err := mgr.Connect(); err != nil { + return err + } + if mgr.clusterClient != nil { + if _, err := mgr.clusterClient.Set(context.Background(), key, value, 0).Result(); err != nil { + return err + } + } else { + if _, err := mgr.client.Set(context.Background(), key, value, 0).Result(); err != nil { + return err + } + } + return nil +} + +func (mgr *RedisManager) Get(key string) (string, error) { + if err := mgr.Connect(); err != nil { + return "", err + } + if mgr.clusterClient != nil { + value, err := mgr.clusterClient.Get(context.Background(), key).Result() + if err != nil { + return "", err + } + return value, nil + } else { + value, err := mgr.client.Get(context.Background(), key).Result() + if err != nil { + return "", err + } + return value, nil + } +} + +func (mgr *RedisManager) Delete(key string) error { + if err := mgr.Connect(); err != nil { + return err + } + if mgr.clusterClient != nil { + if _, err := mgr.clusterClient.Del(context.Background(), key).Result(); err != nil { + return err + } + } else { + if _, err := mgr.client.Del(context.Background(), key).Result(); err != nil { + return err + } + } + return nil +} + +func (mgr *RedisManager) Close() error { + if mgr.client != nil { + if err := mgr.client.Close(); err != nil { + return err + } + } + return nil +} + +func (mgr *RedisManager) Connect() error { + if mgr.client != nil || mgr.clusterClient != nil { + return nil + } + addresses := strings.Split(mgr.config.Address, ";") + if len(addresses) > 1 { + mgr.clusterClient = redis.NewClusterClient(&redis.ClusterOptions{ + Addrs: addresses, + Password: mgr.config.Password, + }) + if err := mgr.clusterClient.Ping(context.Background()).Err(); err != nil { + return err + } + } else { + mgr.client = redis.NewClient(&redis.Options{ + Addr: mgr.config.Address, + Password: mgr.config.Password, + DB: mgr.config.DB, + }) + if err := mgr.client.Ping(context.Background()).Err(); err != nil { + return err + } + } + return nil +} diff --git a/webdav/infra/s3.go b/webdav/infra/s3.go new file mode 100644 index 000000000..e22f3792f --- /dev/null +++ b/webdav/infra/s3.go @@ -0,0 +1,195 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package infra + +import ( + "bytes" + "context" + "errors" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "io" + "strings" + "voltaserve/config" +) + +type S3Manager struct { + config config.S3Config + client *minio.Client +} + +func NewS3Manager() *S3Manager { + mgr := new(S3Manager) + mgr.config = config.GetConfig().S3 + return mgr +} + +func (mgr *S3Manager) StatObject(objectName string, bucketName string, opts minio.StatObjectOptions) (minio.ObjectInfo, error) { + if mgr.client == nil { + if err := mgr.Connect(); err != nil { + return minio.ObjectInfo{}, err + } + } + return mgr.client.StatObject(context.Background(), bucketName, objectName, opts) +} + +func (mgr *S3Manager) GetFile(objectName string, filePath string, bucketName string, opts minio.GetObjectOptions) error { + if mgr.client == nil { + if err := mgr.Connect(); err != nil { + return err + } + } + if err := mgr.client.FGetObject(context.Background(), bucketName, objectName, filePath, opts); err != nil { + return err + } + return nil +} + +func (mgr *S3Manager) PutFile(objectName string, filePath string, contentType string, bucketName string, opts minio.PutObjectOptions) error { + if err := mgr.Connect(); err != nil { + return err + } + if contentType == "" { + contentType = "application/octet-stream" + } + opts.ContentType = contentType + if _, err := mgr.client.FPutObject(context.Background(), bucketName, objectName, filePath, opts); err != nil { + return err + } + return nil +} + +func (mgr *S3Manager) PutText(objectName string, text string, contentType string, bucketName string, opts minio.PutObjectOptions) error { + if contentType != "" && contentType != "text/plain" && contentType != "application/json" { + return errors.New("invalid content type '" + contentType + "'") + } + if contentType == "" { + contentType = "text/plain" + } + if err := mgr.Connect(); err != nil { + return err + } + opts.ContentType = contentType + if _, err := mgr.client.PutObject(context.Background(), bucketName, objectName, strings.NewReader(text), int64(len(text)), opts); err != nil { + return err + } + return nil +} + +func (mgr *S3Manager) GetObject(objectName string, bucketName string, opts minio.GetObjectOptions) (*bytes.Buffer, *int64, error) { + if err := mgr.Connect(); err != nil { + return nil, nil, err + } + reader, err := mgr.client.GetObject(context.Background(), bucketName, objectName, opts) + if err != nil { + return nil, nil, err + } + var buf bytes.Buffer + written, err := io.Copy(io.Writer(&buf), reader) + if err != nil { + return nil, nil, nil + } + return &buf, &written, nil +} + +func (mgr *S3Manager) GetObjectWithBuffer(objectName string, bucketName string, buf *bytes.Buffer, opts minio.GetObjectOptions) (*int64, error) { + if err := mgr.Connect(); err != nil { + return nil, err + } + reader, err := mgr.client.GetObject(context.Background(), bucketName, objectName, opts) + if err != nil { + return nil, err + } + written, err := io.Copy(io.Writer(buf), reader) + if err != nil { + return nil, nil + } + return &written, nil +} + +func (mgr *S3Manager) GetText(objectName string, bucketName string, opts minio.GetObjectOptions) (string, error) { + if err := mgr.Connect(); err != nil { + return "", err + } + reader, err := mgr.client.GetObject(context.Background(), bucketName, objectName, opts) + if err != nil { + return "", err + } + buf := new(strings.Builder) + _, err = io.Copy(buf, reader) + if err != nil { + return "", nil + } + return buf.String(), nil +} + +func (mgr *S3Manager) RemoveObject(objectName string, bucketName string, opts minio.RemoveObjectOptions) error { + if err := mgr.Connect(); err != nil { + return err + } + err := mgr.client.RemoveObject(context.Background(), bucketName, objectName, opts) + if err != nil { + return err + } + return nil +} + +func (mgr *S3Manager) CreateBucket(bucketName string) error { + if err := mgr.Connect(); err != nil { + return err + } + found, err := mgr.client.BucketExists(context.Background(), bucketName) + if err != nil { + return err + } + if !found { + if err = mgr.client.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{ + Region: mgr.config.Region, + }); err != nil { + return err + } + } + return nil +} + +func (mgr *S3Manager) RemoveBucket(bucketName string) error { + if err := mgr.Connect(); err != nil { + return err + } + found, err := mgr.client.BucketExists(context.Background(), bucketName) + if err != nil { + return err + } + if !found { + return nil + } + objectCh := mgr.client.ListObjects(context.Background(), bucketName, minio.ListObjectsOptions{ + Prefix: "", + Recursive: true, + }) + mgr.client.RemoveObjects(context.Background(), bucketName, objectCh, minio.RemoveObjectsOptions{}) + if err = mgr.client.RemoveBucket(context.Background(), bucketName); err != nil { + return err + } + return nil +} + +func (mgr *S3Manager) Connect() error { + client, err := minio.New(mgr.config.URL, &minio.Options{ + Creds: credentials.NewStaticV4(mgr.config.AccessKey, mgr.config.SecretKey, ""), + Secure: mgr.config.Secure, + }) + if err != nil { + return err + } + mgr.client = client + return nil +} diff --git a/webdav/src/helper/token.ts b/webdav/infra/token.go similarity index 65% rename from webdav/src/helper/token.ts rename to webdav/infra/token.go index 71a1ebad0..7985a4294 100644 --- a/webdav/src/helper/token.ts +++ b/webdav/infra/token.go @@ -8,10 +8,11 @@ // by the GNU Affero General Public License v3.0 only, included in the file // licenses/AGPL.txt. -import { Token } from '@/client/idp' +package infra -export function newExpiry(token: Token): Date { - const now = new Date() - now.setSeconds(now.getSeconds() + token.expires_in) - return now +type Token struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` } diff --git a/webdav/main.go b/webdav/main.go new file mode 100644 index 000000000..4e3d2cbf8 --- /dev/null +++ b/webdav/main.go @@ -0,0 +1,123 @@ +// Copyright 2023 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// licenses/AGPL.txt. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + "voltaserve/client" + "voltaserve/handler" + "voltaserve/helper" + "voltaserve/infra" + + "github.com/joho/godotenv" + "voltaserve/config" +) + +var ( + tokens = make(map[string]*infra.Token) + expiries = make(map[string]time.Time) + mu sync.Mutex +) + +func startTokenRefresh(idpClient *client.IdPClient) { + ticker := time.NewTicker(5 * time.Second) + go func() { + for { + <-ticker.C + mu.Lock() + for username, token := range tokens { + expiry := expiries[username] + if time.Now().After(expiry.Add(-1 * time.Minute)) { + newToken, err := idpClient.Exchange(client.TokenExchangeOptions{ + GrantType: client.GrantTypeRefreshToken, + RefreshToken: token.RefreshToken, + }) + if err == nil { + tokens[username] = newToken + expiries[username] = helper.NewExpiry(newToken) + } + } + } + mu.Unlock() + } + }() +} + +func basicAuthMiddleware(next http.Handler, idpClient *client.IdPClient) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="WebDAV Server"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + mu.Lock() + defer mu.Unlock() + token, exists := tokens[username] + if !exists { + var err error + token, err = idpClient.Exchange(client.TokenExchangeOptions{ + GrantType: client.GrantTypePassword, + Username: username, + Password: password, + }) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + tokens[username] = token + expiries[username] = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "token", token))) + }) +} + +// @title Voltaserve WebDAV +// @version 2.0.0 +// @BasePath /v2 +func main() { + if _, err := os.Stat(".env.local"); err == nil { + if err := godotenv.Load(".env.local"); err != nil { + panic(err) + } + } else { + if err := godotenv.Load(); err != nil { + panic(err) + } + } + + cfg := config.GetConfig() + + idpClient := client.NewIdPClient() + + h := handler.NewHandler() + mux := http.NewServeMux() + mux.HandleFunc("/v2/health", h.Health) + mux.HandleFunc("/", h.Dispatch) + + startTokenRefresh(idpClient) + + log.Printf("Listening on port %d", cfg.Port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v2/health") { + mux.ServeHTTP(w, r) + } else { + basicAuthMiddleware(mux, idpClient).ServeHTTP(w, r) + } + }))) +} diff --git a/webdav/package.json b/webdav/package.json deleted file mode 100644 index f430d504c..000000000 --- a/webdav/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "voltaserve-webdav", - "version": "2.0.0", - "license": "BUSL-1.1", - "private": true, - "scripts": { - "start": "bun src/server.ts", - "dev": "bun dev src/server.ts", - "tsc": "tsc --noEmit", - "format": "prettier --write .", - "lint": "eslint" - }, - "dependencies": { - "dotenv": "16.4.5", - "passport": "0.7.0", - "passport-http": "0.3.0", - "uuid": "9.0.1", - "xml2js": "0.6.2" - }, - "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "4.3.0", - "@types/js-yaml": "4.0.9", - "@types/node": "20.12.12", - "@types/passport": "1.0.16", - "@types/passport-http": "0.3.11", - "@types/uuid": "9.0.8", - "@types/xml2js": "0.4.14", - "eslint": "9.3.0", - "globals": "15.3.0", - "nodemon": "3.1.0", - "prettier": "3.2.5", - "ts-node": "10.9.2", - "tsconfig-paths": "4.2.0", - "typescript": "5.4.5", - "typescript-eslint": "7.9.0" - } -} diff --git a/webdav/src/client/api.ts b/webdav/src/client/api.ts deleted file mode 100644 index de8e5be02..000000000 --- a/webdav/src/client/api.ts +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { createWriteStream, unlink } from 'fs' -import { get } from 'http' -import { API_URL } from '@/config' -import { Token } from './idp' - -export type APIErrorResponse = { - code: string - status: number - message: string - userMessage: string - moreInfo: string -} - -export class APIError extends Error { - constructor(readonly error: APIErrorResponse) { - super(JSON.stringify(error, null, 2)) - } -} - -export enum FileType { - File = 'file', - Folder = 'folder', -} - -export type File = { - id: string - workspaceId: string - name: string - type: FileType - parentId: string - permission: PermissionType - isShared: boolean - snapshot?: Snapshot - createTime: string - updateTime?: string -} - -export type PermissionType = 'viewer' | 'editor' | 'owner' - -export type Snapshot = { - version: number - original: Download - preview?: Download - ocr?: Download - text?: Download - thumbnail?: Thumbnail -} - -export type Download = { - extension: string - size: number - image: ImageProps | undefined -} - -export type ImageProps = { - width: number - height: number -} - -export type Thumbnail = { - base64: string - width: number - height: number -} - -export type FileCopyOptions = { - ids: string[] -} - -export type FileRenameOptions = { - name: string -} - -export type FileCreateFolderOptions = { - workspaceId: string - name: string - parentId: string -} - -export type FileCreateOptions = { - type: FileType - workspaceId: string - parentId?: string - blob?: Blob - name?: string -} - -export type FilePatchOptions = { - id: string - blob: Blob - name: string -} - -export type FileMoveOptions = { - ids: string[] -} - -export class HealthAPI { - async get(): Promise { - const response = await fetch(`${API_URL}/v2/health`, { method: 'GET' }) - return response.text() - } -} - -export class FileAPI { - constructor(private token: Token) {} - - private async jsonResponseOrThrow(response: Response): Promise { - if (response.headers.get('content-type')?.includes('application/json')) { - const json = await response.json() - if (response.status > 299) { - throw new APIError(json) - } - return json - } else { - if (response.status > 299) { - throw new Error(response.statusText) - } - } - } - - async create({ - type, - workspaceId, - parentId, - name, - blob, - }: FileCreateOptions): Promise { - const params = new URLSearchParams({ type, workspace_id: workspaceId }) - if (parentId) { - params.append('parent_id', parentId) - } - if (name) { - params.append('name', name) - } - if (type === FileType.File && blob) { - return this.upload(`${API_URL}/v2/files?${params}`, 'POST', blob, name) - } else if (type === FileType.Folder) { - const response = await fetch(`${API_URL}/v2/files?${params}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - }, - }) - return this.jsonResponseOrThrow(response) - } - } - - async patch({ id, blob, name }: FilePatchOptions): Promise { - return this.upload(`${API_URL}/v2/files/${id}`, 'PATCH', blob, name) - } - - private async upload( - url: string, - method: string, - blob: Blob, - name: string, - ) { - const formData = new FormData() - formData.set('file', blob, name) - const response = await fetch(url, { - method, - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - }, - body: formData, - }) - return this.jsonResponseOrThrow(response) - } - - async getByPath(path: string): Promise { - const response = await fetch( - `${API_URL}/v2/files?path=${encodeURIComponent(path)}`, - { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - 'Content-Type': 'application/json', - }, - }, - ) - return this.jsonResponseOrThrow(response) - } - - async listByPath(path: string): Promise { - const response = await fetch( - `${API_URL}/v2/files/list?path=${encodeURIComponent(path)}`, - { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - 'Content-Type': 'application/json', - }, - }, - ) - return this.jsonResponseOrThrow(response) - } - - async copy(id: string, options: FileCopyOptions): Promise { - const response = await fetch(`${API_URL}/v2/files/${id}/copy`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ids: options.ids, - }), - }) - return this.jsonResponseOrThrow(response) - } - - async move(id: string, options: FileMoveOptions): Promise { - const response = await fetch(`${API_URL}/v2/files/${id}/move`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ids: options.ids, - }), - }) - return this.jsonResponseOrThrow(response) - } - - async patchName(id: string, options: FileRenameOptions): Promise { - const response = await fetch(`${API_URL}/v2/files/${id}/name`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: options.name, - }), - }) - return this.jsonResponseOrThrow(response) - } - - async delete(id: string): Promise { - const response = await fetch(`${API_URL}/v2/files`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${this.token.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ ids: [id] }), - }) - return this.jsonResponseOrThrow(response) - } - - downloadOriginal(file: File, outputPath: string): Promise { - return new Promise((resolve, reject) => { - const ws = createWriteStream(outputPath) - const request = get( - `${API_URL}/v2/files/${file.id}/original${file.snapshot.original.extension}?access_token=${this.token.access_token}`, - (response) => { - response.pipe(ws) - ws.on('finish', () => { - ws.close() - resolve() - }) - }, - ) - request.on('error', (error) => { - unlink(outputPath, () => { - reject(error) - }) - }) - }) - } -} - -export const VIEWER_PERMISSION = 'viewer' -export const EDITOR_PERMISSION = 'editor' -export const OWNER_PERMISSION = 'owner' - -export function geViewerPermission(permission: string): boolean { - return ( - getPermissionWeight(permission) >= getPermissionWeight(VIEWER_PERMISSION) - ) -} - -export function geEditorPermission(permission: string) { - return ( - getPermissionWeight(permission) >= getPermissionWeight(EDITOR_PERMISSION) - ) -} - -export function geOwnerPermission(permission: string) { - return ( - getPermissionWeight(permission) >= getPermissionWeight(OWNER_PERMISSION) - ) -} - -export function ltViewerPermission(permission: string): boolean { - return ( - getPermissionWeight(permission) < getPermissionWeight(VIEWER_PERMISSION) - ) -} - -export function ltEditorPermission(permission: string) { - return ( - getPermissionWeight(permission) < getPermissionWeight(EDITOR_PERMISSION) - ) -} - -export function ltOwnerPermission(permission: string) { - return getPermissionWeight(permission) < getPermissionWeight(OWNER_PERMISSION) -} - -export function getPermissionWeight(permission: string) { - switch (permission) { - case VIEWER_PERMISSION: - return 1 - case EDITOR_PERMISSION: - return 2 - case OWNER_PERMISSION: - return 3 - default: - return 0 - } -} diff --git a/webdav/src/client/idp.ts b/webdav/src/client/idp.ts deleted file mode 100644 index e470d7d51..000000000 --- a/webdav/src/client/idp.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IDP_URL } from '@/config' - -export type IdPErrorResponse = { - code: string - status: number - message: string - userMessage: string - moreInfo: string -} - -export class IdPError extends Error { - constructor(readonly error: IdPErrorResponse) { - super(JSON.stringify(error, null, 2)) - } -} - -export type Token = { - access_token: string - expires_in: number - token_type: string - refresh_token: string -} - -export type TokenGrantType = 'password' | 'refresh_token' - -export type TokenExchangeOptions = { - grant_type: TokenGrantType - username?: string - password?: string - refresh_token?: string - locale?: string -} - -export class HealthAPI { - async get(): Promise { - const response = await fetch(`${IDP_URL}/v2/health`, { method: 'GET' }) - return response.text() - } -} - -export class TokenAPI { - async exchange(options: TokenExchangeOptions): Promise { - const formBody = [] - formBody.push(`grant_type=${options.grant_type}`) - formBody.push(`username=${encodeURIComponent(options.username)}`) - formBody.push(`password=${encodeURIComponent(options.password)}`) - if (options.refresh_token) { - formBody.push(`refresh_token=${options.refresh_token}`) - } - const response = await fetch(`${IDP_URL}/v2/token`, { - method: 'POST', - body: formBody.join('&'), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }) - return this.jsonResponseOrThrow(response) - } - - private async jsonResponseOrThrow(response: Response): Promise { - if (response.headers.get('content-type')?.includes('application/json')) { - const json = await response.json() - if (response.status > 299) { - throw new IdPError(json) - } - return json - } else { - if (response.status > 299) { - throw new Error(response.statusText) - } - } - } -} diff --git a/webdav/src/handler/handle-copy.ts b/webdav/src/handler/handle-copy.ts deleted file mode 100644 index f4833d248..000000000 --- a/webdav/src/handler/handle-copy.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' -import path from 'path' -import { FileAPI } from '@/client/api' -import { Token } from '@/client/idp' -import { getTargetPath } from '@/helper/path' -import { handleError } from '@/infra/error' - -/* - This method copies a resource from a source URL to a destination URL. - - Example implementation: - - - Extract the source and destination paths from the headers or request body. - - Use fs.copyFile() to copy the file from the source to the destination. - - Set the response status code to 204 if successful or an appropriate error code if the source file is not found or encountered an error. - - Return the response. - */ -async function handleCopy( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - try { - const api = new FileAPI(token) - const sourceFile = await api.getByPath(decodeURIComponent(req.url)) - const targetFile = await api.getByPath( - decodeURIComponent(path.dirname(getTargetPath(req))), - ) - if (sourceFile.workspaceId !== targetFile.workspaceId) { - res.statusCode = 400 - res.end() - } else { - const clones = await api.copy(targetFile.id, { ids: [sourceFile.id] }) - await api.patchName(clones[0].id, { - name: decodeURIComponent(path.basename(getTargetPath(req))), - }) - res.statusCode = 204 - res.end() - } - } catch (err) { - handleError(err, res) - } -} - -export default handleCopy diff --git a/webdav/src/handler/handle-delete.ts b/webdav/src/handler/handle-delete.ts deleted file mode 100644 index f1077005b..000000000 --- a/webdav/src/handler/handle-delete.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' -import { FileAPI } from '@/client/api' -import { Token } from '@/client/idp' -import { handleError } from '@/infra/error' - -/* - This method deletes a resource identified by the URL. - - Example implementation: - - - Extract the file path from the URL. - - Use fs.unlink() to delete the file. - - Set the response status code to 204 if successful or an appropriate error code if the file is not found. - - Return the response. - */ -async function handleDelete( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - try { - const api = new FileAPI(token) - const file = await api.getByPath(decodeURIComponent(req.url)) - await api.delete(file.id) - res.statusCode = 204 - res.end() - } catch (err) { - handleError(err, res) - } -} - -export default handleDelete diff --git a/webdav/src/handler/handle-get.ts b/webdav/src/handler/handle-get.ts deleted file mode 100644 index 022b6a2ee..000000000 --- a/webdav/src/handler/handle-get.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import fs, { createReadStream, rmSync } from 'fs' -import { IncomingMessage, ServerResponse } from 'http' -import os from 'os' -import path from 'path' -import { v4 as uuidv4 } from 'uuid' -import { FileAPI } from '@/client/api' -import { Token } from '@/client/idp' -import { handleError } from '@/infra/error' - -/* - This method retrieves the content of a resource identified by the URL. - - Example implementation: - - - Extract the file path from the URL. - - Create a read stream from the file and pipe it to the response stream. - - Set the response status code to 200 if successful or an appropriate error code if the file is not found. - - Return the response. - */ -async function handleGet( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - try { - const api = new FileAPI(token) - const file = await api.getByPath(decodeURIComponent(req.url)) - - /* TODO: This should be optimized for the case when there is a range header, - only a partial file should be fetched, here we are fetching the whole file - which is not ideal. */ - const outputPath = path.join(os.tmpdir(), uuidv4()) - await api.downloadOriginal(file, outputPath) - - const stat = fs.statSync(outputPath) - const rangeHeader = req.headers.range - if (rangeHeader) { - const [start, end] = rangeHeader.replace(/bytes=/, '').split('-') - const rangeStart = parseInt(start, 10) || 0 - const rangeEnd = parseInt(end, 10) || stat.size - 1 - const chunkSize = rangeEnd - rangeStart + 1 - res.writeHead(206, { - 'Content-Range': `bytes ${rangeStart}-${rangeEnd}/${stat.size}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunkSize.toString(), - 'Content-Type': 'application/octet-stream', - }) - createReadStream(outputPath, { - start: rangeStart, - end: rangeEnd, - }) - .pipe(res) - .on('finish', () => rmSync(outputPath)) - } else { - res.writeHead(200, { - 'Content-Length': stat.size.toString(), - 'Content-Type': 'application/octet-stream', - }) - createReadStream(outputPath) - .pipe(res) - .on('finish', () => rmSync(outputPath)) - } - } catch (err) { - handleError(err, res) - } -} - -export default handleGet diff --git a/webdav/src/handler/handle-head.ts b/webdav/src/handler/handle-head.ts deleted file mode 100644 index 9882fc6d9..000000000 --- a/webdav/src/handler/handle-head.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' -import { FileAPI, FileType } from '@/client/api' -import { Token } from '@/client/idp' -import { handleError } from '@/infra/error' - -/* - This method is similar to GET but only retrieves the metadata of a resource, without returning the actual content. - - Example implementation: - - - Extract the file path from the URL. - - Retrieve the file metadata using fs.stat(). - - Set the response status code to 200 if successful or an appropriate error code if the file is not found. - - Set the Content-Length header with the file size. - - Return the response. -*/ -async function handleHead( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - try { - const file = await new FileAPI(token).getByPath(decodeURIComponent(req.url)) - if (file.type === FileType.File) { - res.statusCode = 200 - res.setHeader('Content-Length', file.snapshot.original.size) - res.end() - } else { - res.statusCode = 200 - res.end() - } - } catch (err) { - handleError(err, res) - } -} - -export default handleHead diff --git a/webdav/src/handler/handle-mkcol.ts b/webdav/src/handler/handle-mkcol.ts deleted file mode 100644 index 7bc02da18..000000000 --- a/webdav/src/handler/handle-mkcol.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' -import path from 'path' -import { File, FileAPI, FileType } from '@/client/api' -import { Token } from '@/client/idp' -import { handleError } from '@/infra/error' - -/* - This method creates a new collection (directory) at the specified URL. - - Example implementation: - - - Extract the directory path from the URL. - - Use fs.mkdir() to create the directory. - - Set the response status code to 201 if created or an appropriate error code if the directory already exists or encountered an error. - - Return the response. - */ -async function handleMkcol( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - let directory: File - try { - const api = new FileAPI(token) - directory = await api.getByPath(decodeURIComponent(path.dirname(req.url))) - await api.create({ - type: FileType.Folder, - workspaceId: directory.workspaceId, - parentId: directory.id, - name: decodeURIComponent(path.basename(req.url)), - }) - res.statusCode = 201 - res.end() - } catch (err) { - handleError(err, res) - } -} - -export default handleMkcol diff --git a/webdav/src/handler/handle-move.ts b/webdav/src/handler/handle-move.ts deleted file mode 100644 index 3ba860b06..000000000 --- a/webdav/src/handler/handle-move.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' -import path from 'path' -import { FileAPI } from '@/client/api' -import { Token } from '@/client/idp' -import { getTargetPath } from '@/helper/path' -import { handleError } from '@/infra/error' - -/* - This method moves or renames a resource from a source URL to a destination URL. - - Example implementation: - - - Extract the source and destination paths from the headers or request body. - - Use fs.rename() to move or rename the file from the source to the destination. - - Set the response status code to 204 if successful or an appropriate error code if the source file is not found or encountered an error. - - Return the response. - */ -async function handleMove( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - try { - const sourcePath = decodeURIComponent(req.url) - const targetPath = decodeURIComponent(getTargetPath(req)) - const api = new FileAPI(token) - const sourceFile = await api.getByPath(decodeURIComponent(req.url)) - const targetFile = await api.getByPath( - decodeURIComponent(path.dirname(getTargetPath(req))), - ) - if (sourceFile.workspaceId !== targetFile.workspaceId) { - res.statusCode = 400 - res.end() - } else { - if ( - sourcePath.split('/').length === targetPath.split('/').length && - path.dirname(sourcePath) === path.dirname(targetPath) - ) { - await api.patchName(sourceFile.id, { - name: decodeURIComponent(path.basename(targetPath)), - }) - } else { - await api.move(targetFile.id, { ids: [sourceFile.id] }) - } - res.statusCode = 204 - res.end() - } - } catch (err) { - handleError(err, res) - } -} - -export default handleMove diff --git a/webdav/src/handler/handle-options.ts b/webdav/src/handler/handle-options.ts deleted file mode 100644 index 35b66c004..000000000 --- a/webdav/src/handler/handle-options.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' - -/* - This method should respond with the allowed methods and capabilities of the server. - - Example implementation: - - - Set the response status code to 200. - - Set the Allow header to specify the supported methods, such as OPTIONS, GET, PUT, DELETE, etc. - - Return the response. - */ -async function handleOptions(_: IncomingMessage, res: ServerResponse) { - res.statusCode = 200 - res.setHeader( - 'Allow', - 'OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH', - ) - res.end() -} - -export default handleOptions diff --git a/webdav/src/handler/handle-propfind.ts b/webdav/src/handler/handle-propfind.ts deleted file mode 100644 index 2158ccfa6..000000000 --- a/webdav/src/handler/handle-propfind.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' -import { FileAPI, FileType } from '@/client/api' -import { Token } from '@/client/idp' -import { handleError } from '@/infra/error' - -/* - This method retrieves properties and metadata of a resource. - - Example implementation: - - - Extract the file path from the URL. - - Use fs.stat() to retrieve the file metadata. - - Format the response body in the desired XML format with the properties and metadata. - - Set the response status code to 207 if successful or an appropriate error code if the file is not found or encountered an error. - - Set the Content-Type header to indicate the XML format. - - Return the response. - */ -async function handlePropfind( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - try { - const api = new FileAPI(token) - const file = await api.getByPath(decodeURIComponent(req.url)) - if (file.type === FileType.File) { - const responseXml = ` - - - ${encodeURIComponent(file.name)} - - - - ${ - file.snapshot.original - ? `${file.snapshot.original.size}` - : '' - } - ${new Date( - file.createTime, - ).toUTCString()} - ${new Date( - file.updateTime, - ).toUTCString()} - - HTTP/1.1 200 OK - - - ` - res.statusCode = 207 - res.setHeader('Content-Type', 'application/xml; charset=utf-8') - res.end(responseXml) - } else if (file.type === FileType.Folder) { - const list = await api.listByPath(decodeURIComponent(req.url)) - const responseXml = ` - - - ${req.url} - - - - 0 - ${new Date( - file.updateTime, - ).toUTCString()} - ${new Date( - file.createTime, - ).toUTCString()} - - HTTP/1.1 200 OK - - - ${list - .map((item) => { - return ` - - ${req.url}${encodeURIComponent(item.name)} - - - ${ - item.type === FileType.Folder ? '' : '' - } - ${ - item.type === FileType.File && item.snapshot.original - ? `${item.snapshot.original.size}` - : '' - } - ${new Date( - item.updateTime, - ).toUTCString()} - ${new Date( - item.createTime, - ).toUTCString()} - - HTTP/1.1 200 OK - - - ` - }) - .join('')} - ` - res.statusCode = 207 - res.setHeader('Content-Type', 'application/xml; charset=utf-8') - res.end(responseXml) - } - } catch (err) { - handleError(err, res) - } -} - -export default handlePropfind diff --git a/webdav/src/handler/handle-proppatch.ts b/webdav/src/handler/handle-proppatch.ts deleted file mode 100644 index dc29101cf..000000000 --- a/webdav/src/handler/handle-proppatch.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage, ServerResponse } from 'http' - -/* - This method updates the properties of a resource. - - Example implementation: - - - Parse the request body to extract the properties to be updated. - - Read the existing data from the file. - - Parse the existing properties. - - Merge the updated properties with the existing ones. - - Format the updated properties and store them back in the file. - - Set the response status code to 204 if successful or an appropriate error code if the file is not found or encountered an error. - - Return the response. - - In this example implementation, the handleProppatch() method first parses the XML - payload containing the properties to be updated. Then, it reads the existing data from the file, - parses the existing properties (assuming an XML format), - merges the updated properties with the existing ones, and formats - the properties back into the desired format (e.g., XML). - - Finally, the updated properties are written back to the file. - You can customize the parseProperties() and formatProperties() - functions to match the specific property format you are using in your WebDAV server. - - Note that this implementation assumes a simplified example and may require further - customization based on your specific property format and requirements. - */ -async function handleProppatch(_: IncomingMessage, res: ServerResponse) { - res.statusCode = 501 - res.end() -} - -export default handleProppatch diff --git a/webdav/src/handler/handle-put.ts b/webdav/src/handler/handle-put.ts deleted file mode 100644 index c56597601..000000000 --- a/webdav/src/handler/handle-put.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import fs from 'fs' -import { readFile } from 'fs/promises' -import { IncomingMessage, ServerResponse } from 'http' -import fsPromises from 'node:fs/promises' -import os from 'os' -import path from 'path' -import { v4 as uuidv4 } from 'uuid' -import { FileAPI, FileType } from '@/client/api' -import { Token } from '@/client/idp' -import { - isMicrosoftOfficeLockFile, - isOpenOfficeOfficeLockFile, -} from '@/helper/office-lock-files' -import { handleError } from '@/infra/error' - -/* - This method creates or updates a resource with the provided content. - - Example implementation: - - - Extract the file path from the URL. - - Create a write stream to the file. - - Listen for the data event to write the incoming data to the file. - - Listen for the end event to indicate the completion of the write stream. - - Set the response status code to 201 if created or 204 if updated. - - Return the response. - */ -async function handlePut( - req: IncomingMessage, - res: ServerResponse, - token: Token, -) { - const name = decodeURIComponent(path.basename(req.url)) - if (isMicrosoftOfficeLockFile(name) || isOpenOfficeOfficeLockFile(name)) { - res.statusCode = 200 - res.end() - } else { - const api = new FileAPI(token) - try { - const directory = await api.getByPath( - decodeURIComponent(path.dirname(req.url)), - ) - const outputPath = path.join(os.tmpdir(), uuidv4()) - const ws = fs.createWriteStream(outputPath) - req.pipe(ws) - ws.on('error', (err) => { - console.error(err) - res.statusCode = 500 - res.end() - }) - ws.on('finish', async () => { - try { - const blob = new Blob([await readFile(outputPath)]) - const name = decodeURIComponent(path.basename(req.url)) - try { - const existingFile = await api.getByPath( - decodeURIComponent(req.url), - ) - await api.patch({ - id: existingFile.id, - blob, - name, - }) - res.statusCode = 201 - res.end() - } catch { - await api.create({ - type: FileType.File, - workspaceId: directory.workspaceId, - parentId: directory.id, - blob, - name, - }) - res.statusCode = 201 - res.end() - } - } catch (err) { - handleError(err, res) - } finally { - await fsPromises.rm(outputPath) - } - }) - } catch (err) { - handleError(err, res) - } - } -} - -export default handlePut diff --git a/webdav/src/helper/path.ts b/webdav/src/helper/path.ts deleted file mode 100644 index 951c08afa..000000000 --- a/webdav/src/helper/path.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { IncomingMessage } from 'http' - -export function getTargetPath(req: IncomingMessage) { - const destination = req.headers.destination as string - if (!destination) { - return null - } - /* Check if the destination header is a full URL */ - if (destination.startsWith('http://') || destination.startsWith('https://')) { - return new URL(destination).pathname - } else { - /* Extract the path from the destination header */ - const startIndex = - destination.indexOf(req.headers.host) + req.headers.host.length - return destination.substring(startIndex) - } -} diff --git a/webdav/src/infra/error.ts b/webdav/src/infra/error.ts deleted file mode 100644 index 6ba3817a7..000000000 --- a/webdav/src/infra/error.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import { ServerResponse } from 'http' -import { APIError } from '@/client/api' -import { IdPError } from '@/client/idp' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function handleError(err: any, res: ServerResponse) { - if (err instanceof APIError) { - res.statusCode = err.error.status - res.statusMessage = err.error.userMessage - res.end() - } else if (err instanceof IdPError) { - res.statusCode = err.error.status - res.statusMessage = err.error.userMessage - res.end() - } else { - res.statusCode = 500 - res.end() - } - console.error(err) -} diff --git a/webdav/src/server.ts b/webdav/src/server.ts deleted file mode 100644 index 3027291c8..000000000 --- a/webdav/src/server.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2023 Anass Bouassaba. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the GNU Affero General Public License v3.0 only, included in the file -// licenses/AGPL.txt. - -import '@/infra/env' -import { createServer, IncomingMessage, ServerResponse } from 'http' -import passport from 'passport' -import { BasicStrategy } from 'passport-http' -import { HealthAPI as APIHealthAPI } from '@/client/api' -import { TokenAPI, Token } from '@/client/idp' -import { HealthAPI as IDPHealthAPI } from '@/client/idp' -import { PORT } from '@/config' -import handleCopy from '@/handler/handle-copy' -import handleDelete from '@/handler/handle-delete' -import handleGet from '@/handler/handle-get' -import handleHead from '@/handler/handle-head' -import handleMkcol from '@/handler/handle-mkcol' -import handleMove from '@/handler/handle-move' -import handleOptions from '@/handler/handle-options' -import handlePropfind from '@/handler/handle-propfind' -import handleProppatch from '@/handler/handle-proppatch' -import handlePut from '@/handler/handle-put' -import { newExpiry } from '@/helper/token' - -const tokens = new Map() -const expiries = new Map() -const api = new TokenAPI() - -/* Refresh tokens */ -setInterval(async () => { - for (const [username, token] of tokens) { - const expiry = expiries.get(username) - const earlyExpiry = new Date(expiry) - earlyExpiry.setMinutes(earlyExpiry.getMinutes() - 1) - if (new Date() >= earlyExpiry) { - const newToken = await api.exchange({ - grant_type: 'refresh_token', - refresh_token: token.refresh_token, - }) - tokens.set(username, newToken) - expiries.set(username, newExpiry(newToken)) - } - } -}, 5000) - -passport.use( - new BasicStrategy(async (username, password, done) => { - try { - let token = tokens.get(username) - if (!token) { - token = await new TokenAPI().exchange({ - username, - password, - grant_type: 'password', - }) - tokens.set(username, token) - expiries.set(username, newExpiry(token)) - } - return done(null, token) - } catch (err) { - return done(err, false) - } - }), -) - -function handleRequest(req: IncomingMessage, res: ServerResponse) { - passport.authenticate( - 'basic', - { session: false }, - async (err: Error, token: Token) => { - if (err || !token) { - res.statusCode = 401 - res.setHeader('WWW-Authenticate', 'Basic realm="WebDAV Server"') - res.end() - } else { - const method = req.method.toUpperCase() - switch (method) { - case 'OPTIONS': - await handleOptions(req, res) - break - case 'GET': - await handleGet(req, res, token) - break - case 'HEAD': - await handleHead(req, res, token) - break - case 'PUT': - await handlePut(req, res, token) - break - case 'DELETE': - await handleDelete(req, res, token) - break - case 'MKCOL': - await handleMkcol(req, res, token) - break - case 'COPY': - await handleCopy(req, res, token) - break - case 'MOVE': - await handleMove(req, res, token) - break - case 'PROPFIND': - await handlePropfind(req, res, token) - break - case 'PROPPATCH': - await handleProppatch(req, res) - break - default: - res.statusCode = 501 - res.end() - } - } - }, - )(req, res) -} - -const server = createServer((req: IncomingMessage, res: ServerResponse) => { - if (req.url === '/v2/health' && req.method === 'GET') { - const apiHealth = new APIHealthAPI().get() - const idpHealth = new IDPHealthAPI().get() - Promise.all([apiHealth, idpHealth]) - .then((results) => { - if (results.every((result) => result === 'OK')) { - res.statusCode = 200 - res.end('OK') - } else { - res.statusCode = 503 - res.end() - } - }) - .catch(() => { - res.statusCode = 503 - res.end() - }) - } else { - handleRequest(req, res) - } -}) - -server.listen(PORT, () => { - console.log(`Listening on port ${PORT}`) -}) diff --git a/webdav/tsconfig.json b/webdav/tsconfig.json deleted file mode 100644 index 52434aa7e..000000000 --- a/webdav/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, - "module": "commonjs", - "esModuleInterop": true, - "target": "es2021", - "moduleResolution": "node", - "sourceMap": true, - "outDir": "build", - "experimentalDecorators": true - } -}