Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Implement file handling #93

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions cmd/standardfile/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
190 changes: 190 additions & 0 deletions internal/server/files.go
Original file line number Diff line number Diff line change
@@ -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)
Crusader99 marked this conversation as resolved.
Show resolved Hide resolved
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(&params); 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)
}
28 changes: 25 additions & 3 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Controller struct {
NoRegistration bool
ShowRealVersion bool
EnableSubscription bool
FilesServerUrl string
// JWT params
SigningKey []byte
// Session params
Expand All @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we should do something like:

v1valet := v1.Group("")
v1valet.Use(a valet middleware for authentication)
v1valet.POST("/files/upload/create-session", files.CreateUploadSession)

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")
})
Expand Down
10 changes: 9 additions & 1 deletion internal/server/subscription_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,6 +33,9 @@ func (h *subscription) SubscriptionV1(c echo.Context) error {
},
},
},
"server": echo.Map{
"filesServerUrl": h.filesServerUrl,
},
},
"data": echo.Map{
"success": true,
Expand Down Expand Up @@ -70,6 +75,9 @@ func (h *subscription) Features(c echo.Context) error {
},
},
},
"server": echo.Map{
"filesServerUrl": h.filesServerUrl,
},
},
"data": echo.Map{
"success": true,
Expand Down
12 changes: 11 additions & 1 deletion standardfile.yml
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
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"