diff --git a/cmd/standardfile/main.go b/cmd/standardfile/main.go index 4799a8d..df1734f 100644 --- a/cmd/standardfile/main.go +++ b/cmd/standardfile/main.go @@ -172,6 +172,7 @@ var ( NoRegistration: konf.Bool("no_registration"), ShowRealVersion: konf.Bool("show_real_version"), EnableSubscription: konf.Bool("enable_subscription"), + FilesServerUrl: konf.String("files_server_url"), SigningKey: configSecretKey, SessionSecret: kdf(32, configSessionSecret), AccessTokenExpirationTime: konf.MustDuration("session.access_token_ttl"), diff --git a/internal/server/files.go b/internal/server/files.go new file mode 100644 index 0000000..f638443 --- /dev/null +++ b/internal/server/files.go @@ -0,0 +1,190 @@ +package server + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "errors" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" +) + +type files struct{} + +type Resource struct { + RemoteIdentifier string `json:"remoteIdentifier"` +} + +func respond(context echo.Context, code int, message string) error { + success := code < 300 + return context.JSON(code, echo.Map{ + "success": success, + "message": message, + }) +} + +func readValetToken(context echo.Context) (*ValetToken, error) { + valetTokenBase64 := context.Request().Header.Get("x-valet-token") + valetTokenBytes, err := base64.StdEncoding.DecodeString(valetTokenBase64) + if err != nil { + return nil, errors.New("Unable to parse base64 valet token") + } + valetTokenJson := string(valetTokenBytes) + var token ValetToken + if err := json.Unmarshal([]byte(valetTokenJson), &token); err != nil { + return nil, errors.New("Unable to parse json valet token") + } + return &token, nil +} + +type ValetRequestParams struct { + Operation string `json:"operation"` + Resources []Resource +} + +type ValetToken struct { + Authorization string `json:"authorization"` + FileID string `json:"fileId"` +} + +func (token *ValetToken) getFilePath() (*string, error) { + id, err := uuid.FromString(token.FileID) + if err != nil { + return nil, errors.New("Unable to parse json valet token") + } else if !fs.ValidPath(id.String()) { + return nil, errors.New("Invalid path") + } + // TODO: Allow custom path in config + // TODO: Subfolders for each user (Compatible format with official server) + path := filepath.Join("etc", "standardfile", "database", id.String()) + return &path, nil +} + +// Provides a valet token that is required to execute an operation +func (h *files) ValetTokens(c echo.Context) error { + var params ValetRequestParams + if err := c.Bind(¶ms); err != nil { + return respond(c, http.StatusBadRequest, "Unable to parse request") + } else if len(params.Resources) != 1 { + return respond(c, http.StatusBadRequest, "Multi file requests unsupported") + } + + // Generate valet token. Used for actual file operations + var token ValetToken + token.Authorization = c.Request().Header.Get(echo.HeaderAuthorization) + token.FileID = params.Resources[0].RemoteIdentifier + valetTokenJson, err := json.Marshal(token) + if err != nil { + return c.JSON(http.StatusBadRequest, err) + } + return c.JSON(http.StatusOK, echo.Map{ + "success": true, + "valetToken": base64.StdEncoding.EncodeToString(valetTokenJson), + }) +} + +// Called before uploading chunks of a file +func (h *files) CreateUploadSession(c echo.Context) error { + token, err := readValetToken(c) + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + + // Validate file path + path, err := token.getFilePath() + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + + // Create empty file + if _, err := os.Create(*path); err != nil { + return c.JSON(http.StatusBadRequest, err) + } + + return c.JSON(http.StatusOK, echo.Map{ + "success": true, + "uploadId": token.FileID, + }) +} + +// Called when uploaded all chunks of a file +func (h *files) CloseUploadSession(c echo.Context) error { + token, err := readValetToken(c) + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + path, err := token.getFilePath() + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } else if _, err := os.Stat(*path); errors.Is(err, os.ErrNotExist) { + return respond(c, http.StatusBadRequest, "File not created") + } + return respond(c, http.StatusOK, "File uploaded successfully") +} + +// Upload parts of a file in an existing session +func (h *files) UploadChunk(c echo.Context) error { + token, err := readValetToken(c) + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + + // Validate file path + path, err := token.getFilePath() + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } else if _, err := os.Stat(*path); errors.Is(err, os.ErrNotExist) { + return c.JSON(http.StatusBadRequest, "File not created") + } + + // Open file + f, err := os.OpenFile(*path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return respond(c, http.StatusBadRequest, "Unable to open file") + } + defer f.Close() + + // Append chunk to file via buffer + writer := bufio.NewWriter(f) + reader := c.Request().Body + if _, err := io.Copy(writer, reader); err != nil { + return respond(c, http.StatusBadRequest, "Unable to store file") + } + return respond(c, http.StatusOK, "Chunk uploaded successfully") +} + +// Delete file from server +func (h *files) Delete(c echo.Context) error { + token, err := readValetToken(c) + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + path, err := token.getFilePath() + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } else if err := os.Remove(*path); err != nil { + return respond(c, http.StatusBadRequest, "Unable to remove file") + } + return respond(c, http.StatusOK, "File removed successfully") +} + +// Send encrypted file to client +func (h *files) Download(c echo.Context) error { + token, err := readValetToken(c) + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + + // Validate file path + path, err := token.getFilePath() + if err != nil { + return respond(c, http.StatusBadRequest, err.Error()) + } + return c.File(*path) +} diff --git a/internal/server/server.go b/internal/server/server.go index 17e05fe..98ca8ad 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,6 +21,7 @@ type Controller struct { NoRegistration bool ShowRealVersion bool EnableSubscription bool + FilesServerUrl string // JWT params SigningKey []byte // Session params @@ -35,7 +36,12 @@ func EchoEngine(ctrl Controller) *echo.Echo { engine.Use(middleware.Recover()) // engine.Use(middleware.CSRF()) // not supported by StandardNotes engine.Use(middleware.Secure()) - engine.Use(middleware.CORSWithConfig(middleware.DefaultCORSConfig)) + + // Expose headers for file download + cors := middleware.DefaultCORSConfig + cors.ExposeHeaders = append(cors.ExposeHeaders, "Content-Range", "Accept-Ranges") + engine.Use(middleware.CORSWithConfig(cors)) + engine.Use(middleware.Gzip()) engine.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ @@ -68,6 +74,7 @@ func EchoEngine(ctrl Controller) *echo.Echo { v1 := router.Group("/v1") v1restricted := restricted.Group("/v1") + // // generic handlers // router.GET("/version", func(c echo.Context) error { @@ -142,13 +149,28 @@ func EchoEngine(ctrl Controller) *echo.Echo { v2 := router.Group("/v2") v2.POST("/login", auth.LoginPKCE) v2.POST("/login-params", auth.ParamsPKCE) - //v2restricted := restricted.Group("/v2") + + // + // files + // + files := &files{} + v1restricted.POST("/files/valet-tokens", files.ValetTokens) + v1valet := v1.Group("") + // TODO: v1valet.Use(a valet middleware for authentication) + // Following endpoints are authorized via valet token + v1valet.POST("/files/upload/create-session", files.CreateUploadSession) + v1valet.POST("/files/upload/close-session", files.CloseUploadSession) + v1valet.POST("/files/upload/chunk", files.UploadChunk) + v1valet.DELETE("/files", files.Delete) + v1valet.GET("/files", files.Download) // // subscription handlers // if ctrl.EnableSubscription { - subscription := &subscription{} + subscription := &subscription{ + filesServerUrl: ctrl.FilesServerUrl, + } router.GET("/v2/subscriptions", func(c echo.Context) error { return c.HTML(http.StatusInternalServerError, "getaddrinfo EAI_AGAIN payments") }) diff --git a/internal/server/subscription_handler.go b/internal/server/subscription_handler.go index dd201c6..e27ec7e 100644 --- a/internal/server/subscription_handler.go +++ b/internal/server/subscription_handler.go @@ -7,7 +7,9 @@ import ( "github.com/labstack/echo/v4" ) -type subscription struct{} +type subscription struct { + filesServerUrl string +} func (h *subscription) SubscriptionV1(c echo.Context) error { user := currentUser(c) @@ -31,6 +33,9 @@ func (h *subscription) SubscriptionV1(c echo.Context) error { }, }, }, + "server": echo.Map{ + "filesServerUrl": h.filesServerUrl, + }, }, "data": echo.Map{ "success": true, @@ -70,6 +75,9 @@ func (h *subscription) Features(c echo.Context) error { }, }, }, + "server": echo.Map{ + "filesServerUrl": h.filesServerUrl, + }, }, "data": echo.Map{ "success": true, diff --git a/standardfile.yml b/standardfile.yml old mode 100644 new mode 100755 index f3d230c..5245044 --- a/standardfile.yml +++ b/standardfile.yml @@ -7,21 +7,27 @@ # Address to bind address: "localhost:5000" + # Disable registration no_registration: false + # Show real version in `GET /version' show_real_version: false + # Database folder path; empty value means current directory database_path: "" + # Secret key used for JWT authentication (before 004 and 20200115) # If missing, will be read from $CREDENTIALS_DIRECTORY/secret_key file secret_key: jwt-development + # Session used for authentication (since 004 and 20200115) session: # If missing, will be read from $CREDENTIALS_DIRECTORY/session.secret file secret: paseto-development access_token_ttl: 1440h # 60 days expressed in Golang's time.Duration format refresh_token_ttl: 8760h # 1 year + # This option enables paid features in the official StandardNotes client. # If you want to enables these features, you should consider to # donate to the StandardNotes project as they say: @@ -33,4 +39,8 @@ session: # # This project https://github.com/mdouchement/standardfile does not intend to # conflict with the business model of StandardNotes project or seek compensation. -enable_subscription: false \ No newline at end of file +enable_subscription: false + +# Insert a publicly accessible URL to this server and set enable_subscription +# to 'true' for working file upload/download. +files_server_url: "http://localhost:5000" \ No newline at end of file