├── .examples └── libsf │ └── main.go ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── docker.yml │ ├── golangci-lint.yml │ ├── release.go │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── Taskfile.yml ├── cmd ├── sfc │ └── main.go └── standardfile │ └── main.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── client │ ├── backup.go │ ├── config.go │ ├── login.go │ ├── logout.go │ ├── note.go │ ├── tui │ │ ├── item.go │ │ ├── logger.go │ │ ├── note_list.go │ │ └── tui.go │ └── unseal.go ├── database │ ├── database.go │ └── storm.go ├── model │ ├── base.go │ ├── item.go │ ├── pkce.go │ ├── session.go │ └── user.go ├── server │ ├── auth_handler.go │ ├── auth_handler_20161215_test.go │ ├── auth_handler_20200115_test.go │ ├── export_test.go │ ├── item_handler.go │ ├── item_handler_20161215_test.go │ ├── item_handler_20190520_test.go │ ├── middlewares │ │ ├── binder.go │ │ ├── http_error_handler.go │ │ └── session.go │ ├── serializer │ │ ├── session.go │ │ └── user.go │ ├── server.go │ ├── server_test.go │ ├── service │ │ ├── pkce.go │ │ ├── service.go │ │ ├── sync.go │ │ ├── sync_20161215.go │ │ ├── sync_20190520.go │ │ ├── user.go │ │ ├── user_20161215.go │ │ └── user_20200115.go │ ├── session │ │ ├── manager.go │ │ ├── secure_token.go │ │ ├── secure_token_test.go │ │ ├── session.go │ │ └── sesstion_test.go │ ├── session_handler.go │ ├── session_handler_test.go │ └── subscription_handler.go └── sferror │ ├── error.go │ └── error_test.go ├── pkg ├── libsf │ ├── authentication.go │ ├── authentication_test.go │ ├── client.go │ ├── crypto.go │ ├── crypto_test.go │ ├── doc.go │ ├── error.go │ ├── export_test.go │ ├── item.go │ ├── item_test.go │ ├── keychain.go │ ├── keychain_test.go │ ├── note.go │ ├── session.go │ ├── token.go │ ├── token_test.go │ ├── vault.go │ ├── vault3.go │ ├── vault4.go │ ├── version.go │ └── version_test.go ├── stormbinc │ └── binc.go ├── stormcbor │ └── cbor.go ├── stormsql │ └── select.go └── structs │ └── fields.go ├── standardfile.yml └── tools ├── console └── main.go └── rmuser └── main.go /.examples/libsf/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/mdouchement/standardfile/pkg/libsf" 8 | ) 9 | 10 | func main() { 11 | // 12 | // Create client 13 | // 14 | 15 | client, err := libsf.NewDefaultClient("https://notes.nas.lan") 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | // 21 | // Authenticate 22 | // 23 | 24 | email := "george.abitbol@nas.lan" 25 | password := "12345678" 26 | 27 | auth, err := client.GetAuthParams(email) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | err = auth.IntegrityCheck() 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | 37 | // Create keychain containing all the keys used for encryption and authentication. 38 | keychain := auth.SymmetricKeyPair(password) 39 | 40 | err = client.Login(auth.Email(), keychain.Password) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | // 46 | // Get all items 47 | // 48 | 49 | items := libsf.NewSyncItems() 50 | items, err = client.SyncItems(items) // No sync_token and limit are setted so we get all items. 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | // Append `SN|ItemsKey` to the KeyChain. 56 | for _, item := range items.Retrieved { 57 | if item.ContentType != libsf.ContentTypeItemsKey { 58 | continue 59 | } 60 | 61 | err = item.Unseal(keychain) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | } 66 | 67 | var last int 68 | for i, item := range items.Retrieved { 69 | switch item.ContentType { 70 | case libsf.ContentTypeUserPreferences: 71 | // Unseal Preferences item using keychain. 72 | err = item.Unseal(keychain) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | 77 | // Parse metadata. 78 | if err = item.Note.ParseRaw(); err != nil { 79 | log.Fatal(err) 80 | } 81 | 82 | fmt.Println("Items are sorted by:", item.Note.GetSortingField()) 83 | case libsf.ContentTypeNote: 84 | // Unseal Note item using keychain. 85 | err = item.Unseal(keychain) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | // Parse metadata. 91 | if err = item.Note.ParseRaw(); err != nil { 92 | log.Fatal(err) 93 | } 94 | 95 | fmt.Println("Title:", item.Note.Title) 96 | fmt.Println("Content:", item.Note.Text) 97 | 98 | last = i 99 | } 100 | } 101 | 102 | // 103 | // Update an item 104 | // 105 | 106 | item := items.Retrieved[last] 107 | item.Note.Title += " updated" 108 | item.Note.Text += " updated" 109 | 110 | item.Note.SetUpdatedAtNow() 111 | item.Note.SaveRaw() 112 | 113 | err = item.Seal(keychain) 114 | if err != nil { 115 | log.Fatal(err) 116 | } 117 | 118 | // Syncing updated item. 119 | items = libsf.NewSyncItems() 120 | items.Items = append(items.Items, item) 121 | items, err = client.SyncItems(items) 122 | if err != nil { 123 | log.Fatal(err) 124 | } 125 | 126 | if len(items.Conflicts) > 0 { 127 | log.Fatal("items conflict") 128 | } 129 | fmt.Println("Updated!") 130 | } 131 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '31 7 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build docker image 2 | on: 3 | schedule: 4 | - cron: '0 0 */3 * *' # @at 0h0m0s every 3 days 5 | release: 6 | types: [created] 7 | push: 8 | branches: [master] 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # Fetch all history for all branches and tags 17 | # 18 | - name: Prepare 19 | id: prep 20 | run: | 21 | if [[ $GITHUB_EVENT_NAME == schedule ]]; then 22 | sudo apt-get update -y 23 | sudo apt-get install -y curl jq 24 | 25 | LATEST_TAG=$(curl -sSL https://api.github.com/repos/mdouchement/standardfile/releases/latest | jq -r .tag_name) 26 | git checkout $LATEST_TAG 27 | export GITHUB_REF=refs/tags/$LATEST_TAG 28 | fi 29 | 30 | # 31 | # 32 | # 33 | 34 | DOCKER_IMAGE=mdouchement/standardfile 35 | 36 | VERSION=edge 37 | if [[ $GITHUB_REF == refs/heads/* ]]; then 38 | # Branch name 39 | VERSION=${GITHUB_REF#refs/heads/} 40 | fi 41 | if [[ $GITHUB_REF == refs/tags/v* ]]; then 42 | # Tag name 43 | VERSION=${GITHUB_REF#refs/tags/v} 44 | fi 45 | 46 | TAGS="${DOCKER_IMAGE}:${VERSION}" 47 | if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then 48 | TAGS="$TAGS,${DOCKER_IMAGE}:latest" 49 | fi 50 | 51 | echo ::set-output name=tags::${TAGS} 52 | # 53 | - name: Set up QEMU 54 | uses: docker/setup-qemu-action@v3 55 | # 56 | - name: Set up Docker Buildx 57 | uses: docker/setup-buildx-action@v3 58 | # 59 | - name: Login to DockerHub 60 | uses: docker/login-action@v3 61 | with: 62 | username: ${{ secrets.DOCKERHUB_USERNAME }} 63 | password: ${{ secrets.DOCKERHUB_TOKEN }} 64 | # 65 | - name: Build and push 66 | id: docker_build 67 | uses: docker/build-push-action@v5 68 | with: 69 | file: Dockerfile 70 | context: . 71 | push: true 72 | tags: ${{ steps.prep.outputs.tags }} 73 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | name: golangci-lint 5 | permissions: 6 | contents: read 7 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 8 | # pull-requests: read 9 | env: 10 | GO_VERSION: "~1" 11 | jobs: 12 | test: 13 | name: golangci-lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Install Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: ${{ env.GO_VERSION }} 20 | check-latest: true 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v6 25 | with: 26 | version: latest -------------------------------------------------------------------------------- /.github/workflows/release.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "runtime" 15 | "strings" 16 | ) 17 | 18 | type controller struct { 19 | epath string 20 | token string 21 | baseurl string 22 | event event 23 | } 24 | 25 | type event struct { 26 | Release struct { 27 | ID int64 `json:"id"` 28 | URL string `json:"url"` 29 | AssetsURL string `json:"assets_url"` 30 | UploadURL string `json:"upload_url"` 31 | TagName string `json:"tag_name"` 32 | TargetCommitish string `json:"target_commitish"` 33 | Name string `json:"name"` 34 | Draft bool `json:"draft"` 35 | Prerelease bool `json:"prerelease"` 36 | } `json:"release"` 37 | } 38 | 39 | func main() { 40 | c := &controller{ 41 | // https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables 42 | epath: os.Getenv("GITHUB_EVENT_PATH"), 43 | token: os.Getenv("GITHUB_TOKEN"), 44 | baseurl: "https://api.github.com/", 45 | } 46 | fmt.Println("Token size:", len(c.token)) 47 | 48 | // 49 | // 50 | 51 | err := c.load() 52 | if err != nil { 53 | log.Fatal("load: ", err) 54 | } 55 | 56 | fmt.Println("RELEASE:", c.event.Release.ID) 57 | fmt.Println("URL:", c.event.Release.URL) 58 | fmt.Println("UPLOAD_URL:", c.event.Release.UploadURL) 59 | fmt.Println("TAG:", c.event.Release.TagName) 60 | 61 | // 62 | 63 | err = c.update() 64 | if err != nil { 65 | log.Fatal("update: ", err) 66 | } 67 | 68 | // 69 | 70 | err = c.upload() 71 | if err != nil { 72 | log.Fatal("upload: ", err) 73 | } 74 | } 75 | 76 | // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release 77 | func (c *controller) load() error { 78 | payload, err := os.ReadFile(c.epath) 79 | if err != nil { 80 | return fmt.Errorf("readfile: %w", err) 81 | } 82 | 83 | err = json.Unmarshal(payload, &c.event) 84 | if err != nil { 85 | return fmt.Errorf("parse: %w", err) 86 | } 87 | 88 | if !strings.HasPrefix(c.event.Release.URL, c.baseurl) { 89 | return fmt.Errorf("bad url prefix: %s", c.event.Release.URL) 90 | } 91 | if !strings.HasPrefix(c.event.Release.AssetsURL, c.baseurl) { 92 | return fmt.Errorf("bad upload url prefix: %s", c.event.Release.UploadURL) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (c *controller) update() error { 99 | fmt.Println("=> Updating readme") 100 | 101 | readme, err := c.readme() 102 | if err != nil { 103 | return fmt.Errorf("readme: %w", err) 104 | } 105 | fmt.Println(readme) 106 | 107 | body, err := json.Marshal(map[string]any{ 108 | "body": readme, 109 | }) 110 | 111 | if err != nil { 112 | return fmt.Errorf("body: %w", err) 113 | } 114 | 115 | // 116 | 117 | req, err := c.request("PATCH", c.event.Release.URL, bytes.NewBuffer(body)) 118 | if err != nil { 119 | return fmt.Errorf("request: %w", err) 120 | } 121 | 122 | err = c.perform(req, nil) 123 | if err != nil { 124 | return fmt.Errorf("perform: %w", err) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (c *controller) readme() (string, error) { 131 | checksums, err := os.ReadFile("dist/checksum.txt") 132 | if err != nil { 133 | return "", fmt.Errorf("readfile: %w", err) 134 | } 135 | 136 | return fmt.Sprintf("```\n%s\n\n%s\n```", runtime.Version(), bytes.TrimSpace(checksums)), nil 137 | } 138 | 139 | func (c *controller) upload() error { 140 | baseurl := c.event.Release.UploadURL 141 | if idx := strings.IndexRune(baseurl, '{'); idx > 0 { 142 | baseurl = baseurl[:idx] 143 | } 144 | 145 | // 146 | 147 | err := filepath.Walk("dist/", func(path string, info fs.FileInfo, err error) error { 148 | if err != nil { 149 | return err 150 | } 151 | 152 | if info.IsDir() { 153 | return nil 154 | } 155 | 156 | fmt.Println("=> Uploading", path) 157 | 158 | // 159 | 160 | u, err := url.Parse(baseurl) 161 | if err != nil { 162 | return fmt.Errorf("parse upload url: %w", err) 163 | } 164 | params := u.Query() 165 | params.Set("name", info.Name()) 166 | u.RawQuery = params.Encode() 167 | 168 | // 169 | 170 | f, err := os.Open(path) 171 | if err != nil { 172 | return fmt.Errorf("read file: %w", err) 173 | } 174 | defer f.Close() 175 | 176 | // 177 | 178 | req, err := c.request("POST", u.String(), f) 179 | if err != nil { 180 | return fmt.Errorf("request: %w", err) 181 | } 182 | 183 | req.ContentLength = info.Size() 184 | req.Header.Set("Content-Type", "application/octet-stream") 185 | 186 | // 187 | 188 | err = c.perform(req, nil) 189 | if err != nil { 190 | return fmt.Errorf("perform: %w", err) 191 | } 192 | 193 | return nil 194 | }) 195 | if err != nil { 196 | return fmt.Errorf("walk: %w", err) 197 | } 198 | 199 | return nil 200 | } 201 | 202 | ///////////////////// 203 | // // 204 | // HTTP // 205 | // // 206 | ///////////////////// 207 | 208 | func (c *controller) request(method, url string, body io.Reader) (*http.Request, error) { 209 | req, err := http.NewRequest(method, url, body) 210 | if err != nil { 211 | return nil, fmt.Errorf("create request: %w", err) 212 | } 213 | 214 | req.Header.Set("Accept", "application/vnd.github.v3+json") 215 | if c.token != "" { 216 | req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) 217 | } 218 | 219 | return req, nil 220 | } 221 | 222 | func (c *controller) perform(req *http.Request, v any) error { 223 | response, err := http.DefaultClient.Do(req) 224 | if err != nil { 225 | return fmt.Errorf("do: %w", err) 226 | } 227 | defer response.Body.Close() 228 | 229 | if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { 230 | return fmt.Errorf("status: %s", response.Status) 231 | } 232 | 233 | if v != nil { 234 | codec := json.NewDecoder(response.Body) 235 | if err = codec.Decode(v); err != nil { 236 | return fmt.Errorf("decode: %s", response.Status) 237 | } 238 | } 239 | 240 | return nil 241 | } 242 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | # https://docs.github.com/en/actions/reference/events-that-trigger-workflows#release 3 | release: 4 | types: [created] 5 | name: Build Release 6 | env: 7 | GO_VERSION: "~1" 8 | TASK_VERSION: v3.11.0 9 | TASK_SUM: 8284fa89367e0bbb8ba5dcb90baa6826b7669c4a317e5b9a46711f7380075e21 10 | jobs: 11 | release: 12 | name: Build binaries 13 | runs-on: ubuntu-latest 14 | steps: 15 | # 16 | - name: Install Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: ${{ env.GO_VERSION }} 20 | check-latest: true 21 | # 22 | - name: Tooling(checksum) 23 | run: go install github.com/mdouchement/checksum@master 24 | - name: Tooling(Taskfile) 25 | run: | 26 | curl -LO https://github.com/go-task/task/releases/download/${{ env.TASK_VERSION }}/task_linux_amd64.tar.gz && \ 27 | echo "${{ env.TASK_SUM }} task_linux_amd64.tar.gz" | sha256sum -c && \ 28 | tar -xf task_linux_amd64.tar.gz && \ 29 | cp task /usr/local/bin/ 30 | # 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | # 34 | - name: Build binaries 35 | run: task build-all 36 | # 37 | - name: Update release 38 | run: go run .github/workflows/release.go 39 | env: 40 | # secrets.GITHUB_TOKEN is created by GH action and is limited to the repository 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | name: Test 5 | env: 6 | GO_VERSION: "~1" 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | # 13 | - name: Install Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: ${{ env.GO_VERSION }} 17 | check-latest: true 18 | - name: Tooling 19 | run: go install gotest.tools/gotestsum@latest 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | - name: Test 23 | run: gotestsum 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.DS_Store 2 | .envrc 3 | standardfile.db 4 | .standardfile 5 | sfc.log 6 | auth_params.json 7 | items_*.json 8 | notes_*.json 9 | subscription.json 10 | features.json 11 | dist/ 12 | TODO.md -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | linters: 5 | enable-all: false 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine as build-env 3 | MAINTAINER mdouchement 4 | 5 | RUN apk upgrade 6 | RUN apk add --update --no-cache git curl go-task 7 | 8 | RUN mkdir -p /go/src/github.com/mdouchement/standardfile 9 | WORKDIR /go/src/github.com/mdouchement/standardfile 10 | 11 | ENV CGO_ENABLED 0 12 | ENV GO111MODULE on 13 | ENV GOPROXY https://proxy.golang.org 14 | 15 | COPY . /go/src/github.com/mdouchement/standardfile 16 | # Dependencies 17 | RUN go mod download 18 | 19 | RUN go-task build-server 20 | 21 | # final stage 22 | FROM alpine 23 | MAINTAINER mdouchement 24 | 25 | ENV DATABASE_PATH /data/database 26 | 27 | RUN mkdir -p ${DATABASE_PATH} 28 | 29 | COPY --from=build-env /go/src/github.com/mdouchement/standardfile/dist/standardfile /usr/local/bin/ 30 | 31 | EXPOSE 5000 32 | CMD ["standardfile", "server", "-c", "/etc/standardfile/standardfile.yml"] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 mdouchement 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yet Another Standardfile Implementation in Go 2 | 3 | This project is **maintained for the basic features** as encrypted notes because on its own it already takes hours to figure out what is the issue when a breaking change or bug happens (e.g. [#87](https://github.com/mdouchement/standardfile/issues/87)). 4 | 5 | People's pull requests that implement or fix extra features (revision, file storage, etc.) will be gladly reviewed and merged. 6 | 7 | - For any bug linked to the code or its faulty behavior, please take a look to the existing [Issues](https://github.com/mdouchement/standardfile/issues) and feel free to open a new one if nothing match your issue 8 | - For any question about this project, the configuration, integrations, related projects and so one, please take a look to the existing [Discussions](https://github.com/mdouchement/standardfile/discussions) and feel free to open a new one 9 | 10 |
11 | 12 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/mdouchement/standardfile) 13 | [![Go Report Card](https://goreportcard.com/badge/github.com/mdouchement/standardfile)](https://goreportcard.com/report/github.com/mdouchement/standardfile) 14 | [![License](https://img.shields.io/github/license/mdouchement/standardfile.svg)](http://opensource.org/licenses/MIT) 15 | 16 | This is a 100% Golang implementation of the [Standard Notes](https://docs.standardnotes.com/specification/sync) protocol. It aims to be **portable** and **lightweight**. 17 | 18 | ### Running your own server 19 | 20 | You can run your own Standard File server, and use it with any SF compatible client (like Standard Notes). 21 | This allows you to have 100% control of your data. 22 | This server implementation is built with Go and can be deployed in seconds. 23 | 24 | https://hub.docker.com/r/mdouchement/standardfile 25 | 26 | ### Client library 27 | 28 | Go to `pgk/libsf` for more details. 29 | https://godoc.org/github.com/mdouchement/standardfile/pkg/libsf 30 | 31 | It is an alternative to https://github.com/jonhadfield/gosn 32 | 33 | ### SF client 34 | 35 | ```sh 36 | go run cmd/sfc/main.go -h 37 | ``` 38 | 39 | Terminal UI client: 40 | ![sfc note](https://user-images.githubusercontent.com/6150317/62490536-c997f780-b7c9-11e9-867a-bc619d286b31.png) 41 | 42 | ## Requirements 43 | 44 | - Golang 1.16.x (Go Modules) 45 | 46 | ### Technologies / Frameworks 47 | 48 | - [Cobra](https://github.com/spf13/cobra) 49 | - [Echo](https://github.com/labstack/echo) 50 | - [BoltDB](https://github.com/etcd-io/bbolt) + [Storm](https://github.com/asdine/storm) Toolkit 51 | - [Gowid](https://github.com/gcla/gowid) 52 | 53 | 54 | ## Differences with reference implementation 55 | 56 |
57 | Drop legacy support for clients which hardcoded the "api" path to the base url (iOS) 58 | 59 | > [Permalink](https://github.com/standardfile/ruby-server/blob/0a48c2625afc21966b110e0f73a1ff7bd212dbf4/config/routes.rb#L19-L26) 60 | 61 |
62 | 63 |
64 | Drop the POST request done on Extensions (backups too) 65 | 66 | > [Permalink](https://github.com/standardfile/ruby-server/blob/09b2020313a54668b7c6c0e122bbc8a530767d06/app/controllers/api/items_controller.rb#L20-L45) 67 | 68 | This feature is pretty undocumented and I feel uncomfortable about the outgoing traffic from my server on unknown URLs. 69 | 70 |
71 | 72 |
73 | Drop V1 support 74 | 75 | > [All stuff used in v1 and not in v2 nor v3](https://github.com/standardfile/standardfile.github.io/blob/master/doc/spec-001.md) 76 | 77 |
78 | 79 |
80 | JWT revocation strategy after password update 81 | 82 | > Reference implementation use a [pw_hash](https://github.com/standardfile/ruby-server/blob/0a48c2625afc21966b110e0f73a1ff7bd212dbf4/app/controllers/api/api_controller.rb#L37-L43) claim to check if the user has changed their pw and thus forbid them from access if they have an old jwt. 83 | 84 |
85 | 86 | > Here we will revoke JWT based on its `iat` claim and `User.PasswordUpdatedAt` field. 87 | > Looks more safer than publicly expose any sort of password stuff. 88 | > See `internal/server/middlewares/current_user.go` 89 | 90 |
91 | 92 |
93 | Session use PASETO tokens instead of random tokens 94 | 95 | > Here we will be using PASETO to strengthen authentication to ensure that the tokens are issued by the server. 96 | 97 |
98 | 99 | ## Not implemented (yet) 100 | 101 | - **2FA** (aka `verify_mfa`) 102 | - Postgres if a more stronger database is needed 103 | - A console for admin usage 104 | 105 | 106 | ## License 107 | 108 | **MIT** 109 | 110 | 111 | ## Contributing 112 | 113 | All PRs are welcome. 114 | 115 | 1. Fork it 116 | 2. Create your feature branch (git checkout -b my-new-feature) 117 | 3. Commit your changes (git commit -am 'Add some feature') 118 | 5. Push to the branch (git push origin my-new-feature) 119 | 6. Create new Pull Request 120 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | # https://github.com/mdouchement/checksum 3 | 4 | version: '3' 5 | 6 | vars: 7 | VERSION: 0.13.2 8 | REVISION: { sh: git rev-parse HEAD } 9 | WORKDIR: { sh: pwd } 10 | 11 | env: 12 | GO111MODULE: on 13 | CGO_ENABLED: 0 14 | 15 | tasks: 16 | docker: 17 | desc: Build a Docker image of the server 18 | cmds: 19 | - task: clean 20 | - docker build -t mdouchement/standardfile:{{.VERSION}} . 21 | 22 | clean: 23 | desc: Clean project 24 | cmds: 25 | - rm -rf {{.WORKDIR}}/dist 26 | 27 | build-server: 28 | desc: Build the server 29 | cmds: 30 | - task: clean 31 | - mkdir -p {{.WORKDIR}}/dist 32 | - task: build 33 | vars: 34 | BINARY_NAME: standardfile 35 | ENTRYPOINT: "{{.WORKDIR}}/cmd/standardfile" 36 | TARGET_DIST: "" 37 | 38 | build-all: 39 | desc: Build all binaries 40 | cmds: 41 | - task: clean 42 | - mkdir -p {{.WORKDIR}}/dist 43 | 44 | - task: build 45 | vars: 46 | BINARY_NAME: standardfile-linux-amd64 47 | ENTRYPOINT: "{{.WORKDIR}}/cmd/standardfile" 48 | TARGET_DIST: GOOS=linux GOARCH=amd64 49 | 50 | - task: checksum 51 | vars: 52 | BINARY_NAME: standardfile-linux-amd64 53 | 54 | - task: build 55 | vars: 56 | BINARY_NAME: standardfile-linux-arm64 57 | ENTRYPOINT: "{{.WORKDIR}}/cmd/standardfile" 58 | TARGET_DIST: GOOS=linux GOARCH=arm64 59 | 60 | - task: checksum 61 | vars: 62 | BINARY_NAME: standardfile-linux-arm64 63 | 64 | - task: build 65 | vars: 66 | BINARY_NAME: sfc-linux-amd64 67 | ENTRYPOINT: "{{.WORKDIR}}/cmd/sfc" 68 | TARGET_DIST: GOOS=linux GOARCH=amd64 69 | 70 | - task: checksum 71 | vars: 72 | BINARY_NAME: sfc-linux-amd64 73 | 74 | - task: build 75 | vars: 76 | BINARY_NAME: sfc-darwin-amd64 77 | ENTRYPOINT: "{{.WORKDIR}}/cmd/sfc" 78 | TARGET_DIST: GOOS=darwin GOARCH=amd64 79 | 80 | - task: checksum 81 | vars: 82 | BINARY_NAME: sfc-darwin-amd64 83 | 84 | - task: build 85 | vars: 86 | BINARY_NAME: sfc-windows-amd64.exe 87 | ENTRYPOINT: "{{.WORKDIR}}/cmd/sfc" 88 | TARGET_DIST: GOOS=windows GOARCH=amd64 89 | 90 | - task: checksum 91 | vars: 92 | BINARY_NAME: sfc-windows-amd64.exe 93 | 94 | build: 95 | dir: "{{.ENTRYPOINT}}" 96 | cmds: 97 | - '{{.TARGET_DIST}} go build -ldflags "{{.LDFLAGS | splitList "\n" | join " "}}" -o {{.WORKDIR}}/dist/{{.BINARY_NAME}} .' 98 | vars: 99 | LDFLAGS: | 100 | -s 101 | -w 102 | -X main.version={{.VERSION}} 103 | -X main.revision={{ printf "%.7s" .REVISION }} 104 | -X main.date={{now | date "2006-01-02~15:04:05"}} 105 | 106 | checksum: 107 | dir: "{{.WORKDIR}}/dist" 108 | cmds: 109 | - checksum --algs="sha256" --append-to checksum.txt {{.BINARY_NAME}} 110 | -------------------------------------------------------------------------------- /cmd/sfc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/mdouchement/standardfile/internal/client" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | version = "dev" 14 | revision = "none" 15 | date = "unknown" 16 | ) 17 | 18 | func main() { 19 | c := &cobra.Command{ 20 | Use: "sfc", 21 | Short: "Standard File client (aka StandardNotes)", 22 | Version: fmt.Sprintf("%s - build %.7s @ %s - %s", version, revision, date, runtime.Version()), 23 | Args: cobra.NoArgs, 24 | } 25 | c.AddCommand(loginCmd) 26 | c.AddCommand(logoutCmd) 27 | c.AddCommand(backupCmd) 28 | c.AddCommand(unsealCmd) 29 | c.AddCommand(noteCmd) 30 | 31 | if err := c.Execute(); err != nil { 32 | fmt.Println(err) 33 | os.Exit(1) 34 | } 35 | } 36 | 37 | var ( 38 | loginCmd = &cobra.Command{ 39 | Use: "login", 40 | Short: "Login to the StandardFile server", 41 | Args: cobra.NoArgs, 42 | RunE: func(_ *cobra.Command, args []string) error { 43 | return client.Login() 44 | }, 45 | } 46 | 47 | logoutCmd = &cobra.Command{ 48 | Use: "logout", 49 | Short: "Logout from a StandardFile server session", 50 | Args: cobra.NoArgs, 51 | RunE: func(_ *cobra.Command, args []string) error { 52 | return client.Logout() 53 | }, 54 | } 55 | 56 | backupCmd = &cobra.Command{ 57 | Use: "backup", 58 | Short: "Backup your notes", 59 | Args: cobra.NoArgs, 60 | RunE: func(_ *cobra.Command, args []string) error { 61 | return client.Backup() 62 | }, 63 | } 64 | 65 | unsealCmd = &cobra.Command{ 66 | Use: "unseal FILENAME", 67 | Short: "Decrypt your backuped notes", 68 | Args: cobra.ExactArgs(1), 69 | RunE: func(_ *cobra.Command, args []string) error { 70 | return client.Unseal(args[0]) 71 | }, 72 | } 73 | 74 | noteCmd = &cobra.Command{ 75 | Use: "note", 76 | Short: "Text-based StandardNotes application", 77 | Args: cobra.NoArgs, 78 | RunE: func(_ *cobra.Command, args []string) error { 79 | return client.Note() 80 | }, 81 | } 82 | ) 83 | -------------------------------------------------------------------------------- /cmd/standardfile/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "hash" 6 | "io" 7 | "io/fs" 8 | "log" 9 | "net" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | 15 | "github.com/knadh/koanf/parsers/yaml" 16 | "github.com/knadh/koanf/providers/file" 17 | "github.com/knadh/koanf/v2" 18 | "github.com/mdouchement/standardfile/internal/database" 19 | "github.com/mdouchement/standardfile/internal/server" 20 | "github.com/pkg/errors" 21 | "github.com/spf13/cobra" 22 | "golang.org/x/crypto/blake2b" 23 | "golang.org/x/crypto/hkdf" 24 | ) 25 | 26 | const dbname = "standardfile.db" 27 | 28 | var ( 29 | version = "dev" 30 | revision = "none" 31 | date = "unknown" 32 | 33 | cfg string 34 | ) 35 | 36 | func main() { 37 | c := &cobra.Command{ 38 | Use: "standardfile", 39 | Short: "Standard File server for StandardNotes", 40 | Version: fmt.Sprintf("%s - build %.7s @ %s - %s", version, revision, date, runtime.Version()), 41 | Args: cobra.ExactArgs(0), 42 | } 43 | initCmd.Flags().StringVarP(&cfg, "config", "c", "", "Configuration file") 44 | c.AddCommand(initCmd) 45 | 46 | reindexCmd.Flags().StringVarP(&cfg, "config", "c", "", "Configuration file") 47 | c.AddCommand(reindexCmd) 48 | 49 | serverCmd.Flags().StringVarP(&cfg, "config", "c", "", "Configuration file") 50 | c.AddCommand(serverCmd) 51 | 52 | if err := c.Execute(); err != nil { 53 | log.Fatalf("%+v", err) 54 | } 55 | } 56 | 57 | func dbnameWithPath(path string) string { 58 | if len(path) == 0 { 59 | return dbname 60 | } 61 | return filepath.Join(path, dbname) 62 | } 63 | 64 | func kdf(l int, k []byte) []byte { 65 | nhash := func() hash.Hash { 66 | h, err := blake2b.New256(nil) 67 | if err != nil { 68 | panic(err) 69 | } 70 | return h 71 | } 72 | 73 | payload := make([]byte, l) 74 | 75 | kdf := hkdf.New(nhash, k, nil, nil) 76 | _, err := io.ReadFull(kdf, payload) 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | return payload 82 | } 83 | 84 | // keyFromConfig reads a key from the configuration, and if it's not present, tries to read it from a file instead 85 | func keyFromConfig(konf *koanf.Koanf, path string) (out []byte, err error) { 86 | // check if the key is directly placed in the config file 87 | out = konf.Bytes(path) 88 | if len(out) > 0 { 89 | return out, nil 90 | } 91 | 92 | // check if the key is available as a systemd credential 93 | credsDir := os.Getenv("CREDENTIALS_DIRECTORY") 94 | if credsDir == "" { 95 | return nil, errors.New("not found") 96 | } 97 | filename := filepath.Join(credsDir, path) 98 | 99 | out, err = os.ReadFile(filename) 100 | if err != nil { 101 | return nil, errors.Wrap(err, "file read") 102 | } 103 | 104 | if len(out) == 0 { 105 | return nil, errors.New("file empty") 106 | } 107 | 108 | return out, nil 109 | } 110 | 111 | var ( 112 | initCmd = &cobra.Command{ 113 | Use: "init", 114 | Short: "Init the database", 115 | Args: cobra.ExactArgs(0), 116 | RunE: func(_ *cobra.Command, _ []string) error { 117 | konf := koanf.New(".") 118 | if err := konf.Load(file.Provider(cfg), yaml.Parser()); err != nil { 119 | return err 120 | } 121 | 122 | return database.StormInit(dbnameWithPath(konf.String("database_path"))) 123 | }, 124 | } 125 | 126 | // 127 | reindexCmd = &cobra.Command{ 128 | Use: "reindex", 129 | Short: "Reindex the database", 130 | Args: cobra.ExactArgs(0), 131 | RunE: func(_ *cobra.Command, _ []string) error { 132 | konf := koanf.New(".") 133 | if err := konf.Load(file.Provider(cfg), yaml.Parser()); err != nil { 134 | return err 135 | } 136 | 137 | return database.StormReIndex(dbnameWithPath(konf.String("database_path"))) 138 | }, 139 | } 140 | 141 | // 142 | // 143 | serverCmd = &cobra.Command{ 144 | Use: "server", 145 | Short: "Start server", 146 | Args: cobra.ExactArgs(0), 147 | RunE: func(_ *cobra.Command, _ []string) error { 148 | konf := koanf.New(".") 149 | if err := konf.Load(file.Provider(cfg), yaml.Parser()); err != nil { 150 | return err 151 | } 152 | 153 | configSecretKey, err := keyFromConfig(konf, "secret_key") 154 | if err != nil { 155 | return errors.Wrap(err, "secret key") 156 | } 157 | 158 | configSessionSecret, err := keyFromConfig(konf, "session.secret") 159 | if err != nil { 160 | return errors.Wrap(err, "session secret") 161 | } 162 | 163 | db, err := database.StormOpen(dbnameWithPath(konf.String("database_path"))) 164 | if err != nil { 165 | return errors.Wrap(err, "could not open database") 166 | } 167 | defer db.Close() 168 | 169 | var subscription, features []byte 170 | if konf.String("subscription_file") != "" { 171 | subscription, err = os.ReadFile(konf.String("subscription_file")) 172 | if err != nil { 173 | return errors.Wrap(err, "could not read subscription_file") 174 | } 175 | 176 | features, err = os.ReadFile(konf.String("features_file")) 177 | if err != nil { 178 | return errors.Wrap(err, "could not read features_file") 179 | } 180 | } 181 | 182 | engine := server.EchoEngine(server.Controller{ 183 | Version: version, 184 | Database: db, 185 | NoRegistration: konf.Bool("no_registration"), 186 | ShowRealVersion: konf.Bool("show_real_version"), 187 | SubscriptionPayload: subscription, 188 | FeaturesPayload: features, 189 | AllowOrigins: konf.MustStrings("cors.allow_origins"), 190 | AllowMethods: konf.MustStrings("cors.allow_methods"), 191 | SigningKey: configSecretKey, 192 | SessionSecret: kdf(32, configSessionSecret), 193 | AccessTokenExpirationTime: konf.MustDuration("session.access_token_ttl"), 194 | RefreshTokenExpirationTime: konf.MustDuration("session.refresh_token_ttl"), 195 | }) 196 | server.PrintRoutes(engine) 197 | 198 | address := konf.String("address") 199 | message := "could not run server" 200 | log.Printf("Server listening on %s\n", address) 201 | parts := strings.Split(address, ":") 202 | if len(parts) == 2 && parts[0] == "unix" { 203 | socketFile := parts[1] 204 | if _, err := os.Stat(socketFile); err == nil { 205 | log.Printf("Removing existing %s\n", socketFile) 206 | os.Remove(socketFile) 207 | } 208 | defer os.Remove(socketFile) 209 | 210 | listener, err := net.Listen(parts[0], socketFile) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | if socketMode := konf.Int("socket_mode"); socketMode != 0 { 216 | mode := fs.FileMode(socketMode) 217 | if err := os.Chmod(socketFile, mode); err != nil { 218 | return errors.Wrap(err, fmt.Sprintf("chmod %s %#o", socketFile, mode)) 219 | } 220 | } 221 | 222 | return errors.Wrap(engine.Server.Serve(listener), message) 223 | } 224 | 225 | return errors.Wrap(engine.Start(address), message) 226 | }, 227 | } 228 | ) 229 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | standardfile: 5 | restart: always 6 | image: mdouchement/standardfile 7 | container_name: standardfile 8 | ports: 9 | - 8080:5000 10 | # Create a dedicated user on host: 11 | # useradd --no-create-home --shell /sbin/nologin standardfile 12 | user: root:root # or use your dedicated user with: $(id -u standardfile):$(id -g standardfile) 13 | volumes: 14 | - /tmp/standardfile/standardfile.yml:/etc/standardfile/standardfile.yml:ro 15 | - /tmp/standardfile:/data/database # chown -R $(id -u standardfile):$(id -g standardfile) /tmp/standardfile 16 | 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mdouchement/standardfile 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/appleboy/gofight/v2 v2.1.2 9 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 10 | github.com/asdine/storm/v3 v3.2.1 11 | github.com/bep/debounce v1.2.1 12 | github.com/chzyer/readline v1.5.1 13 | github.com/d1str0/pkcs7 v0.0.0-20200424205038-d65c16a5759a 14 | github.com/gcla/gowid v1.4.0 15 | github.com/gdamore/tcell/v2 v2.8.1 16 | github.com/gofrs/uuid v4.4.0+incompatible 17 | github.com/golang-jwt/jwt v3.2.2+incompatible 18 | github.com/golang-jwt/jwt/v5 v5.2.2 19 | github.com/knadh/koanf/parsers/yaml v0.1.0 20 | github.com/knadh/koanf/providers/file v1.1.2 21 | github.com/knadh/koanf/v2 v2.1.2 22 | github.com/labstack/echo-jwt/v4 v4.3.1 23 | github.com/labstack/echo/v4 v4.13.3 24 | github.com/mdouchement/middlewarex v0.3.7 25 | github.com/mdouchement/simple-argon2 v0.1.7 26 | github.com/o1egl/paseto/v2 v2.1.1 27 | github.com/oleiade/reflections v1.1.0 28 | github.com/pkg/errors v0.9.1 29 | github.com/sanity-io/litter v1.5.8 30 | github.com/sirupsen/logrus v1.9.3 31 | github.com/spf13/cobra v1.9.1 32 | github.com/stretchr/testify v1.10.0 33 | github.com/ugorji/go/codec v1.2.12 34 | github.com/valyala/fastjson v1.6.4 35 | github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 36 | golang.org/x/crypto v0.37.0 37 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 38 | ) 39 | 40 | require ( 41 | github.com/davecgh/go-spew v1.1.1 // indirect 42 | github.com/fsnotify/fsnotify v1.9.0 // indirect 43 | github.com/gdamore/encoding v1.0.1 // indirect 44 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 45 | github.com/golang/protobuf v1.5.4 // indirect 46 | github.com/hashicorp/golang-lru v1.0.2 // indirect 47 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 48 | github.com/knadh/koanf/maps v0.1.2 // indirect 49 | github.com/labstack/gommon v0.4.2 // indirect 50 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 51 | github.com/mattn/go-colorable v0.1.14 // indirect 52 | github.com/mattn/go-isatty v0.0.20 // indirect 53 | github.com/mattn/go-runewidth v0.0.16 // indirect 54 | github.com/mitchellh/copystructure v1.2.0 // indirect 55 | github.com/mitchellh/mapstructure v1.5.0 // indirect 56 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 57 | github.com/pmezard/go-difflib v1.0.0 // indirect 58 | github.com/rivo/uniseg v0.4.7 // indirect 59 | github.com/spf13/pflag v1.0.6 // indirect 60 | github.com/valyala/bytebufferpool v1.0.0 // indirect 61 | github.com/valyala/fasttemplate v1.2.2 // indirect 62 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 63 | go.etcd.io/bbolt v1.4.0 // indirect 64 | golang.org/x/net v0.39.0 // indirect 65 | golang.org/x/sys v0.32.0 // indirect 66 | golang.org/x/term v0.31.0 // indirect 67 | golang.org/x/text v0.24.0 // indirect 68 | golang.org/x/time v0.11.0 // indirect 69 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 70 | google.golang.org/appengine v1.6.8 // indirect 71 | google.golang.org/protobuf v1.36.6 // indirect 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /internal/client/backup.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/mdouchement/standardfile/pkg/libsf" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // Backup fetchs all the items and store it in the current directory. 14 | func Backup() error { 15 | cfg, err := Load() 16 | if err != nil { 17 | return errors.Wrap(err, "could not load config") 18 | } 19 | 20 | // 21 | // 22 | 23 | client, err := libsf.NewDefaultClient(cfg.Endpoint) 24 | if err != nil { 25 | return errors.Wrap(err, "could not reach StandardFile endpoint") 26 | } 27 | client.SetBearerToken(cfg.BearerToken) 28 | if cfg.Session.Defined() { 29 | client.SetSession(cfg.Session) 30 | if err = Refresh(client, &cfg); err != nil { 31 | return err 32 | } 33 | } 34 | 35 | // 36 | // 37 | 38 | auth, err := client.GetAuthParams(cfg.Email) 39 | if err != nil { 40 | return errors.Wrap(err, "could not get auth params") 41 | } 42 | 43 | if err = backup(auth, "auth_params.json"); err != nil { 44 | return errors.Wrap(err, "auth_params") 45 | } 46 | 47 | // 48 | // 49 | 50 | // No sync_token and limit are setted so we get all items. 51 | items := libsf.NewSyncItems() 52 | items, err = client.SyncItems(items) 53 | if err != nil { 54 | return errors.Wrap(err, "could not get items") 55 | } 56 | 57 | err = backup(items.Retrieved, fmt.Sprintf("items_%s.json", time.Now().Format("20060102150405"))) 58 | return errors.Wrap(err, "items") 59 | } 60 | 61 | func backup(v any, filename string) error { 62 | payload, err := json.MarshalIndent(v, "", " ") 63 | if err != nil { 64 | return errors.Wrap(err, "could not serialize value to backup") 65 | } 66 | 67 | f, err := os.Create(filename) 68 | if err != nil { 69 | return errors.Wrap(err, "could not create backup file") 70 | } 71 | defer f.Close() 72 | 73 | _, err = f.Write(payload) 74 | if err != nil { 75 | return errors.Wrap(err, "could not write backuped values") 76 | } 77 | 78 | return errors.Wrap(f.Sync(), "could not backup") 79 | } 80 | -------------------------------------------------------------------------------- /internal/client/config.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/chzyer/readline" 10 | sargon2 "github.com/mdouchement/simple-argon2" 11 | "github.com/mdouchement/standardfile/pkg/libsf" 12 | "github.com/pkg/errors" 13 | "golang.org/x/crypto/argon2" 14 | "golang.org/x/crypto/chacha20poly1305" 15 | ) 16 | 17 | const ( 18 | saltKeyLength = 16 19 | credentialsfile = ".standardfile" 20 | ) 21 | 22 | // A Config holds client's configuration. 23 | type Config struct { 24 | Endpoint string `json:"endpoint"` 25 | Email string `json:"email"` 26 | BearerToken string `json:"bearer_token"` // JWT used before 20200115 27 | Session libsf.Session `json:"session"` // Since 20200115 28 | KeyChain libsf.KeyChain `json:"keychain"` 29 | } 30 | 31 | // Remove removes the credential files from the current directory. 32 | func Remove() error { 33 | return os.Remove(credentialsfile) 34 | } 35 | 36 | // Load gets the configuration from the current folder according to `credentialsfile` const. 37 | func Load() (Config, error) { 38 | fmt.Println("Loading credentials from " + credentialsfile) 39 | cfg := Config{ 40 | KeyChain: libsf.KeyChain{ 41 | ItemsKey: make(map[string]string), 42 | }, 43 | } 44 | 45 | ciphertext, err := os.ReadFile(credentialsfile) 46 | if err != nil { 47 | return cfg, errors.Wrap(err, "could not read credentials file") 48 | } 49 | 50 | // 51 | // Key derivation of passphrase 52 | 53 | passphrase, err := readline.Password("passphrase: ") 54 | if err != nil { 55 | return cfg, errors.Wrap(err, "could not read passphrase from stdin") 56 | } 57 | 58 | salt := ciphertext[:saltKeyLength] 59 | ciphertext = ciphertext[saltKeyLength:] 60 | hash := argon2.IDKey(passphrase, salt, 3, 64<<10, 2, 32) 61 | 62 | // 63 | // Seal config 64 | 65 | aead, err := chacha20poly1305.NewX(hash) 66 | if err != nil { 67 | return cfg, errors.Wrap(err, "could not create AEAD") 68 | } 69 | 70 | nonce := ciphertext[:aead.NonceSize()] 71 | ciphertext = ciphertext[aead.NonceSize():] 72 | 73 | payload, err := aead.Open(nil, nonce, ciphertext, nil) 74 | if err != nil { 75 | return cfg, errors.Wrap(err, "could not decrypt credentials file") 76 | } 77 | 78 | err = json.Unmarshal(payload, &cfg) 79 | if err != nil { 80 | return cfg, errors.Wrap(err, "could not parse config") 81 | } 82 | 83 | return cfg, nil 84 | } 85 | 86 | // Save stores the configuration in the current folder according to `credentialsfile` const. 87 | func Save(cfg Config) error { 88 | payload, err := json.Marshal(cfg) 89 | if err != nil { 90 | return errors.Wrap(err, "could not serialize config") 91 | } 92 | 93 | fmt.Println("Storing credentials in current directory as " + credentialsfile) 94 | passphrase, err := readline.Password("passphrase: ") 95 | if err != nil { 96 | return errors.Wrap(err, "could not read passphrase from stdin") 97 | } 98 | 99 | // 100 | // Key derivation of passphrase 101 | 102 | salt, err := sargon2.GenerateRandomBytes(saltKeyLength) 103 | if err != nil { 104 | return errors.Wrap(err, "could not generate salt for credentials") 105 | } 106 | hash := argon2.IDKey(passphrase, salt, 3, 64<<10, 2, 32) 107 | 108 | // 109 | // Seal config 110 | 111 | aead, err := chacha20poly1305.NewX(hash) 112 | if err != nil { 113 | return errors.Wrap(err, "could not create AEAD") 114 | } 115 | nonce, err := sargon2.GenerateRandomBytes(uint32(aead.NonceSize())) 116 | if err != nil { 117 | return errors.Wrap(err, "could not generate nonce for credentials") 118 | } 119 | 120 | ciphertext := aead.Seal(nil, nonce, payload, nil) 121 | ciphertext = append(nonce, ciphertext...) 122 | ciphertext = append(salt, ciphertext...) 123 | 124 | f, err := os.Create(credentialsfile) 125 | if err != nil { 126 | return errors.Wrapf(err, "could not create %s", credentialsfile) 127 | } 128 | defer f.Close() 129 | 130 | _, err = f.Write(ciphertext) 131 | if err != nil { 132 | return errors.Wrap(err, "could not store credentials") 133 | } 134 | 135 | return errors.Wrap(f.Sync(), "could not store credentials") 136 | } 137 | 138 | // Refresh refreshes the session if needed. 139 | func Refresh(client libsf.Client, cfg *Config) error { 140 | if !cfg.Session.AccessExpiredAt(time.Now().Add(time.Hour)) { 141 | return nil 142 | } 143 | 144 | fmt.Println("Refreshing the session") 145 | 146 | session, err := client.RefreshSession(cfg.Session.AccessToken, cfg.Session.RefreshToken) 147 | if err != nil { 148 | return errors.Wrap(err, "could not refresh session") 149 | } 150 | cfg.Session = *session 151 | client.SetSession(cfg.Session) 152 | 153 | err = Save(*cfg) 154 | return errors.Wrap(err, "could not save refreshed session") 155 | } 156 | -------------------------------------------------------------------------------- /internal/client/login.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/chzyer/readline" 5 | "github.com/mdouchement/standardfile/pkg/libsf" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // Login connects to a StandardFile server. 10 | func Login() error { 11 | cfg := Config{} 12 | 13 | endpoint, err := readline.Line("Endpoint: ") 14 | if err != nil { 15 | return errors.Wrap(err, "could not read endpoint from stdin") 16 | } 17 | cfg.Endpoint = endpoint 18 | 19 | client, err := libsf.NewDefaultClient(cfg.Endpoint) 20 | if err != nil { 21 | return errors.Wrap(err, "could not reach given endpoint") 22 | } 23 | 24 | cfg.Email, err = readline.Line("Email: ") 25 | if err != nil { 26 | return errors.Wrap(err, "could not read email from stdin") 27 | } 28 | 29 | auth, err := client.GetAuthParams(cfg.Email) 30 | if err != nil { 31 | return errors.Wrap(err, "could not get auth params") 32 | } 33 | if err = auth.IntegrityCheck(); err != nil { 34 | return errors.Wrap(err, "invalid auth params") 35 | } 36 | 37 | password, err := readline.Password("Password: ") 38 | if err != nil { 39 | return errors.Wrap(err, "could not read password from stdin") 40 | } 41 | 42 | cfg.KeyChain = *auth.SymmetricKeyPair(string(password)) 43 | 44 | err = client.Login(auth.Email(), cfg.KeyChain.Password) 45 | if err != nil { 46 | return errors.Wrap(err, "could not login") 47 | } 48 | cfg.BearerToken = client.BearerToken() // JWT or access token 49 | cfg.Session = client.Session() // Can be empty if a JWT is used 50 | 51 | cfg.KeyChain.Password = "" // Bearer is used instead 52 | return Save(cfg) 53 | } 54 | -------------------------------------------------------------------------------- /internal/client/logout.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/mdouchement/standardfile/pkg/libsf" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | // Logout disconnects from a StandardFile server. 9 | func Logout() error { 10 | cfg, err := Load() 11 | if err != nil { 12 | return errors.Wrap(err, "could not load config") 13 | } 14 | 15 | // 16 | // 17 | 18 | client, err := libsf.NewDefaultClient(cfg.Endpoint) 19 | if err != nil { 20 | return errors.Wrap(err, "could not reach StandardFile endpoint") 21 | } 22 | 23 | if !cfg.Session.Defined() { 24 | return errors.New("could not logout because session is not defined") 25 | } 26 | client.SetSession(cfg.Session) 27 | 28 | // 29 | // 30 | 31 | if err = client.Logout(); err != nil { 32 | return errors.Wrap(err, "could not logout") 33 | } 34 | 35 | return errors.Wrap(Remove(), "could not remive credential file") 36 | } 37 | -------------------------------------------------------------------------------- /internal/client/note.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "sync" 7 | "time" 8 | 9 | "github.com/mdouchement/standardfile/internal/client/tui" 10 | "github.com/mdouchement/standardfile/pkg/libsf" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Note runs the text-based StandardNotes application. 15 | func Note() error { 16 | defer func() { 17 | if r := recover(); r != nil { 18 | var err error 19 | switch r := r.(type) { 20 | case error: 21 | err = r 22 | default: 23 | err = fmt.Errorf("%v", r) 24 | } 25 | stack := make([]byte, 4<<10) 26 | length := runtime.Stack(stack, true) 27 | 28 | tui.NewLogger().Printf("[PANIC RECOVER] %s %s\n", err, stack[:length]) 29 | } 30 | }() 31 | 32 | cfg, err := Load() 33 | if err != nil { 34 | return errors.Wrap(err, "could not load config") 35 | } 36 | 37 | // 38 | // 39 | 40 | client, err := libsf.NewDefaultClient(cfg.Endpoint) 41 | if err != nil { 42 | return errors.Wrap(err, "could not reach StandardFile endpoint") 43 | } 44 | client.SetBearerToken(cfg.BearerToken) 45 | if cfg.Session.Defined() { 46 | client.SetSession(cfg.Session) 47 | if err = Refresh(client, &cfg); err != nil { 48 | return err 49 | } 50 | } 51 | 52 | // 53 | // 54 | ui, err := tui.New() 55 | if err != nil { 56 | return err 57 | } 58 | defer ui.Cleanup() 59 | 60 | // No sync_token and limit are setted so we get all items. 61 | items := libsf.NewSyncItems() 62 | items, err = client.SyncItems(items) 63 | if err != nil { 64 | return errors.Wrap(err, "could not get items") 65 | } 66 | 67 | synchronizer := initSynchronizer(client, cfg, ui) 68 | 69 | // Append `SN|ItemsKey` to the KeyChain. 70 | for _, item := range items.Retrieved { 71 | if item.ContentType != libsf.ContentTypeItemsKey { 72 | continue 73 | } 74 | 75 | err := item.Unseal(&cfg.KeyChain) 76 | if err != nil { 77 | return errors.Wrap(err, "could not unseal item SN|ItemsKey") 78 | } 79 | } 80 | 81 | for _, item := range items.Retrieved { 82 | switch item.ContentType { 83 | case libsf.ContentTypeUserPreferences: 84 | err := item.Unseal(&cfg.KeyChain) 85 | if err != nil { 86 | return errors.Wrap(err, "could not unseal item SN|UserPreferences") 87 | } 88 | 89 | if err = item.Note.ParseRaw(); err != nil { 90 | return errors.Wrap(err, "could not parse note metadata") 91 | } 92 | 93 | ui.SortBy = item.Note.GetSortingField() 94 | case libsf.ContentTypeNote: 95 | err := item.Unseal(&cfg.KeyChain) 96 | if err != nil { 97 | return errors.Wrap(err, "could not unseal item Note") 98 | } 99 | 100 | if err = item.Note.ParseRaw(); err != nil { 101 | return errors.Wrap(err, "could not parse note metadata") 102 | } 103 | 104 | ui.Register(tui.NewItem(item, synchronizer)) 105 | } 106 | } 107 | ui.SortItems() 108 | 109 | ui.Run() 110 | return nil 111 | } 112 | 113 | func initSynchronizer(client libsf.Client, cfg Config, ui *tui.TUI) func(item *libsf.Item) *time.Time { 114 | var mu sync.Mutex 115 | 116 | return func(item *libsf.Item) *time.Time { 117 | mu.Lock() 118 | defer mu.Unlock() 119 | 120 | item.Note.SetUpdatedAtNow() 121 | item.Note.SaveRaw() 122 | 123 | err := item.Seal(&cfg.KeyChain) 124 | if err != nil { 125 | ui.DisplayStatus(errors.Wrap(err, "could not seal item").Error()) 126 | return item.UpdatedAt 127 | } 128 | 129 | items := libsf.NewSyncItems() 130 | items.Items = append(items.Items, item) 131 | items, err = client.SyncItems(items) 132 | if err != nil { 133 | ui.DisplayStatus(errors.Wrap(err, "could not get items").Error()) 134 | return item.UpdatedAt 135 | } 136 | if len(items.Conflicts) > 0 { 137 | // Won't be addressed until we want several clients to run on the same account. 138 | // The list refreshing is done by restarting the application. 139 | panic("TODO: update the item proprely (item conflicts)") 140 | } 141 | ui.DisplayStatus("saved") 142 | ui.SortItems() // Based on local updates. No resync with the remote server is done (single client usage) 143 | 144 | return items.Saved[0].UpdatedAt 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/client/tui/item.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bep/debounce" 7 | "github.com/gcla/gowid" 8 | "github.com/gcla/gowid/gwutil" 9 | "github.com/gcla/gowid/widgets/columns" 10 | "github.com/gcla/gowid/widgets/edit" 11 | "github.com/gcla/gowid/widgets/selectable" 12 | "github.com/gcla/gowid/widgets/styled" 13 | "github.com/gcla/gowid/widgets/text" 14 | "github.com/gcla/gowid/widgets/vscroll" 15 | "github.com/gdamore/tcell/v2" 16 | "github.com/mdouchement/standardfile/pkg/libsf" 17 | ) 18 | 19 | // An Item is the graphical representation of an libsf.Item. 20 | type Item struct { 21 | ID string 22 | presentation gowid.IWidget 23 | abstraction *libsf.Item 24 | editorPresentation *ItemEditor 25 | } 26 | 27 | // NewItem returns a new Item. 28 | func NewItem(item *libsf.Item, sync func(item *libsf.Item) *time.Time) *Item { 29 | editor := edit.New(edit.Options{Text: item.Note.Text}) 30 | debounced := debounce.New(500 * time.Millisecond) 31 | editor.OnTextSet(gowid.WidgetCallback{Name: "cb", WidgetChangedFunction: func(app gowid.IApp, iw gowid.IWidget) { 32 | debounced(func() { 33 | item.Note.Text = editor.Text() 34 | item.UpdatedAt = sync(item) 35 | }) 36 | }}) 37 | 38 | return &Item{ 39 | ID: item.ID, 40 | presentation: selectable.New( 41 | styled.NewExt( 42 | text.New(item.Note.Title), 43 | gowid.MakePaletteRef("normal"), gowid.MakePaletteRef("focused"), 44 | ), 45 | ), 46 | editorPresentation: newItemEditor(editor), 47 | abstraction: item, 48 | } 49 | } 50 | 51 | // Title returns the name of the editor. 52 | func (w *Item) Title() string { 53 | return w.abstraction.Note.Title 54 | } 55 | 56 | // Editor returns the ItemContent of the Item. 57 | func (w *Item) Editor() *ItemEditor { 58 | return w.editorPresentation 59 | } 60 | 61 | //////////////////// 62 | // // 63 | // Delegates // 64 | // // 65 | //////////////////// 66 | 67 | // Render implements gowid.IWidget 68 | func (w *Item) Render(size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) gowid.ICanvas { 69 | return w.presentation.Render(size, focus, app) 70 | } 71 | 72 | // RenderSize implements gowid.IWidget 73 | func (w *Item) RenderSize(size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) gowid.IRenderBox { 74 | return w.presentation.RenderSize(size, focus, app) 75 | } 76 | 77 | // UserInput implements gowid.IWidget 78 | func (w *Item) UserInput(ev any, size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) bool { 79 | return w.presentation.UserInput(ev, size, focus, app) 80 | } 81 | 82 | // Selectable implements gowid.IWidget 83 | func (w *Item) Selectable() bool { 84 | return w.presentation.Selectable() 85 | } 86 | 87 | // 88 | // 89 | // 90 | // 91 | // 92 | // 93 | // 94 | // 95 | // 96 | // 97 | // 98 | // 99 | 100 | // An ItemEditor is the graphical representation of editable libsf.Item text. 101 | type ItemEditor struct { 102 | *columns.Widget 103 | e *edit.Widget 104 | sb *vscroll.Widget 105 | goUpDown int // positive means down 106 | pgUpDown int // positive means down 107 | } 108 | 109 | func newItemEditor(e *edit.Widget) *ItemEditor { 110 | sb := vscroll.NewExt(vscroll.VerticalScrollbarUnicodeRunes) 111 | ie := &ItemEditor{ 112 | Widget: columns.New([]gowid.IContainerWidget{ 113 | &gowid.ContainerWidget{IWidget: e, D: gowid.RenderWithWeight{W: 1}}, 114 | &gowid.ContainerWidget{IWidget: sb, D: gowid.RenderWithUnits{U: 1}}, 115 | }), 116 | e: e, 117 | sb: sb, 118 | goUpDown: 0, 119 | pgUpDown: 0, 120 | } 121 | sb.OnClickAbove(gowid.WidgetCallback{Name: "cb", WidgetChangedFunction: ie.clickUp}) 122 | sb.OnClickBelow(gowid.WidgetCallback{Name: "cb", WidgetChangedFunction: ie.clickDown}) 123 | sb.OnClickUpArrow(gowid.WidgetCallback{Name: "cb", WidgetChangedFunction: ie.clickUpArrow}) 124 | sb.OnClickDownArrow(gowid.WidgetCallback{Name: "cb", WidgetChangedFunction: ie.clickDownArrow}) 125 | return ie 126 | } 127 | 128 | func (w *ItemEditor) clickUp(app gowid.IApp, iw gowid.IWidget) { 129 | w.pgUpDown-- 130 | } 131 | 132 | func (w *ItemEditor) clickDown(app gowid.IApp, iw gowid.IWidget) { 133 | w.pgUpDown++ 134 | } 135 | 136 | func (w *ItemEditor) clickUpArrow(app gowid.IApp, iw gowid.IWidget) { 137 | w.goUpDown-- 138 | } 139 | 140 | func (w *ItemEditor) clickDownArrow(app gowid.IApp, iw gowid.IWidget) { 141 | w.goUpDown++ 142 | } 143 | 144 | // UserInput implements gowid.IWidget 145 | func (w *ItemEditor) UserInput(ev any, size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) bool { 146 | box, _ := size.(gowid.IRenderBox) 147 | w.sb.Top, w.sb.Middle, w.sb.Bottom = w.e.CalculateTopMiddleBottom(gowid.MakeRenderBox(box.BoxColumns()-1, box.BoxRows())) 148 | 149 | // Remap events 150 | if k, ok := ev.(*tcell.EventKey); ok { 151 | switch k.Key() { 152 | case tcell.KeyHome: 153 | ev = tcell.NewEventKey(tcell.KeyCtrlA, ' ', tcell.ModNone) // Start of line defined by edit widget 154 | case tcell.KeyEnd: 155 | ev = tcell.NewEventKey(tcell.KeyCtrlE, ' ', tcell.ModNone) // End of line defined by edit widget 156 | } 157 | } 158 | 159 | handled := w.Widget.UserInput(ev, size, focus, app) 160 | if handled { 161 | w.Widget.SetFocus(app, 0) 162 | } 163 | 164 | return handled 165 | } 166 | 167 | // Render implements gowid.IWidget 168 | func (w *ItemEditor) Render(size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) gowid.ICanvas { 169 | box, _ := size.(gowid.IRenderBox) 170 | ecols := box.BoxColumns() - 1 171 | ebox := gowid.MakeRenderBox(ecols, box.BoxRows()) 172 | if w.goUpDown != 0 || w.pgUpDown != 0 { 173 | w.e.SetLinesFromTop(gwutil.Max(0, w.e.LinesFromTop()+w.goUpDown+(w.pgUpDown*box.BoxRows())), app) 174 | txt := w.e.MakeText() 175 | layout := text.MakeTextLayout(txt.Content(), ecols, txt.Wrap(), gowid.HAlignLeft{}) 176 | _, y := text.GetCoordsFromCursorPos(w.e.CursorPos(), ecols, layout, w.e) 177 | if y < w.e.LinesFromTop() { 178 | for i := y; i < w.e.LinesFromTop(); i++ { 179 | w.e.DownLines(ebox, false, app) 180 | } 181 | } else if y >= w.e.LinesFromTop()+box.BoxRows() { 182 | for i := w.e.LinesFromTop() + box.BoxRows(); i <= y; i++ { 183 | w.e.UpLines(ebox, false, app) 184 | } 185 | } 186 | } 187 | w.goUpDown = 0 188 | w.pgUpDown = 0 189 | w.sb.Top, w.sb.Middle, w.sb.Bottom = w.e.CalculateTopMiddleBottom(ebox) 190 | 191 | return w.Widget.Render(size, focus, app) 192 | } 193 | -------------------------------------------------------------------------------- /internal/client/tui/logger.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/sanity-io/litter" 12 | "github.com/sirupsen/logrus" 13 | "gopkg.in/natefinch/lumberjack.v2" 14 | ) 15 | 16 | // nolint:deadcode,unused 17 | func debug(v any, verbose ...bool) { 18 | if len(verbose) > 0 && verbose[0] { 19 | NewLogger().Println(litter.Sdump(v)) 20 | return 21 | } 22 | NewLogger().Println(v) 23 | } 24 | 25 | // NewLogger returns a new well configured logger. 26 | func NewLogger() logrus.StdLogger { 27 | formatter := new(logFormatter) 28 | 29 | log := logrus.New() 30 | log.SetOutput(io.Discard) // stdout & stderr to /dev/null 31 | log.SetFormatter(formatter) 32 | log.Hooks.Add(&fileHook{ 33 | rotate: &lumberjack.Logger{ 34 | Filename: "sfc.log", 35 | MaxSize: 20, // megabytes 36 | MaxBackups: 2, 37 | MaxAge: 10, //days 38 | }, 39 | formatter: formatter, 40 | }) 41 | 42 | return log 43 | } 44 | 45 | //////////////////// 46 | // // 47 | // File hook // 48 | // // 49 | //////////////////// 50 | 51 | type fileHook struct { 52 | sync.Mutex 53 | rotate *lumberjack.Logger 54 | formatter logrus.Formatter 55 | } 56 | 57 | // Fire opens the file, writes to the file and closes the file. 58 | // Whichever user is running the function needs write permissions to the file or directory if the file does not yet exist. 59 | func (hook *fileHook) Fire(entry *logrus.Entry) error { 60 | hook.Lock() 61 | defer hook.Unlock() 62 | 63 | // use our formatter instead of entry.String() 64 | msg, err := hook.formatter.Format(entry) 65 | if err != nil { 66 | log.Println("failed to generate string for entry:", err) 67 | return err 68 | } 69 | 70 | _, err = hook.rotate.Write(msg) 71 | return err 72 | } 73 | 74 | // Levels returns configured log levels. 75 | func (hook *fileHook) Levels() []logrus.Level { 76 | return logrus.AllLevels 77 | } 78 | 79 | //////////////////// 80 | // // 81 | // Log formatter // 82 | // // 83 | //////////////////// 84 | 85 | type logFormatter struct{} 86 | 87 | // Format implements Logrus formatter. 88 | func (f *logFormatter) Format(entry *logrus.Entry) ([]byte, error) { 89 | fields := "" 90 | if len(entry.Data) > 0 { 91 | fs := []string{} 92 | for k, v := range entry.Data { 93 | fs = append(fs, fmt.Sprintf("%s=%v", k, v)) 94 | } 95 | fields = fmt.Sprintf(" (%s)", strings.Join(fs, ", ")) 96 | } 97 | 98 | data := fmt.Sprintf("[%s] %+5s: %s%s\n", 99 | time.Now().Format(time.RFC3339), 100 | strings.ToUpper(entry.Level.String()), 101 | entry.Message, 102 | fields, 103 | ) 104 | return []byte(data), nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/client/tui/note_list.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/gcla/gowid" 8 | "github.com/gcla/gowid/widgets/list" 9 | "github.com/gdamore/tcell/v2" 10 | ) 11 | 12 | // A NoteList is a list of Items to interract with. 13 | // It implements gowid.IWidget by delegating to its presentation. 14 | type NoteList struct { 15 | ui *TUI 16 | presentation list.IWidget 17 | abstraction *noteListAbstraction 18 | } 19 | 20 | // NewNoteList returns a new NoteList. 21 | func NewNoteList(ui *TUI) *NoteList { 22 | abs := newNoteListAbstraction() 23 | 24 | return &NoteList{ 25 | ui: ui, 26 | presentation: list.New(abs), 27 | abstraction: abs, 28 | } 29 | } 30 | 31 | // Register registers an item to this list. 32 | func (w *NoteList) Register(i *Item) { 33 | n := w.abstraction.Add(i) 34 | if n == 1 { 35 | w.hackToDisplayFirstNote() 36 | } 37 | } 38 | 39 | // Sort orders items by the given field. 40 | func (w *NoteList) Sort(field string) { 41 | if !w.abstraction.Sort(field) { 42 | msg := "No sort as been applied" 43 | if len(field) > 0 { 44 | msg = fmt.Sprintf("Failed to sort on %s, fallback on the item's name", field) 45 | } 46 | 47 | w.ui.DisplayStatus(msg) 48 | return 49 | } 50 | 51 | if w.abstraction.Length() > 0 { 52 | w.hackToDisplayFirstNote() 53 | } 54 | } 55 | 56 | // Hack to display first note content. 57 | func (w *NoteList) hackToDisplayFirstNote() { 58 | w.ui.editor.SetTitle(w.abstraction.ItemAt(0).Title(), w.ui.App) 59 | w.ui.editor.SetSubWidget(w.abstraction.ItemAt(0).Editor(), w.ui.App) 60 | } 61 | 62 | //////////////////// 63 | // // 64 | // Delegates // 65 | // // 66 | //////////////////// 67 | 68 | // Render implements gowid.IWidget 69 | func (w *NoteList) Render(size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) gowid.ICanvas { 70 | return w.presentation.Render(size, focus, app) 71 | } 72 | 73 | // RenderSize implements gowid.IWidget 74 | func (w *NoteList) RenderSize(size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) gowid.IRenderBox { 75 | return w.presentation.RenderSize(size, focus, app) 76 | } 77 | 78 | // UserInput implements gowid.IWidget 79 | func (w *NoteList) UserInput(ev any, size gowid.IRenderSize, focus gowid.Selector, app gowid.IApp) bool { 80 | ok := w.presentation.UserInput(ev, size, focus, app) 81 | 82 | if evm, ok := ev.(*tcell.EventMouse); !ok || evm.Buttons() != tcell.ButtonNone { 83 | // Avoid next action on mouse hover event 84 | if item, ok := w.abstraction.At(w.abstraction.Focus()).(*Item); ok { 85 | // Set editor name 86 | w.ui.editor.SetTitle(item.Title(), app) 87 | // Display the text to edit 88 | w.ui.editor.SetSubWidget(item.Editor(), app) 89 | } 90 | } 91 | return ok 92 | } 93 | 94 | // Selectable implements gowid.IWidget 95 | func (w *NoteList) Selectable() bool { 96 | return w.presentation.Selectable() 97 | } 98 | 99 | //////////////////// 100 | // // 101 | // Abstraction // 102 | // // 103 | //////////////////// 104 | 105 | // A noteListAbstraction is a list of Items to interract with. 106 | // It implements list.IWalker interface. 107 | type noteListAbstraction struct { 108 | widgets []*Item 109 | registred map[string]bool 110 | focus list.ListPos 111 | } 112 | 113 | func newNoteListAbstraction() *noteListAbstraction { 114 | return ¬eListAbstraction{ 115 | widgets: make([]*Item, 0), 116 | registred: make(map[string]bool, 0), 117 | focus: 0, 118 | } 119 | } 120 | 121 | func (w *noteListAbstraction) Add(item *Item) int { 122 | if w.registred[item.ID] { 123 | // Won't be addressed until we want several clients to run on the same account. 124 | // The list refreshing is done by restarting the application. 125 | panic("TODO: update the item proprely") 126 | } 127 | 128 | w.widgets = append(w.widgets, item) 129 | w.registred[item.ID] = true 130 | return len(w.widgets) 131 | } 132 | 133 | func (w *noteListAbstraction) Sort(field string) bool { 134 | switch field { 135 | case "name": 136 | sort.Slice(w.widgets, func(i, j int) bool { 137 | return w.widgets[i].abstraction.Note.Title < w.widgets[j].abstraction.Note.Title 138 | }) 139 | case "client_updated_at": 140 | sort.Slice(w.widgets, func(i, j int) bool { 141 | return w.widgets[i].abstraction.Note.UpdatedAt().After(w.widgets[j].abstraction.Note.UpdatedAt()) 142 | }) 143 | default: 144 | return false 145 | } 146 | return true 147 | } 148 | 149 | func (w *noteListAbstraction) ItemAt(i int) *Item { 150 | return w.widgets[i] 151 | } 152 | 153 | func (w *noteListAbstraction) First() list.IWalkerPosition { 154 | if len(w.widgets) == 0 { 155 | return nil 156 | } 157 | return list.ListPos(0) 158 | } 159 | 160 | func (w *noteListAbstraction) Last() list.IWalkerPosition { 161 | if len(w.widgets) == 0 { 162 | return nil 163 | } 164 | return list.ListPos(len(w.widgets) - 1) 165 | } 166 | 167 | func (w *noteListAbstraction) Length() int { 168 | return len(w.widgets) 169 | } 170 | 171 | func (w *noteListAbstraction) At(pos list.IWalkerPosition) gowid.IWidget { 172 | var res gowid.IWidget 173 | ipos := int(pos.(list.ListPos)) 174 | if ipos >= 0 && ipos < w.Length() { 175 | res = w.widgets[ipos] 176 | } 177 | return res 178 | } 179 | 180 | func (w *noteListAbstraction) Focus() list.IWalkerPosition { 181 | return w.focus 182 | } 183 | 184 | func (w *noteListAbstraction) SetFocus(focus list.IWalkerPosition, app gowid.IApp) { 185 | w.focus = focus.(list.ListPos) 186 | } 187 | 188 | func (w *noteListAbstraction) Next(ipos list.IWalkerPosition) list.IWalkerPosition { 189 | pos := ipos.(list.ListPos) 190 | if int(pos) == w.Length()-1 { 191 | return list.ListPos(-1) 192 | } 193 | return pos + 1 194 | } 195 | 196 | func (w *noteListAbstraction) Previous(ipos list.IWalkerPosition) list.IWalkerPosition { 197 | pos := ipos.(list.ListPos) 198 | if pos-1 == -1 { 199 | return list.ListPos(-1) 200 | } 201 | return pos - 1 202 | } 203 | -------------------------------------------------------------------------------- /internal/client/tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gcla/gowid" 7 | "github.com/gcla/gowid/widgets/columns" 8 | "github.com/gcla/gowid/widgets/framed" 9 | "github.com/gcla/gowid/widgets/null" 10 | "github.com/gcla/gowid/widgets/pile" 11 | "github.com/gcla/gowid/widgets/styled" 12 | "github.com/gcla/gowid/widgets/text" 13 | "github.com/gdamore/tcell/v2" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | // A TUI is a text-based interface. 18 | type TUI struct { 19 | App *gowid.App 20 | SortBy string 21 | list *NoteList 22 | editor *framed.Widget 23 | status *text.Widget 24 | } 25 | 26 | // New returns a new TUI. 27 | func New() (*TUI, error) { 28 | ui := new(TUI) 29 | ui.SortBy = "name" 30 | 31 | app, err := gowid.NewApp(layout(ui)) 32 | if err != nil { 33 | return ui, errors.Wrap(err, "could not create application widgets") 34 | } 35 | 36 | ui.App = app 37 | return ui, nil 38 | } 39 | 40 | // Run starts the application and thus the event loop. 41 | func (ui *TUI) Run() { 42 | ui.App.MainLoop(gowid.UnhandledInputFunc(ui.unhandled)) 43 | } 44 | 45 | // Cleanup cleans the application properly (in case of panic). 46 | func (ui *TUI) Cleanup() { 47 | ui.App.GetScreen().Fini() // Cleanup tcell screen's objects 48 | } 49 | 50 | // Register registers an item. 51 | func (ui *TUI) Register(i *Item) { 52 | ui.list.Register(i) 53 | } 54 | 55 | // SortItems sorts the items on ui.SortBy. 56 | func (ui *TUI) SortItems() { 57 | ui.list.Sort(ui.SortBy) 58 | } 59 | 60 | // DisplayStatus displays a message in the status bar (aka notifications). 61 | func (ui *TUI) DisplayStatus(message string) { 62 | ui.App.Run(gowid.RunFunction(func(app gowid.IApp) { // nolint:errcheck 63 | ui.status.SetText(message, ui.App) 64 | })) 65 | go func() { 66 | timer := time.NewTimer(1200 * time.Millisecond) 67 | <-timer.C 68 | ui.App.Run(gowid.RunFunction(func(app gowid.IApp) { // nolint:errcheck 69 | ui.status.SetText("", ui.App) 70 | })) 71 | }() 72 | } 73 | 74 | //////////////////// 75 | // // 76 | // Layout // 77 | // // 78 | //////////////////// 79 | 80 | func layout(ui *TUI) gowid.AppArgs { 81 | ui.list = NewNoteList(ui) 82 | ui.editor = framed.NewUnicode(null.New()) 83 | ui.status = text.New("") 84 | 85 | notePane := columns.New([]gowid.IContainerWidget{ 86 | &gowid.ContainerWidget{ 87 | IWidget: styled.New(framed.NewUnicode(ui.list), gowid.MakePaletteRef("mainpane")), 88 | D: gowid.RenderWithWeight{W: 1}, 89 | }, 90 | &gowid.ContainerWidget{ 91 | IWidget: styled.New(ui.editor, gowid.MakePaletteRef("mainpane")), 92 | D: gowid.RenderWithWeight{W: 8}, 93 | }, 94 | }) 95 | 96 | main := pile.New([]gowid.IContainerWidget{ 97 | &gowid.ContainerWidget{IWidget: notePane, D: gowid.RenderWithWeight{W: 20}}, 98 | &gowid.ContainerWidget{ 99 | IWidget: styled.New(framed.NewUnicode(ui.status), gowid.MakePaletteRef("mainpane")), 100 | D: gowid.RenderWithWeight{W: 2}, 101 | }, 102 | }) 103 | 104 | return gowid.AppArgs{ 105 | View: main, 106 | Palette: &gowid.Palette{ 107 | "mainpane": gowid.MakePaletteEntry(gowid.ColorLightGray, gowid.ColorBlack), 108 | // List style 109 | "normal": gowid.MakePaletteEntry(gowid.ColorLightGray, gowid.ColorBlack), 110 | "focused": gowid.MakePaletteEntry(gowid.ColorBlack, gowid.ColorRed), 111 | }, 112 | Log: NewLogger(), 113 | } 114 | } 115 | 116 | //////////////////// 117 | // // 118 | // Events // 119 | // // 120 | //////////////////// 121 | 122 | func (ui *TUI) unhandled(app gowid.IApp, ev any) bool { 123 | evk, ok := ev.(*tcell.EventKey) 124 | if !ok { 125 | return false 126 | } 127 | 128 | handled := false 129 | 130 | switch evk.Key() { 131 | case tcell.KeyCtrlQ: 132 | handled = true 133 | app.Quit() 134 | } 135 | 136 | return handled 137 | } 138 | -------------------------------------------------------------------------------- /internal/client/unseal.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "strings" 7 | 8 | "github.com/mdouchement/standardfile/pkg/libsf" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // Unseal decrypt your backuped notes. 13 | func Unseal(filename string) error { 14 | cfg, err := Load() 15 | if err != nil { 16 | return errors.Wrap(err, "could not load config") 17 | } 18 | 19 | // 20 | // 21 | 22 | data, err := os.ReadFile(filename) 23 | if err != nil { 24 | return errors.Wrap(err, "could not load file") 25 | } 26 | 27 | sync := libsf.NewSyncItems() 28 | if err = json.Unmarshal(data, &sync.Retrieved); err != nil { 29 | return errors.Wrap(err, "could not parse backuped notes") 30 | } 31 | 32 | // 33 | // 34 | 35 | var notes []libsf.Note 36 | 37 | for _, rt := range sync.Retrieved { 38 | if rt.ContentType != libsf.ContentTypeNote { 39 | continue 40 | } 41 | 42 | err = rt.Unseal(&cfg.KeyChain) 43 | if err != nil { 44 | return errors.Wrap(err, "could not unseal item") 45 | } 46 | 47 | notes = append(notes, *rt.Note) 48 | } 49 | 50 | err = backup(notes, strings.Replace(filename, "items_", "notes_", 1)) 51 | return errors.Wrap(err, "notes") 52 | } 53 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mdouchement/standardfile/internal/model" 7 | ) 8 | 9 | type ( 10 | // A Client can interacts with the database. 11 | Client interface { 12 | // Save inserts or updates the entry in database with the given model. 13 | Save(m model.Model) error 14 | // Delete deletes the entry in database with the given model. 15 | Delete(m model.Model) error 16 | // Close the database. 17 | Close() error 18 | // IsNotFound returns true if err is a not found error. 19 | IsNotFound(err error) bool 20 | // IsAlreadyExists returns true if err is a not found error. 21 | IsAlreadyExists(err error) bool 22 | 23 | UserInteraction 24 | SessionInteraction 25 | ItemInteraction 26 | PKCEInteraction 27 | } 28 | 29 | // An UserInteraction defines all the methods used to interact with a user record. 30 | UserInteraction interface { 31 | // FindUser returns the user for the given id (UUID). 32 | FindUser(id string) (*model.User, error) 33 | // FindUserByMail returns the user for the given email. 34 | FindUserByMail(email string) (*model.User, error) 35 | } 36 | 37 | // An SessionInteraction defines all the methods used to interact with a session record. 38 | SessionInteraction interface { 39 | // FindSession returns the session for the given id (UUID). 40 | FindSession(id string) (*model.Session, error) 41 | // FindSessionsByUserID returns all sessions for the given id and user id. 42 | FindSessionByUserID(id, userID string) (*model.Session, error) 43 | // FindActiveSessionsByUserID returns all active sessions for the given user id. 44 | FindActiveSessionsByUserID(userID string) ([]*model.Session, error) 45 | // FindSessionsByUserID returns all sessions for the given user id. 46 | FindSessionsByUserID(userID string) ([]*model.Session, error) 47 | // FindSessionByAccessToken returns the session for the given id and access token. 48 | FindSessionByAccessToken(id, token string) (*model.Session, error) 49 | // FindSessionByTokens returns the session for the given id, access and refresh token. 50 | FindSessionByTokens(id, access, refresh string) (*model.Session, error) 51 | } 52 | 53 | // An ItemInteraction defines all the methods used to interact with a item record(s). 54 | ItemInteraction interface { 55 | // FindItem returns the item for the given id (UUID). 56 | FindItem(id string) (*model.Item, error) 57 | // FindItemByUserID returns the item for the given id and user id (UUID). 58 | FindItemByUserID(id, userID string) (*model.Item, error) 59 | // FindItemsByParams returns all the matching records for the given parameters. 60 | // It also returns a boolean to true if there is more items than the given limit. 61 | // limit equals to 0 means all items. 62 | FindItemsByParams(userID, contentType string, updated time.Time, strictTime, filterDeleted bool, limit int) ([]*model.Item, bool, error) 63 | // FindItemsForIntegrityCheck returns valid items for computing data signature forthe given user. 64 | FindItemsForIntegrityCheck(userID string) ([]*model.Item, error) 65 | // DeleteItem deletes the item matching the given parameters. 66 | DeleteItem(id, userID string) error 67 | } 68 | 69 | // A PKCEInteraction defines all the methods used to interact with PKCE mechanism. 70 | PKCEInteraction interface { 71 | // FindPKCE returns the item for the given code. 72 | FindPKCE(codeChallenge string) (*model.PKCE, error) 73 | // RemovePKCE removes from database the given challenge code. 74 | RemovePKCE(codeChallenge string) error 75 | // RevokeExpiredChallenges removes from database all old challenge codes. 76 | RevokeExpiredChallenges() error 77 | } 78 | ) 79 | -------------------------------------------------------------------------------- /internal/model/base.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ( 8 | // A Model defines an object that can be stored in database. 9 | Model interface { 10 | // GetID returns the model's ID. 11 | GetID() string 12 | // SetID defines the model's ID. 13 | SetID(string) 14 | // GetCreatedAt returns the model's creation date. 15 | GetCreatedAt() *time.Time 16 | // SetCreatedAt defines the model's creation date. 17 | SetCreatedAt(time.Time) 18 | // GetUpdatedAt returns the model's last update date. 19 | GetUpdatedAt() *time.Time 20 | // SetUpdatedAt defines the model's last update date. 21 | SetUpdatedAt(time.Time) 22 | } 23 | 24 | // A Base contains the default model fields. 25 | Base struct { 26 | ID string `json:"uuid" msgpack:"id" storm:"id"` 27 | CreatedAt *time.Time `json:"created_at" msgpack:"created_at" storm:"index"` 28 | UpdatedAt *time.Time `json:"updated_at" msgpack:"updated_at" storm:"index"` 29 | } 30 | ) 31 | 32 | // GetID returns the model's ID. 33 | func (m *Base) GetID() string { 34 | return m.ID 35 | } 36 | 37 | // SetID defines the model's ID. 38 | func (m *Base) SetID(id string) { 39 | m.ID = id 40 | } 41 | 42 | // GetCreatedAt returns the model's creation date. 43 | func (m *Base) GetCreatedAt() *time.Time { 44 | return m.CreatedAt 45 | } 46 | 47 | // SetCreatedAt defines the model's creation date. 48 | func (m *Base) SetCreatedAt(t time.Time) { 49 | m.CreatedAt = &t 50 | } 51 | 52 | // GetUpdatedAt returns the model's last update date. 53 | func (m *Base) GetUpdatedAt() *time.Time { 54 | return m.UpdatedAt 55 | } 56 | 57 | // SetUpdatedAt defines the model's last update date. 58 | func (m *Base) SetUpdatedAt(t time.Time) { 59 | m.UpdatedAt = &t 60 | } 61 | -------------------------------------------------------------------------------- /internal/model/item.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // A Item represents a database record and the rendered API response. 4 | type Item struct { 5 | Base `msgpack:",inline" storm:"inline"` 6 | 7 | UserID string `json:"user_uuid" msgpack:"user_id" storm:"index"` 8 | ItemsKeyID string `json:"items_key_id" msgpack:"items_key_id,omitempty"` 9 | Content string `json:"content" msgpack:"content"` 10 | ContentType string `json:"content_type" msgpack:"content_type" storm:"index"` 11 | EncryptedItemKey string `json:"enc_item_key" msgpack:"enc_item_key"` 12 | Deleted bool `json:"deleted" msgpack:"deleted" storm:"index"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/model/pkce.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // A PKCE represents a database record. 8 | type PKCE struct { 9 | Base `msgpack:",inline" storm:"inline"` 10 | 11 | CodeChallenge string `msgpack:"code_challenge" storm:"index"` 12 | ExpireAt time.Time `msgpack:"expire_at"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/model/session.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // A Session represents a database record. 8 | type Session struct { 9 | Base `msgpack:",inline" storm:"inline"` 10 | 11 | ExpireAt time.Time `msgpack:"expire_at"` 12 | UserID string `msgpack:"user_id" storm:"index"` 13 | UserAgent string `msgpack:"user_agent"` 14 | APIVersion string `msgpack:"api_version"` 15 | AccessToken string `msgpack:"access_token" storm:"index"` 16 | RefreshToken string `msgpack:"refresh_token"` 17 | 18 | // Custom fields 19 | Current bool `msgpack:"-"` 20 | } 21 | -------------------------------------------------------------------------------- /internal/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/mdouchement/standardfile/pkg/libsf" 4 | 5 | // A User represents a database record. 6 | type User struct { 7 | Base `msgpack:",inline" storm:"inline"` 8 | 9 | // Standardfile fields 10 | Email string `msgpack:"email" storm:"unique"` 11 | Password string `msgpack:"password,omitempty"` 12 | PasswordCost int `msgpack:"pw_cost,omitempty"` 13 | PasswordNonce string `msgpack:"pw_nonce,omitempty"` 14 | PasswordAuth string `msgpack:"pw_auth,omitempty"` 15 | Version string `msgpack:"version"` 16 | 17 | // V2 flields compatibility 18 | PasswordSalt string `msgpack:"pw_salt,omitempty"` 19 | 20 | // Custom fields 21 | PasswordUpdatedAt int64 `msgpack:"password_updated_at"` 22 | } 23 | 24 | // NewUser returns a new user with default params. 25 | func NewUser() *User { 26 | return &User{ 27 | // https://github.com/standardfile/ruby-server/blob/master/app/controllers/api/auth_controller.rb#L70 28 | // Version3 is provided by client and overrided before inserting record. 29 | Version: libsf.ProtocolVersion2, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/server/export_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/golang-jwt/jwt" 8 | "github.com/mdouchement/standardfile/internal/model" 9 | ) 10 | 11 | // This file is only for test purpose and is only loaded by test framework. 12 | 13 | // CreateJWT returns a JWT token. 14 | func CreateJWT(ctrl Controller, u *model.User) string { 15 | token := jwt.New(jwt.SigningMethodHS256) 16 | claims := token.Claims.(jwt.MapClaims) 17 | claims["user_uuid"] = u.ID 18 | // claims["pw_hash"] = fmt.Sprintf("%x", sha256.Sum256([]byte(u.Password))) // See readme 19 | claims["iss"] = "github.com/mdouchement/standardfile" 20 | claims["iat"] = time.Now().Unix() // Unix Timestamp in seconds 21 | 22 | t, err := token.SignedString(ctrl.SigningKey) 23 | if err != nil { 24 | log.Fatalf("could not generate token: %s", err) 25 | } 26 | return t 27 | } 28 | -------------------------------------------------------------------------------- /internal/server/item_handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/mdouchement/standardfile/internal/database" 8 | "github.com/mdouchement/standardfile/internal/server/service" 9 | "github.com/mdouchement/standardfile/internal/sferror" 10 | ) 11 | 12 | // item contains all item handlers. 13 | type item struct { 14 | db database.Client 15 | } 16 | 17 | ///// Sync 18 | //// 19 | // 20 | 21 | // Sync used for saves local changes as well as retrieves remote changes. 22 | // https://standardfile.org/#post-items-sync 23 | func (h *item) Sync(c echo.Context) error { 24 | // Filter params 25 | var params service.SyncParams 26 | if err := c.Bind(¶ms); err != nil { 27 | return c.JSON(http.StatusBadRequest, sferror.New("Could not get syncing params.")) 28 | } 29 | params.UserAgent = c.Request().UserAgent() 30 | params.Session = currentSession(c) 31 | 32 | sync := service.NewSync(h.db, currentUser(c), params) 33 | if err := sync.Execute(); err != nil { 34 | return err 35 | } 36 | 37 | return c.JSON(http.StatusOK, sync) 38 | } 39 | 40 | ///// Backup 41 | //// 42 | // 43 | 44 | // Backup used for writes all user data to backup extension. 45 | // This is called when a new extension is registered. 46 | func (h *item) Backup(c echo.Context) error { 47 | // In reference implementation, there is post_to_extension but not implemented here. 48 | // See README.md 49 | return c.NoContent(http.StatusOK) 50 | } 51 | 52 | ///// Delete 53 | //// 54 | // 55 | 56 | // Delete used for remove all defined items. 57 | func (h *item) Delete(c echo.Context) error { 58 | // user := currentUser(c) 59 | // https://github.com/standardfile/ruby-server/blob/master/app/controllers/api/items_controller.rb#L72-L76 60 | 61 | // TODO undocumented feature and seems not used by official client. 62 | 63 | return c.NoContent(http.StatusNoContent) 64 | } 65 | -------------------------------------------------------------------------------- /internal/server/item_handler_20161215_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/appleboy/gofight/v2" 10 | "github.com/gofrs/uuid" 11 | "github.com/mdouchement/standardfile/internal/model" 12 | "github.com/mdouchement/standardfile/internal/server" 13 | "github.com/mdouchement/standardfile/internal/server/service" 14 | "github.com/mdouchement/standardfile/pkg/libsf" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | type sync20161215 struct { 19 | Retrieved []*model.Item `json:"retrieved_items"` 20 | Saved []*model.Item `json:"saved_items"` 21 | Unsaved []*service.UnsavedItem `json:"unsaved"` 22 | SyncToken string `json:"sync_token"` 23 | CursorToken string `json:"cursor_token"` 24 | IntegrityHash string `json:"integrity_hash,omitempty"` 25 | } 26 | 27 | func TestRequestItemsSync20161215(t *testing.T) { 28 | engine, ctrl, r, cleanup := setup() 29 | defer cleanup() 30 | 31 | r.POST("/items/sync").Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 32 | assert.Equal(t, http.StatusUnauthorized, r.Code) 33 | assert.JSONEq(t, `{"error":{"tag":"invalid-auth", "message":"Invalid login credentials."}}`, r.Body.String()) 34 | }) 35 | 36 | user := createUser(ctrl) 37 | header := gofight.H{ 38 | "Authorization": "Bearer " + server.CreateJWT(ctrl, user), 39 | } 40 | 41 | r.POST("/items/sync").SetHeader(header).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 42 | assert.Equal(t, http.StatusBadRequest, r.Code) 43 | assert.JSONEq(t, `{"error":{"message":"Could not get syncing params."}}`, r.Body.String()) 44 | }) 45 | 46 | // 47 | // 48 | 49 | item := &model.Item{ 50 | Base: model.Base{ 51 | ID: "d989ccc9-15c6-475e-839b-1690bd07d073", 52 | }, 53 | UserID: "b329a187-ddf8-4e9b-960d-49c272a58794", 54 | Content: "003:d83ea9b696c313c8a352795264873fefebbd60a92c5d9a89e3a380a0d3a68b62:d989ccc9-15c6-475e-839b-1690bd07d073:400591db0ad08c0847f45a1e76ceb87d:HI0PGPWB667YzIlWPR4A8VpGuDb9YOcTRknFb4CZXM2yJ0KK68W2giX6AV6KNV19exUvnunTmkfxlOWUfiG2m7YU2rIO76MMMfs5wqBKqO4eTuootiYVi5JbCW2BFHJcDnj3seb8juBV95Bm5lm4tQ==:eyJpZGVudGlmaWVyIjoiZ2VvcmdlLmFiaXRib2xAbm93aGVyZS5sYW4iLCJ2ZXJzaW9uIjoiMDAzIiwicHdfY29zdCI6NDIwMDAwLCJwd19ub25jZSI6Im5vbmNlIn0=", 55 | ContentType: libsf.ContentTypeNote, 56 | EncryptedItemKey: "003:3c69d9526d2846671c7e8cf89968f3b6ffd92e82ca15b04d29a3f77100ce857c:d989ccc9-15c6-475e-839b-1690bd07d073:93b257d16f53732d81230e41b62eab7c:Ai0xyC1CFcah3/rubAXV+j433oXoABPU8kmYdAzE1WlscKQIXbds8USDG0HmoC1XkCHerozTcJc5IgTAN2JZZBYttmllRswgpn7vDKZIUbXa/FDao3l6a43hedxIfd+4b1moSnB1IgG/T8c+WoA0zDd5vKtB5EMyljLVbyItBZnNrg7toV1bSWQ1t+8xUcKm:eyJpZGVudGlmaWVyIjoiZ2VvcmdlLmFiaXRib2xAbm93aGVyZS5sYW4iLCJ2ZXJzaW9uIjoiMDAzIiwicHdfY29zdCI6NDIwMDAwLCJwd19ub25jZSI6Im5vbmNlIn0=", 57 | Deleted: false, 58 | } 59 | err := ctrl.Database.Save(item) 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | // 65 | // 66 | 67 | params := gofight.D{ 68 | "compute_integrity": false, 69 | "limit": 100000, 70 | "sync_token": "", 71 | "cursor_token": "", 72 | "items": []*model.Item{}, 73 | } 74 | 75 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 76 | assert.Equal(t, http.StatusOK, r.Code) 77 | 78 | var v sync20161215 79 | err := json.Unmarshal(r.Body.Bytes(), &v) 80 | assert.NoError(t, err) 81 | 82 | at := libsf.TimeFromToken(v.SyncToken) 83 | assert.NotZero(t, at) 84 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 85 | 86 | assert.Empty(t, v.Retrieved) // Nothing for this user 87 | assert.Empty(t, v.Saved) 88 | assert.Empty(t, v.Unsaved) 89 | }) 90 | 91 | // 92 | // 93 | 94 | item.UserID = user.ID 95 | err = ctrl.Database.Save(item) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | // 101 | // 102 | 103 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 104 | assert.Equal(t, http.StatusOK, r.Code) 105 | 106 | var v sync20161215 107 | err := json.Unmarshal(r.Body.Bytes(), &v) 108 | assert.NoError(t, err) 109 | 110 | at := libsf.TimeFromToken(v.SyncToken) 111 | assert.NotZero(t, at) 112 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 113 | 114 | assert.Len(t, v.Retrieved, 1) 115 | assert.Empty(t, v.Saved) 116 | assert.Empty(t, v.Unsaved) 117 | }) 118 | 119 | item.SetID(uuid.Must(uuid.NewV4()).String()) 120 | params["items"] = []*model.Item{item} 121 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 122 | assert.Equal(t, http.StatusOK, r.Code) 123 | 124 | var v sync20161215 125 | err := json.Unmarshal(r.Body.Bytes(), &v) 126 | assert.NoError(t, err) 127 | 128 | at := libsf.TimeFromToken(v.SyncToken) 129 | assert.NotZero(t, at) 130 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 131 | 132 | assert.Len(t, v.Retrieved, 1) 133 | assert.Len(t, v.Saved, 1) 134 | assert.Empty(t, v.Unsaved) 135 | }) 136 | 137 | params["items"] = []*model.Item{} 138 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 139 | assert.Equal(t, http.StatusOK, r.Code) 140 | 141 | var v sync20161215 142 | err := json.Unmarshal(r.Body.Bytes(), &v) 143 | assert.NoError(t, err) 144 | 145 | at := libsf.TimeFromToken(v.SyncToken) 146 | assert.NotZero(t, at) 147 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 148 | 149 | assert.Len(t, v.Retrieved, 2) 150 | assert.Empty(t, v.Saved) 151 | assert.Empty(t, v.Unsaved) 152 | }) 153 | } 154 | -------------------------------------------------------------------------------- /internal/server/item_handler_20190520_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/appleboy/gofight/v2" 10 | "github.com/gofrs/uuid" 11 | "github.com/mdouchement/standardfile/internal/model" 12 | "github.com/mdouchement/standardfile/internal/server" 13 | "github.com/mdouchement/standardfile/internal/server/service" 14 | "github.com/mdouchement/standardfile/pkg/libsf" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | type sync20190520 struct { 19 | Retrieved []*model.Item `json:"retrieved_items"` 20 | Saved []*model.Item `json:"saved_items"` 21 | Conflicts []*service.ConflictItem `json:"conflicts"` 22 | SyncToken string `json:"sync_token"` 23 | CursorToken string `json:"cursor_token"` 24 | IntegrityHash string `json:"integrity_hash,omitempty"` 25 | } 26 | 27 | func TestRequestItemsSync20190520(t *testing.T) { 28 | engine, ctrl, r, cleanup := setup() 29 | defer cleanup() 30 | 31 | r.POST("/items/sync").Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 32 | assert.Equal(t, http.StatusUnauthorized, r.Code) 33 | assert.JSONEq(t, `{"error":{"tag":"invalid-auth", "message":"Invalid login credentials."}}`, r.Body.String()) 34 | }) 35 | 36 | user := createUser(ctrl) 37 | header := gofight.H{ 38 | "Authorization": "Bearer " + server.CreateJWT(ctrl, user), 39 | } 40 | 41 | r.POST("/items/sync").SetHeader(header).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 42 | assert.Equal(t, http.StatusBadRequest, r.Code) 43 | assert.JSONEq(t, `{"error":{"message":"Could not get syncing params."}}`, r.Body.String()) 44 | }) 45 | 46 | // 47 | // 48 | 49 | item := &model.Item{ 50 | Base: model.Base{ 51 | ID: "d989ccc9-15c6-475e-839b-1690bd07d073", 52 | }, 53 | UserID: "b329a187-ddf8-4e9b-960d-49c272a58794", 54 | Content: "003:d83ea9b696c313c8a352795264873fefebbd60a92c5d9a89e3a380a0d3a68b62:d989ccc9-15c6-475e-839b-1690bd07d073:400591db0ad08c0847f45a1e76ceb87d:HI0PGPWB667YzIlWPR4A8VpGuDb9YOcTRknFb4CZXM2yJ0KK68W2giX6AV6KNV19exUvnunTmkfxlOWUfiG2m7YU2rIO76MMMfs5wqBKqO4eTuootiYVi5JbCW2BFHJcDnj3seb8juBV95Bm5lm4tQ==:eyJpZGVudGlmaWVyIjoiZ2VvcmdlLmFiaXRib2xAbm93aGVyZS5sYW4iLCJ2ZXJzaW9uIjoiMDAzIiwicHdfY29zdCI6NDIwMDAwLCJwd19ub25jZSI6Im5vbmNlIn0=", 55 | ContentType: libsf.ContentTypeNote, 56 | EncryptedItemKey: "003:3c69d9526d2846671c7e8cf89968f3b6ffd92e82ca15b04d29a3f77100ce857c:d989ccc9-15c6-475e-839b-1690bd07d073:93b257d16f53732d81230e41b62eab7c:Ai0xyC1CFcah3/rubAXV+j433oXoABPU8kmYdAzE1WlscKQIXbds8USDG0HmoC1XkCHerozTcJc5IgTAN2JZZBYttmllRswgpn7vDKZIUbXa/FDao3l6a43hedxIfd+4b1moSnB1IgG/T8c+WoA0zDd5vKtB5EMyljLVbyItBZnNrg7toV1bSWQ1t+8xUcKm:eyJpZGVudGlmaWVyIjoiZ2VvcmdlLmFiaXRib2xAbm93aGVyZS5sYW4iLCJ2ZXJzaW9uIjoiMDAzIiwicHdfY29zdCI6NDIwMDAwLCJwd19ub25jZSI6Im5vbmNlIn0=", 57 | Deleted: false, 58 | } 59 | err := ctrl.Database.Save(item) 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | // 65 | // 66 | 67 | params := gofight.D{ 68 | "api": "20190520", // ===========> !important 69 | "compute_integrity": false, 70 | "limit": 100000, 71 | "sync_token": "", 72 | "cursor_token": "", 73 | "items": []*model.Item{}, 74 | } 75 | 76 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 77 | assert.Equal(t, http.StatusOK, r.Code) 78 | 79 | var v sync20190520 80 | err := json.Unmarshal(r.Body.Bytes(), &v) 81 | assert.NoError(t, err) 82 | 83 | at := libsf.TimeFromToken(v.SyncToken) 84 | assert.NotZero(t, at) 85 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 86 | 87 | assert.Empty(t, v.Retrieved) // Nothing for this user 88 | assert.Empty(t, v.Saved) 89 | assert.Empty(t, v.Conflicts) 90 | }) 91 | 92 | // 93 | // 94 | 95 | item.UserID = user.ID 96 | err = ctrl.Database.Save(item) 97 | if err != nil { 98 | panic(err) 99 | } 100 | 101 | // 102 | // 103 | 104 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 105 | assert.Equal(t, http.StatusOK, r.Code) 106 | 107 | var v sync20190520 108 | err := json.Unmarshal(r.Body.Bytes(), &v) 109 | assert.NoError(t, err) 110 | 111 | at := libsf.TimeFromToken(v.SyncToken) 112 | assert.NotZero(t, at) 113 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 114 | 115 | assert.Len(t, v.Retrieved, 1) 116 | assert.Empty(t, v.Saved) 117 | assert.Empty(t, v.Conflicts) 118 | }) 119 | 120 | item.SetID(uuid.Must(uuid.NewV4()).String()) 121 | params["items"] = []*model.Item{item} 122 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 123 | assert.Equal(t, http.StatusOK, r.Code) 124 | 125 | var v sync20190520 126 | err := json.Unmarshal(r.Body.Bytes(), &v) 127 | assert.NoError(t, err) 128 | 129 | at := libsf.TimeFromToken(v.SyncToken) 130 | assert.NotZero(t, at) 131 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 132 | 133 | assert.Len(t, v.Retrieved, 1) 134 | assert.Len(t, v.Saved, 1) 135 | assert.Empty(t, v.Conflicts) 136 | }) 137 | 138 | params["items"] = []*model.Item{} 139 | r.POST("/items/sync").SetHeader(header).SetJSON(params).Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 140 | assert.Equal(t, http.StatusOK, r.Code) 141 | 142 | var v sync20190520 143 | err := json.Unmarshal(r.Body.Bytes(), &v) 144 | assert.NoError(t, err) 145 | 146 | at := libsf.TimeFromToken(v.SyncToken) 147 | assert.NotZero(t, at) 148 | assert.WithinDuration(t, time.Now(), at, 2*time.Second) 149 | 150 | assert.Len(t, v.Retrieved, 2) 151 | assert.Empty(t, v.Saved) 152 | assert.Empty(t, v.Conflicts) 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /internal/server/middlewares/binder.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type binder struct { 10 | echo.DefaultBinder 11 | methodsWithBody map[string]bool 12 | } 13 | 14 | // NewBinder returns a wrapp of the default binder implementation with extra checks. 15 | func NewBinder() echo.Binder { 16 | return &binder{ 17 | methodsWithBody: map[string]bool{ 18 | http.MethodPost: true, 19 | http.MethodPatch: true, 20 | http.MethodPut: true, 21 | }, 22 | } 23 | } 24 | 25 | // Bind implements the echo.Bind interface. 26 | func (b *binder) Bind(i any, c echo.Context) (err error) { 27 | if c.Request().ContentLength == 0 && b.methodsWithBody[c.Request().Method] { 28 | return echo.NewHTTPError(http.StatusBadRequest, "Request body can't be empty") 29 | } 30 | 31 | if c.Request().Header.Get("Content-Type") == "" { 32 | c.Request().Header.Set("Content-Type", "application/json") 33 | } 34 | 35 | return b.DefaultBinder.Bind(i, c) 36 | } 37 | -------------------------------------------------------------------------------- /internal/server/middlewares/http_error_handler.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gofrs/uuid" 9 | "github.com/labstack/echo/v4" 10 | "github.com/mdouchement/standardfile/internal/sferror" 11 | ) 12 | 13 | // HTTPErrorHandler is a middleware that formats rendered errors. 14 | func HTTPErrorHandler(err error, c echo.Context) { 15 | if !c.Response().Committed { 16 | switch err := err.(type) { 17 | case *echo.HTTPError: 18 | log.Printf("Error [ECHO]: %s", err.Internal) 19 | _ = c.JSON(err.Code, echo.Map{ 20 | "error": echo.Map{ 21 | "message": err.Message, 22 | }, 23 | }) 24 | case *sferror.SFError: 25 | status := sferror.StatusCode(err) 26 | if status < 500 { 27 | _ = c.JSON(status, err) 28 | return 29 | } 30 | 31 | internal(err, c) 32 | default: 33 | internal(err, c) 34 | } 35 | } 36 | } 37 | 38 | func internal(err error, c echo.Context) { 39 | id := uuid.Must(uuid.NewV4()).String() 40 | log.Printf("Error [%s]: %s", id, err.Error()) 41 | 42 | _ = c.JSON(http.StatusInternalServerError, echo.Map{ 43 | "error": echo.Map{ 44 | "message": fmt.Sprintf("Unexpected error (id: %s)", id), 45 | }, 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /internal/server/middlewares/session.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | echojwt "github.com/labstack/echo-jwt/v4" 8 | "github.com/labstack/echo/v4" 9 | "github.com/mdouchement/middlewarex" 10 | "github.com/mdouchement/standardfile/internal/server/session" 11 | "github.com/o1egl/paseto/v2" 12 | ) 13 | 14 | const ( 15 | // CurrentUserContextKey is the key to retrieve the current_user from echo.Context. 16 | CurrentUserContextKey = "current_user" 17 | // CurrentSessionContextKey is the key to retrieve the current_session from echo.Context. 18 | CurrentSessionContextKey = "current_session" 19 | ) 20 | 21 | // Session returns a Session auth middleware. 22 | // It also handle JWT tokens from previous API versions. 23 | // It stores current_user into echo.Context 24 | func Session(m session.Manager) echo.MiddlewareFunc { 25 | jwt := echojwt.JWT(m.JWTSigningKey()) 26 | paseto := middlewarex.PASETOWithConfig(middlewarex.PASETOConfig{ 27 | SigningKey: m.SessionSecret(), 28 | Validators: []paseto.Validator{ 29 | paseto.IssuedBy("standardfile"), 30 | paseto.ForAudience(session.TypeAccessToken), 31 | }, 32 | }) 33 | 34 | fake := func(echo.Context) error { 35 | return nil 36 | } 37 | 38 | return func(next echo.HandlerFunc) echo.HandlerFunc { 39 | return func(c echo.Context) (err error) { 40 | authorization := c.Request().Header.Get(echo.HeaderAuthorization) 41 | token := token(authorization) 42 | 43 | if token == "" { 44 | return c.JSON(http.StatusUnauthorized, echo.Map{ 45 | "error": echo.Map{ 46 | "tag": "invalid-auth", 47 | "message": "Invalid login credentials.", 48 | }, 49 | }) 50 | } 51 | 52 | // 53 | // Session 54 | // 55 | 56 | if strings.HasPrefix(token, "v2.local.") { 57 | err = paseto(fake)(c) // Check PASETO validity according its claims. 58 | if err != nil && !strings.Contains(err.Error(), "token has expired: token validation error") { 59 | // Token is not valid. 60 | // We do not catch token expiration here and let the session manager performs its validation. 61 | return c.JSON(http.StatusUnauthorized, echo.Map{ 62 | "error": echo.Map{ 63 | "tag": "invalid-auth", 64 | "message": "Invalid login credentials.", 65 | }, 66 | }) 67 | } 68 | 69 | tk := c.Get(middlewarex.DefaultPASETOConfig.ContextKey).(middlewarex.Token) 70 | 71 | // Find, validate and store current_session for handlers. 72 | session, err := m.Validate(tk.Subject, tk.Jti) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | c.Set(CurrentSessionContextKey, session) 78 | 79 | // Find and store current_user for handlers. 80 | user, err := m.UserFromToken(tk) 81 | if err != nil { 82 | return err 83 | } 84 | c.Set(CurrentUserContextKey, user) 85 | 86 | // TODO: Find a way to extract `api` (apiversion) from the requests body. 87 | // Revoke old JWT. 88 | // if apiversion >= 20190520 && session.UserSupportsSessions(user) { 89 | // return c.JSON(http.StatusUnauthorized, echo.Map{ 90 | // "error": echo.Map{ 91 | // "tag": "invalid-auth", 92 | // "message": "Invalid login credentials.", 93 | // }, 94 | // }) 95 | // } 96 | 97 | return next(c) 98 | } 99 | 100 | // 101 | // JWT (deprecated auth) 102 | // 103 | 104 | err = jwt(fake)(c) // Check JWT validity according its claims. 105 | if err != nil { 106 | return c.JSON(http.StatusUnauthorized, echo.Map{ 107 | "error": echo.Map{ 108 | "tag": "invalid-auth", 109 | "message": "Invalid login credentials.", 110 | }, 111 | }) 112 | } 113 | 114 | user, err := m.UserFromToken(c.Get("user")) // https://github.com/labstack/echo-jwt/blob/v4.3.0/jwt.go#L178 115 | if err != nil { 116 | return err 117 | } 118 | 119 | // Store current_user for handlers. 120 | c.Set(CurrentUserContextKey, user) 121 | return next(c) 122 | } 123 | } 124 | } 125 | 126 | func token(authorization string) string { 127 | parts := strings.Split(authorization, " ") 128 | if strings.ToLower(parts[0]) != "bearer" { 129 | return "" 130 | } 131 | 132 | if len(parts) < 2 { 133 | return "" 134 | } 135 | return parts[1] 136 | } 137 | -------------------------------------------------------------------------------- /internal/server/serializer/session.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | import "github.com/mdouchement/standardfile/internal/model" 4 | 5 | // Session serializes the render of a session. 6 | func Session(m *model.Session) map[string]any { 7 | r := map[string]any{ 8 | "uuid": m.ID, 9 | "created_at": m.CreatedAt.UTC(), 10 | "updated_at": m.UpdatedAt.UTC(), 11 | "api_version": m.APIVersion, 12 | "user_agent": m.UserAgent, // TODO: rename field to device_info? 13 | "current": m.Current, 14 | } 15 | return r 16 | } 17 | 18 | // Sessions serializes the render of sessions. 19 | func Sessions(m []*model.Session) []map[string]any { 20 | sessions := make([]map[string]any, len(m)) 21 | for i, s := range m { 22 | sessions[i] = Session(s) 23 | } 24 | return sessions 25 | } 26 | -------------------------------------------------------------------------------- /internal/server/serializer/user.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | import ( 4 | "github.com/mdouchement/standardfile/internal/model" 5 | "github.com/mdouchement/standardfile/pkg/libsf" 6 | ) 7 | 8 | // User serializes the render of a user. 9 | func User(m *model.User) map[string]any { 10 | r := map[string]any{ 11 | "uuid": m.ID, 12 | "created_at": m.CreatedAt.UTC(), 13 | "updated_at": m.UpdatedAt.UTC(), 14 | "email": m.Email, 15 | "version": m.Version, 16 | "pw_cost": m.PasswordCost, 17 | } 18 | 19 | switch m.Version { 20 | case libsf.ProtocolVersion2: 21 | r["pw_salt"] = m.PasswordSalt 22 | r["pw_auth"] = m.PasswordAuth 23 | case libsf.ProtocolVersion3: 24 | fallthrough 25 | case libsf.ProtocolVersion4: 26 | r["pw_nonce"] = m.PasswordNonce 27 | } 28 | 29 | return r 30 | } 31 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sort" 7 | "time" 8 | 9 | "github.com/labstack/echo/v4" 10 | "github.com/labstack/echo/v4/middleware" 11 | "github.com/mdouchement/standardfile/internal/database" 12 | "github.com/mdouchement/standardfile/internal/model" 13 | "github.com/mdouchement/standardfile/internal/server/middlewares" 14 | "github.com/mdouchement/standardfile/internal/server/session" 15 | ) 16 | 17 | // A Controller is an Iversion Of Control pattern used to init the server package. 18 | type Controller struct { 19 | Version string 20 | Database database.Client 21 | NoRegistration bool 22 | ShowRealVersion bool 23 | SubscriptionPayload []byte 24 | FeaturesPayload []byte 25 | AllowOrigins []string 26 | AllowMethods []string 27 | // JWT params 28 | SigningKey []byte 29 | // Session params 30 | SessionSecret []byte 31 | AccessTokenExpirationTime time.Duration 32 | RefreshTokenExpirationTime time.Duration 33 | } 34 | 35 | // EchoEngine instantiates the wep server. 36 | func EchoEngine(ctrl Controller) *echo.Echo { 37 | engine := echo.New() 38 | engine.Use(middleware.Recover()) 39 | // engine.Use(middleware.CSRF()) // not supported by StandardNotes 40 | engine.Use(middleware.Secure()) 41 | engine.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 42 | AllowCredentials: true, 43 | AllowOrigins: ctrl.AllowOrigins, 44 | AllowMethods: ctrl.AllowMethods, 45 | })) 46 | engine.Use(middleware.Gzip()) 47 | 48 | engine.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 49 | Format: "[${status}] ${method} ${uri} (${bytes_in}) ${latency_human}\n", 50 | })) 51 | engine.Binder = middlewares.NewBinder() 52 | // Error handler 53 | engine.HTTPErrorHandler = middlewares.HTTPErrorHandler 54 | 55 | engine.Pre(middleware.Rewrite(map[string]string{ 56 | "/": "/version", 57 | })) 58 | 59 | //////////// 60 | // Router // 61 | //////////// 62 | 63 | sessions := session.NewManager( 64 | ctrl.Database, 65 | ctrl.SigningKey, 66 | ctrl.SessionSecret, 67 | ctrl.AccessTokenExpirationTime, 68 | ctrl.RefreshTokenExpirationTime, 69 | ) 70 | 71 | router := engine.Group("") 72 | restricted := router.Group("") 73 | restricted.Use(middlewares.Session(sessions)) 74 | 75 | v1 := router.Group("/v1") 76 | v1restricted := restricted.Group("/v1") 77 | 78 | // generic handlers 79 | // 80 | router.GET("/version", func(c echo.Context) error { 81 | version := "n/a" 82 | if ctrl.ShowRealVersion { 83 | version = ctrl.Version 84 | } 85 | 86 | return c.JSON(http.StatusOK, echo.Map{ 87 | "version": version, 88 | }) 89 | }) 90 | 91 | // 92 | // auth handlers 93 | // 94 | auth := &auth{ 95 | db: ctrl.Database, 96 | sessions: sessions, 97 | } 98 | if !ctrl.NoRegistration { 99 | router.POST("/auth", auth.Register) 100 | 101 | v1.POST("/users", auth.Register) 102 | } 103 | router.GET("/auth/params", auth.Params) // Used for sign_in 104 | router.POST("/auth/sign_in", auth.Login) 105 | restricted.POST("/auth/sign_out", auth.Logout) 106 | restricted.POST("/auth/update", auth.Update) 107 | restricted.POST("/auth/change_pw", auth.UpdatePassword) 108 | 109 | v1.GET("/login-params", auth.Params) 110 | v1.POST("/login", auth.Login) 111 | v1restricted.POST("/logout", auth.Logout) 112 | v1restricted.PUT("/users/:id/attributes/credentials", auth.UpdatePassword) 113 | 114 | // TODO: GET /auth/methods 115 | // TODO: GET /v1/users/:id/params => currentuser auth.Params 116 | // TODO: PATCH /v1/users/:id 117 | // TODO: PUT /v1/users/:id/settings 118 | // TODO: DELETE /v1/users/:id/settings/:param 119 | 120 | // 121 | // session handlers 122 | // 123 | session := &sess{ 124 | db: ctrl.Database, 125 | sessions: sessions, 126 | } 127 | router.POST("/session/refresh", session.Refresh) 128 | restricted.GET("/sessions", session.List) 129 | restricted.DELETE("/session", session.Delete) 130 | restricted.DELETE("/session/all", session.DeleteAll) 131 | 132 | v1.POST("/sessions/refresh", session.Refresh) 133 | v1restricted.GET("/sessions", session.List) 134 | v1restricted.DELETE("/sessions/:id", session.Delete) 135 | v1restricted.DELETE("/sessions", session.DeleteAll) 136 | 137 | // 138 | // item handlers 139 | // 140 | item := &item{ 141 | db: ctrl.Database, 142 | } 143 | restricted.POST("/items/sync", item.Sync) 144 | restricted.POST("/items/backup", item.Backup) 145 | restricted.DELETE("/items", item.Delete) 146 | 147 | v1restricted.POST("/items", item.Sync) 148 | 149 | v2 := router.Group("/v2") 150 | v2.POST("/login", auth.LoginPKCE) 151 | v2.POST("/login-params", auth.ParamsPKCE) 152 | // v2restricted := restricted.Group("/v2") 153 | 154 | // 155 | // subscription handlers 156 | // 157 | if len(ctrl.SubscriptionPayload) != 0 { 158 | subscription := &subscription{ 159 | SubscriptionPayload: ctrl.SubscriptionPayload, 160 | FeaturesPayload: ctrl.FeaturesPayload, 161 | } 162 | router.GET("/v2/subscriptions", func(c echo.Context) error { 163 | return c.HTML(http.StatusInternalServerError, "getaddrinfo EAI_AGAIN payments") 164 | }) 165 | v1restricted.GET("/users/:id/subscription", subscription.SubscriptionV1) 166 | v1restricted.GET("/users/:id/features", subscription.Features) 167 | } 168 | 169 | return engine 170 | } 171 | 172 | // PrintRoutes prints the Echo engin exposed routes. 173 | func PrintRoutes(e *echo.Echo) { 174 | ignored := map[string]bool{ 175 | "": true, 176 | ".": true, 177 | "/*": true, 178 | "/v1": true, 179 | "/v1/*": true, 180 | "/v2": true, 181 | "/v2/*": true, 182 | } 183 | 184 | routes := e.Routes() 185 | sort.Slice(routes, func(i int, j int) bool { 186 | return routes[i].Path < routes[j].Path 187 | }) 188 | 189 | fmt.Println("Routes:") 190 | for _, route := range routes { 191 | if ignored[route.Path] { 192 | continue 193 | } 194 | fmt.Printf("%6s %s\n", route.Method, route.Path) 195 | } 196 | } 197 | 198 | func currentUser(c echo.Context) *model.User { 199 | user, ok := c.Get(middlewares.CurrentUserContextKey).(*model.User) 200 | if ok { 201 | return user 202 | } 203 | return nil 204 | } 205 | 206 | func currentSession(c echo.Context) *model.Session { 207 | session, ok := c.Get(middlewares.CurrentSessionContextKey).(*model.Session) 208 | if ok { 209 | return session 210 | } 211 | return nil 212 | } 213 | -------------------------------------------------------------------------------- /internal/server/server_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/appleboy/gofight/v2" 10 | "github.com/labstack/echo/v4" 11 | argon2 "github.com/mdouchement/simple-argon2" 12 | "github.com/mdouchement/standardfile/internal/database" 13 | "github.com/mdouchement/standardfile/internal/model" 14 | "github.com/mdouchement/standardfile/internal/server" 15 | "github.com/mdouchement/standardfile/internal/server/session" 16 | sessionpkg "github.com/mdouchement/standardfile/internal/server/session" 17 | "github.com/mdouchement/standardfile/pkg/libsf" 18 | "github.com/stretchr/testify/assert" 19 | ) 20 | 21 | // Echo 4.2.2 uses req.RequestURI rewrite middleware which is not defined by gofight. 22 | // https://github.com/appleboy/gofight/pull/87 23 | // 24 | // func TestRequestHome(t *testing.T) { 25 | // engine, _, r, cleanup := setup() 26 | // defer cleanup() 27 | 28 | // r.GET("/").Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 29 | // assert.Equal(t, http.StatusOK, r.Code) 30 | // assert.JSONEq(t, `{"version":"test"}`, r.Body.String()) 31 | // }) 32 | // } 33 | 34 | func TestRequestVersion(t *testing.T) { 35 | engine, _, r, cleanup := setup() 36 | defer cleanup() 37 | 38 | r.GET("/version").Run(engine, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { 39 | assert.Equal(t, http.StatusOK, r.Code) 40 | assert.JSONEq(t, `{"version":"test"}`, r.Body.String()) 41 | }) 42 | } 43 | 44 | func setup() (engine *echo.Echo, ctrl server.Controller, r *gofight.RequestConfig, cleanup func()) { 45 | tmpfile, err := os.CreateTemp("", "standardfile.*.db") 46 | if err != nil { 47 | panic(err) 48 | } 49 | filename := tmpfile.Name() 50 | tmpfile.Close() 51 | 52 | db, err := database.StormOpen(filename) 53 | if err != nil { 54 | panic(err) 55 | } 56 | 57 | ctrl = server.Controller{ 58 | Version: "test", 59 | Database: db, 60 | NoRegistration: false, 61 | ShowRealVersion: true, 62 | SigningKey: []byte("secret"), 63 | SessionSecret: []byte("00000000000000000000000000000000"), 64 | AccessTokenExpirationTime: 60 * 24 * time.Hour, 65 | RefreshTokenExpirationTime: 365 * 24 * time.Hour, 66 | } 67 | engine = server.EchoEngine(ctrl) 68 | 69 | return engine, ctrl, gofight.New(), func() { 70 | db.Close() 71 | os.RemoveAll(filename) 72 | } 73 | } 74 | 75 | func createUser(ctrl server.Controller) *model.User { 76 | var err error 77 | t := time.Now() 78 | 79 | user := model.NewUser() 80 | user.CreatedAt = &t 81 | user.UpdatedAt = &t 82 | user.Email = "george.abitbol@nowhere.lan" 83 | user.Version = libsf.ProtocolVersion3 84 | user.Password, err = argon2.GenerateFromPasswordString("password42", argon2.Default) 85 | user.PasswordCost = 110000 86 | user.PasswordNonce = "nonce42" 87 | user.PasswordUpdatedAt = time.Now().Add(-12 * time.Hour).Unix() 88 | if err != nil { 89 | panic(err) 90 | } 91 | err = ctrl.Database.Save(user) 92 | if err != nil { 93 | panic(err) 94 | } 95 | 96 | return user 97 | } 98 | 99 | func createUserWithSession(ctrl server.Controller) (*model.User, *model.Session) { 100 | var err error 101 | 102 | user := model.NewUser() 103 | user.Email = "george.abitbol@nowhere.lan" 104 | user.Version = libsf.ProtocolVersion4 105 | user.Password, err = argon2.GenerateFromPasswordString("password42", argon2.Default) 106 | user.PasswordCost = 110000 107 | user.PasswordNonce = "nonce42" 108 | user.PasswordUpdatedAt = time.Now().Add(-12 * time.Hour).Unix() 109 | if err != nil { 110 | panic(err) 111 | } 112 | err = ctrl.Database.Save(user) 113 | if err != nil { 114 | panic(err) 115 | } 116 | 117 | session := &model.Session{ 118 | APIVersion: "20200115", 119 | UserAgent: "Go-http-client/1.1", 120 | UserID: user.ID, 121 | ExpireAt: time.Now().Add(ctrl.RefreshTokenExpirationTime).UTC(), 122 | AccessToken: session.SecureToken(8), 123 | RefreshToken: session.SecureToken(8), 124 | } 125 | err = ctrl.Database.Save(session) 126 | if err != nil { 127 | panic(err) 128 | } 129 | 130 | return user, session 131 | } 132 | 133 | func accessToken(ctrl server.Controller, s *model.Session) string { 134 | sessions := sessionpkg.NewManager( 135 | ctrl.Database, 136 | ctrl.SigningKey, 137 | ctrl.SessionSecret, 138 | ctrl.AccessTokenExpirationTime, 139 | ctrl.RefreshTokenExpirationTime, 140 | ) 141 | 142 | token, err := sessions.Token(s, sessionpkg.TypeAccessToken) 143 | if err != nil { 144 | panic(err) 145 | } 146 | return token 147 | } 148 | 149 | func refreshToken(ctrl server.Controller, s *model.Session) string { 150 | sessions := sessionpkg.NewManager( 151 | ctrl.Database, 152 | ctrl.SigningKey, 153 | ctrl.SessionSecret, 154 | ctrl.AccessTokenExpirationTime, 155 | ctrl.RefreshTokenExpirationTime, 156 | ) 157 | 158 | token, err := sessions.Token(s, sessionpkg.TypeRefreshToken) 159 | if err != nil { 160 | panic(err) 161 | } 162 | return token 163 | } 164 | -------------------------------------------------------------------------------- /internal/server/service/pkce.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/mdouchement/standardfile/internal/database" 10 | "github.com/mdouchement/standardfile/internal/model" 11 | ) 12 | 13 | type ( 14 | // A PKCEService is a service used for managing challenges. 15 | PKCEService interface { 16 | ComputeChallenge(codeVerifier string) string 17 | StoreChallenge(codeChallenge string) error 18 | CheckChallenge(codeChallenge string) error 19 | } 20 | 21 | pkceService struct { 22 | db database.Client 23 | Params Params 24 | } 25 | ) 26 | 27 | // NewPKCE instantiates a new PKCE service. 28 | func NewPKCE(db database.Client, params Params) (s PKCEService) { 29 | switch params.APIVersion { // for future API increments 30 | default: 31 | s = &pkceService{ 32 | db: db, 33 | Params: params, 34 | } 35 | } 36 | return s 37 | } 38 | 39 | func (s *pkceService) ComputeChallenge(codeVerifier string) string { 40 | hash := sha256.Sum256([]byte(codeVerifier)) 41 | hexHash := fmt.Sprintf("%x", hash) 42 | return base64.RawURLEncoding.EncodeToString([]byte(hexHash)) 43 | } 44 | 45 | func (s *pkceService) StoreChallenge(codeChallenge string) error { 46 | if err := s.db.RevokeExpiredChallenges(); err != nil { 47 | return err 48 | } 49 | 50 | return s.db.Save(&model.PKCE{ 51 | CodeChallenge: codeChallenge, 52 | ExpireAt: time.Now().Add(1 * time.Hour).UTC(), 53 | }) 54 | } 55 | 56 | func (s *pkceService) CheckChallenge(codeChallenge string) error { 57 | if err := s.db.RevokeExpiredChallenges(); err != nil { 58 | return err 59 | } 60 | 61 | if _, err := s.db.FindPKCE(codeChallenge); err != nil { 62 | return err 63 | } 64 | 65 | return s.db.RemovePKCE(codeChallenge) 66 | } 67 | -------------------------------------------------------------------------------- /internal/server/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/mdouchement/standardfile/internal/model" 4 | 5 | // M is an arbitrary map. 6 | type M map[string]any 7 | 8 | // Params are the basic fields used in requests. 9 | type Params struct { 10 | APIVersion string `json:"api"` // Since 20190520 11 | UserAgent string 12 | Session *model.Session 13 | } 14 | -------------------------------------------------------------------------------- /internal/server/service/sync.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mdouchement/standardfile/internal/database" 11 | "github.com/mdouchement/standardfile/internal/model" 12 | "github.com/mdouchement/standardfile/pkg/libsf" 13 | ) 14 | 15 | type ( 16 | // A SyncParams is used when a client want to sync items. 17 | SyncParams struct { 18 | Params 19 | ComputeIntegrity bool `json:"compute_integrity"` 20 | Limit int `json:"limit"` 21 | SyncToken string `json:"sync_token"` 22 | CursorToken string `json:"cursor_token"` 23 | ContentType string `json:"content_type"` // optional, only return items of these type if present 24 | Items []*model.Item `json:"items"` 25 | } 26 | 27 | // A SyncService is a service used for syncing items. 28 | SyncService interface { 29 | // Execute performs the synchronisation. 30 | Execute() error 31 | } 32 | 33 | syncServiceBase struct { 34 | db database.Client 35 | User *model.User `json:"-"` 36 | Params SyncParams `json:"-"` 37 | } 38 | 39 | errorItem struct { 40 | Message string `json:"message"` 41 | Tag string `json:"tag"` 42 | } 43 | ) 44 | 45 | // NewSync instantiates a new Sync service. 46 | func NewSync(db database.Client, user *model.User, params SyncParams) (s SyncService) { 47 | switch params.APIVersion { 48 | case "20200115": 49 | fallthrough 50 | case "20190520": 51 | s = &syncService20190520{ 52 | Base: &syncServiceBase{ 53 | db: db, 54 | User: user, 55 | Params: params, 56 | }, 57 | } 58 | case "20161215": 59 | fallthrough 60 | default: 61 | s = &syncService20161215{ 62 | Base: &syncServiceBase{ 63 | db: db, 64 | User: user, 65 | Params: params, 66 | }, 67 | } 68 | } 69 | return s 70 | } 71 | 72 | // Get 73 | func (s *syncServiceBase) get() ([]*model.Item, bool, error) { 74 | if s.Params.SyncToken == "" { 75 | // If it's the first sync request, front-load all exisitng items keys 76 | // so that the client can decrypt incoming items without having to wait. 77 | s.Params.Limit = 0 78 | } 79 | 80 | var ( 81 | updated time.Time 82 | strict bool 83 | noDeleted bool 84 | ) 85 | 86 | // if both are present, cursor_token takes precedence as that would eventually return all results 87 | // the distinction between getting results for a cursor and a sync token is that cursor results use a 88 | // >= comparison, while a sync token uses a > comparison. The reason for this is that cursor tokens are 89 | // typically used for initial syncs or imports, where a bunch of notes could have the exact same updated_at 90 | // by using >=, we don't miss those results on a subsequent call with a cursor token. 91 | switch { 92 | case s.Params.CursorToken != "": 93 | updated = libsf.TimeFromToken(s.Params.CursorToken) 94 | case s.Params.SyncToken != "": 95 | updated = libsf.TimeFromToken(s.Params.SyncToken) 96 | strict = true 97 | default: 98 | // if no cursor token and no sync token, this is an initial sync. No need to return deleted items. 99 | noDeleted = true 100 | } 101 | 102 | return s.db.FindItemsByParams( 103 | s.User.ID, s.Params.ContentType, 104 | updated, strict, 105 | noDeleted, s.Params.Limit) 106 | } 107 | 108 | // Compute data signature for integrity check 109 | // 110 | // https://github.com/standardfile/sfjs/blob/499fd0bc7ebddfc72f8b1dc3c9cbf134e92016d3/lib/app/lib/modelManager.js#L664-L677 111 | func (s *syncServiceBase) computeDataSignature() (string, error) { 112 | items, err := s.db.FindItemsForIntegrityCheck(s.User.ID) 113 | if err != nil { 114 | return "", err 115 | } 116 | 117 | timestamps := []string{} 118 | for _, item := range items { 119 | // Unix timestamp in milliseconds (like MRI's `Time.now.to_datetime.strftime('%Q')`) 120 | timestamps = append(timestamps, fmt.Sprintf("%d", item.UpdatedAt.UnixMilli())) 121 | } 122 | 123 | sort.SliceStable(timestamps, func(i, j int) bool { 124 | return timestamps[j] < timestamps[i] 125 | }) 126 | 127 | b := []byte(strings.Join(timestamps, ",")) 128 | return fmt.Sprintf("%x", sha256.Sum256(b)), nil 129 | } 130 | 131 | // PrepareDelete 132 | func (s *syncServiceBase) prepareDelete(item *model.Item) { 133 | item.Content = "" 134 | item.EncryptedItemKey = "" 135 | } 136 | -------------------------------------------------------------------------------- /internal/server/service/sync_20161215.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/mdouchement/standardfile/internal/model" 8 | "github.com/mdouchement/standardfile/pkg/libsf" 9 | ) 10 | 11 | const minConflictInterval20161215 = 20 // in second 12 | 13 | type ( 14 | // A syncService20161215 is a service used for syncing items. 15 | syncService20161215 struct { 16 | Base *syncServiceBase `json:"-"` 17 | // Populated during `Execute()` 18 | Retrieved []*model.Item `json:"retrieved_items"` 19 | Saved []*model.Item `json:"saved_items"` 20 | Unsaved []*UnsavedItem `json:"unsaved"` 21 | SyncToken string `json:"sync_token"` 22 | CursorToken string `json:"cursor_token"` 23 | IntegrityHash string `json:"integrity_hash,omitempty"` 24 | } 25 | 26 | // An UnsavedItem is an object containing an item that can't be saved. 27 | UnsavedItem struct { 28 | Item *model.Item `json:"item"` 29 | Error errorItem `json:"error"` 30 | } 31 | ) 32 | 33 | // Execute performs the synchronisation. 34 | func (s *syncService20161215) Execute() error { 35 | retrievedItems, overLimit, err := s.Base.get() 36 | if err != nil { 37 | return err 38 | } 39 | s.Retrieved = retrievedItems 40 | 41 | s.Saved, s.Unsaved = s.save() 42 | 43 | retrievedToDelete := s.checkForConflicts() 44 | // Remove potential conflicted items => cf. checkForConflicts() 45 | var n int 46 | for _, item := range s.Retrieved { 47 | if retrievedToDelete[item.ID] { 48 | continue 49 | } 50 | 51 | s.Retrieved[n] = item 52 | n++ 53 | } 54 | s.Retrieved = s.Retrieved[:n] 55 | 56 | // In reference implementation, there is post_to_extensions but not implemented here. 57 | // See README.md 58 | 59 | if s.Base.Params.ComputeIntegrity { 60 | s.IntegrityHash, err = s.Base.computeDataSignature() 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | 66 | // 67 | // Prepare returned value 68 | // 69 | 70 | // CursorToken 71 | if overLimit { 72 | s.CursorToken = libsf.TokenFromTime(*retrievedItems[s.Base.Params.Limit-1].UpdatedAt) 73 | } 74 | 75 | // SyncToken 76 | var lastUpdated time.Time 77 | for _, item := range s.Saved { 78 | if item.UpdatedAt.After(lastUpdated) { 79 | lastUpdated = *item.UpdatedAt 80 | } 81 | } 82 | if lastUpdated.IsZero() { // occurred when `len(savedItems) == 0' 83 | lastUpdated = time.Now() 84 | } 85 | 86 | // add 1 microsecond to avoid returning same object in subsequent sync. 87 | s.SyncToken = libsf.TokenFromTime(lastUpdated.Add(1 * time.Microsecond)) 88 | 89 | return nil 90 | } 91 | 92 | // Save 93 | func (s *syncService20161215) save() (saved []*model.Item, unsaved []*UnsavedItem) { 94 | saved = make([]*model.Item, 0) 95 | unsaved = make([]*UnsavedItem, 0) 96 | 97 | if len(s.Base.Params.Items) == 0 { 98 | return 99 | } 100 | 101 | for _, item := range s.Base.Params.Items { 102 | item.UserID = s.Base.User.ID 103 | 104 | if item.Deleted { 105 | s.Base.prepareDelete(item) 106 | } 107 | 108 | err := s.Base.db.Save(item) // aka item.update(..) 109 | if err != nil { 110 | // TODO return an Internal Server Error? 111 | unsaved = append(unsaved, &UnsavedItem{ 112 | Item: item, 113 | Error: errorItem{ 114 | Message: err.Error(), 115 | // There is no need of the tag. `Save` will insert or update. 116 | // https://github.com/standardfile/rails-engine/blob/cc0d40856800ab1fa9fd1aa20a03e98f8d351a0b/lib/standard_file/sync_manager.rb#L118-L123 117 | // Tag: "uuid_conflict", 118 | }, 119 | }) 120 | continue 121 | } 122 | 123 | saved = append(saved, item) 124 | } 125 | 126 | return 127 | } 128 | 129 | // Check conflicts 130 | func (s *syncService20161215) checkForConflicts() map[string]bool { 131 | // Saved is the smallest slice. 132 | saved := make(map[string]*model.Item) 133 | for _, item := range s.Saved { 134 | saved[item.ID] = item 135 | } 136 | 137 | retrieved := make(map[string]*model.Item) 138 | for _, item := range s.Retrieved { 139 | if _, ok := saved[item.ID]; ok { 140 | // Keep only items within the intersection. 141 | // There are the conflicted items to iterate on and compare to the saved one. 142 | retrieved[item.ID] = item 143 | } 144 | } 145 | 146 | tobedeleted := map[string]bool{} 147 | // Saved items take precedence, retrieved items are duplicated with a new uuid. 148 | for id, conflicted := range retrieved { 149 | diff := saved[id].UpdatedAt.Sub(*conflicted.UpdatedAt).Seconds() 150 | diff = math.Abs(diff) 151 | 152 | // If changes are greater than minConflictInterval20161215 seconds apart, 153 | // create conflicted copy, otherwise discard conflicted. 154 | if diff > minConflictInterval20161215 { 155 | s.Unsaved = append(s.Unsaved, &UnsavedItem{ 156 | Item: conflicted, 157 | Error: errorItem{ 158 | Tag: "sync_conflict", 159 | }, 160 | }) 161 | } 162 | 163 | // We remove the item from retrieved items whether or not it satisfies the minConflictInterval20161215. 164 | // This is because the 'saved' value takes precedence, since that's the current value in the database. 165 | // So by removing it from retrieved, we are forcing the client to ignore this change. 166 | tobedeleted[id] = true 167 | } 168 | 169 | return tobedeleted 170 | } 171 | -------------------------------------------------------------------------------- /internal/server/service/sync_20190520.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/mdouchement/standardfile/internal/model" 8 | "github.com/mdouchement/standardfile/pkg/libsf" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // Ignore differences that are at most this many seconds apart 13 | // Anything over this threshold will be conflicted. 14 | const minConflictIntervalMicrosecond20190520 = 1_000 15 | 16 | type ( 17 | // A syncService20190520 is a service used for syncing items. 18 | syncService20190520 struct { 19 | Base *syncServiceBase `json:"-"` 20 | // Populated during `Execute()` 21 | Retrieved []*model.Item `json:"retrieved_items"` 22 | Saved []*model.Item `json:"saved_items"` 23 | Conflicts []*ConflictItem `json:"conflicts"` 24 | SyncToken string `json:"sync_token"` 25 | CursorToken string `json:"cursor_token"` 26 | IntegrityHash string `json:"integrity_hash,omitempty"` 27 | } 28 | 29 | // A ConflictItem is an object containing an item that can't be saved caused by conflicts. 30 | ConflictItem struct { 31 | UnsavedItem *model.Item `json:"unsaved_item,omitempty"` 32 | ServerItem *model.Item `json:"server_item,omitempty"` 33 | Type string `json:"type"` 34 | } 35 | ) 36 | 37 | // Execute performs the synchronisation. 38 | func (s *syncService20190520) Execute() error { 39 | retrievedItems, overLimit, err := s.Base.get() 40 | if err != nil { 41 | return err 42 | } 43 | s.Retrieved = retrievedItems 44 | 45 | var retrievedToDelete map[string]bool 46 | s.Saved, s.Conflicts, retrievedToDelete = s.save() 47 | 48 | // Remove potential conflicted items 49 | var n int 50 | for _, item := range s.Retrieved { 51 | if retrievedToDelete[item.GetID()] { 52 | continue 53 | } 54 | 55 | s.Retrieved[n] = item 56 | n++ 57 | } 58 | s.Retrieved = s.Retrieved[:n] 59 | 60 | // In reference implementation, there is post_to_extensions but not implemented here. 61 | // See README.md 62 | 63 | if s.Base.Params.ComputeIntegrity { 64 | s.IntegrityHash, err = s.Base.computeDataSignature() 65 | if err != nil { 66 | return err 67 | } 68 | } 69 | 70 | // 71 | // Prepare returned value 72 | // 73 | 74 | // CursorToken 75 | if overLimit { 76 | s.CursorToken = libsf.TokenFromTime(*retrievedItems[s.Base.Params.Limit-1].UpdatedAt) 77 | } 78 | 79 | // SyncToken 80 | var lastUpdated time.Time 81 | for _, item := range s.Saved { 82 | if item.UpdatedAt.After(lastUpdated) { 83 | lastUpdated = *item.UpdatedAt 84 | } 85 | } 86 | if lastUpdated.IsZero() { // occurred when `len(savedItems) == 0' 87 | lastUpdated = time.Now() 88 | } 89 | 90 | // add 1 microsecond to avoid returning same object in subsequent sync. 91 | s.SyncToken = libsf.TokenFromTime(lastUpdated.Add(1 * time.Microsecond)) 92 | 93 | return nil 94 | } 95 | 96 | // Save 97 | func (s *syncService20190520) save() (saved []*model.Item, conflicts []*ConflictItem, tobedeleted map[string]bool) { 98 | saved = make([]*model.Item, 0) 99 | conflicts = make([]*ConflictItem, 0) 100 | tobedeleted = map[string]bool{} 101 | 102 | if len(s.Base.Params.Items) == 0 { 103 | return 104 | } 105 | 106 | for _, incomingItem := range s.Base.Params.Items { 107 | incomingItem.UserID = s.Base.User.ID 108 | 109 | serverItem, err := s.Base.db.FindItemByUserID(incomingItem.GetID(), s.Base.User.ID) 110 | newRecord := s.Base.db.IsNotFound(err) 111 | if err != nil && !newRecord { 112 | // TODO: return an Internal Server Error? 113 | logrus.WithError(err).Error("could not find item") 114 | conflicts = append(conflicts, &ConflictItem{ 115 | UnsavedItem: incomingItem, 116 | Type: "internal_error", // FIXME: do not exists in reference implementation. 117 | }) 118 | continue 119 | } 120 | 121 | if !newRecord { 122 | // We want to check if the incoming updated_at value is equal to the item's current updated_at value. 123 | // If they differ, it means the client is attempting to save an item which doesn't have the correct server value. 124 | // We conflict if the difference in dates is greater than the 1 unit of precision (MIN_CONFLICT_INTERVAL_MICROSECONDS) 125 | 126 | // By default incoming should equal to server item (which is desired, healthy behavior) 127 | saveIncoming := true 128 | // SFJS did not send updated_at prior to 0.3.59 but applied by the database layer so the value is OK. 129 | difference := incomingItem.UpdatedAt.Sub(*serverItem.UpdatedAt).Microseconds() 130 | 131 | switch { 132 | case difference < 0: 133 | // incoming is less than server item. This implies stale data. Don't save if greater than interval 134 | fallthrough 135 | case difference > 0: 136 | // incoming is greater than server item. Should never be the case. If so though, don't save. 137 | saveIncoming = math.Abs(float64(difference)) < minConflictIntervalMicrosecond20190520 138 | } 139 | 140 | if !saveIncoming { 141 | // Dont save incoming and send it back. At this point the server item is likely to be included 142 | // in retrievedItems in a subsequent sync, so when that value comes into the client. 143 | conflicts = append(conflicts, &ConflictItem{ 144 | ServerItem: serverItem, 145 | Type: "sync_conflict", 146 | }) 147 | tobedeleted[serverItem.GetID()] = true 148 | continue 149 | } 150 | } 151 | 152 | if incomingItem.Deleted { 153 | s.Base.prepareDelete(incomingItem) 154 | } 155 | 156 | err = s.Base.db.Save(incomingItem) // aka item.update(..) 157 | if err != nil { 158 | // TODO: return an Internal Server Error? 159 | // Type is pretty useless because `Save` will insert or update. 160 | logrus.WithError(err).Error("could not save item") 161 | conflicts = append(conflicts, &ConflictItem{ 162 | UnsavedItem: incomingItem, 163 | Type: "uuid_conflict", 164 | }) 165 | continue 166 | } 167 | 168 | saved = append(saved, incomingItem) 169 | } 170 | 171 | return 172 | } 173 | -------------------------------------------------------------------------------- /internal/server/service/user_20161215.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/golang-jwt/jwt" 8 | "github.com/mdouchement/standardfile/internal/model" 9 | "github.com/mdouchement/standardfile/internal/server/serializer" 10 | ) 11 | 12 | type userService20161215 struct { 13 | userServiceBase 14 | } 15 | 16 | func (s *userService20161215) Register(params RegisterParams) (Render, error) { 17 | return s.register(params, s.SuccessfulAuthentication, nil) 18 | } 19 | 20 | func (s *userService20161215) Login(params LoginParams) (Render, error) { 21 | return s.login(params, s.SuccessfulAuthentication, nil) 22 | } 23 | 24 | func (s *userService20161215) Update(user *model.User, params UpdateUserParams) (Render, error) { 25 | return s.update(user, params, s.SuccessfulAuthentication, nil) 26 | } 27 | 28 | func (s *userService20161215) Password(user *model.User, params UpdatePasswordParams) (Render, error) { 29 | return s.password(user, params, s.SuccessfulAuthentication, nil) 30 | } 31 | 32 | func (s *userService20161215) SuccessfulAuthentication(u *model.User, _ Params, response M) (Render, error) { 33 | if response == nil { 34 | response = M{} 35 | } 36 | 37 | response["user"] = serializer.User(u) 38 | response["token"] = s.CreateJWT(u) 39 | return response, nil 40 | } 41 | 42 | func (s *userService20161215) CreateJWT(u *model.User) string { 43 | token := jwt.New(jwt.SigningMethodHS256) 44 | claims := token.Claims.(jwt.MapClaims) 45 | claims["user_uuid"] = u.ID 46 | // claims["pw_hash"] = fmt.Sprintf("%x", sha256.Sum256([]byte(u.Password))) // See readme 47 | claims["iss"] = "github.com/mdouchement/standardfile" 48 | claims["iat"] = time.Now().Unix() // Unix Timestamp in seconds 49 | 50 | t, err := token.SignedString(s.sessions.JWTSigningKey()) 51 | if err != nil { 52 | log.Fatalf("could not generate token: %s", err) 53 | } 54 | return t 55 | } 56 | -------------------------------------------------------------------------------- /internal/server/service/user_20200115.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/mdouchement/standardfile/internal/model" 8 | "github.com/mdouchement/standardfile/internal/server/serializer" 9 | "github.com/mdouchement/standardfile/internal/server/session" 10 | sessionpkg "github.com/mdouchement/standardfile/internal/server/session" 11 | "github.com/mdouchement/standardfile/internal/sferror" 12 | "github.com/mdouchement/standardfile/pkg/libsf" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | type userService20200115 struct { 17 | userService20161215 18 | } 19 | 20 | func (s *userService20200115) Register(params RegisterParams) (Render, error) { 21 | return s.register(params, s.SuccessfulAuthentication, nil) 22 | } 23 | 24 | func (s *userService20200115) Login(params LoginParams) (Render, error) { 25 | return s.login(params, s.SuccessfulAuthentication, nil) 26 | } 27 | 28 | func (s *userService20200115) Update(user *model.User, params UpdateUserParams) (Render, error) { 29 | return s.update(user, params, s.SuccessfulAuthentication, nil) 30 | } 31 | 32 | func (s *userService20200115) Password(user *model.User, params UpdatePasswordParams) (Render, error) { 33 | // FIXME: Reference implementation creates a session only if the user uses the 004 version. 34 | // As version 004 as been released too early by mistake, this code seems now useless. 35 | // https://github.com/standardnotes/syncing-server/pull/56/files#diff-21301a75c96c49e2bf016f4c63206521R12 36 | // `upgrading_protocol_version && new_protocol_version == @user_class::SESSIONS_PROTOCOL_VERSION` 37 | 38 | return s.password(user, params, s.SuccessfulAuthentication, M{ 39 | "key_params": s.KeyParams(user), 40 | }) 41 | } 42 | 43 | func (s *userService20200115) SuccessfulAuthentication(u *model.User, params Params, response M) (Render, error) { 44 | if !session.UserSupportsSessions(u) { 45 | return s.userService20161215.SuccessfulAuthentication(u, params, response) 46 | } 47 | 48 | if response == nil { 49 | response = M{} 50 | } 51 | 52 | var err error 53 | session := params.Session 54 | if session == nil { 55 | session, err = s.CreateSession(u, params) 56 | if err != nil { 57 | return nil, err 58 | } 59 | } 60 | 61 | access, err := s.sessions.Token(session, sessionpkg.TypeAccessToken) 62 | if err != nil { 63 | return nil, errors.Wrap(err, "could not generate access token") 64 | } 65 | refresh, err := s.sessions.Token(session, sessionpkg.TypeRefreshToken) 66 | if err != nil { 67 | return nil, errors.Wrap(err, "could not generate refresh token") 68 | } 69 | 70 | response["user"] = serializer.User(u) 71 | response["session"] = echo.Map{ 72 | "access_token": access, 73 | "refresh_token": refresh, 74 | "access_expiration": s.sessions.AccessTokenExprireAt(session).UTC().UnixMilli(), 75 | "refresh_expiration": session.ExpireAt.UTC().UnixMilli(), 76 | } 77 | return response, nil 78 | } 79 | 80 | func (s *userService20200115) CreateSession(u *model.User, params Params) (*model.Session, error) { 81 | session := s.sessions.Generate() 82 | session.UserID = u.ID 83 | session.APIVersion = params.APIVersion 84 | session.UserAgent = params.UserAgent 85 | 86 | if err := s.db.Save(session); err != nil { 87 | return nil, sferror.NewWithTagCode(http.StatusBadRequest, "", "Could not create a session.") 88 | } 89 | 90 | return session, nil 91 | } 92 | 93 | func (s *userService20200115) KeyParams(u *model.User) M { 94 | params := M{ 95 | "version": u.Version, 96 | "identifier": u.Email, 97 | } 98 | 99 | switch u.Version { 100 | case libsf.ProtocolVersion2: 101 | params["email"] = u.Email 102 | params["pw_salt"] = u.PasswordSalt 103 | params["pw_auth"] = u.PasswordAuth 104 | case libsf.ProtocolVersion3: 105 | params["pw_cost"] = u.PasswordCost 106 | params["pw_nonce"] = u.PasswordNonce 107 | case libsf.ProtocolVersion4: 108 | params["pw_nonce"] = u.PasswordNonce 109 | // params["created"] = u.kp_created TODO: 110 | // params["origination"] = u.kp_origination 111 | } 112 | 113 | return params 114 | } 115 | -------------------------------------------------------------------------------- /internal/server/session/manager.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/mdouchement/middlewarex" 10 | "github.com/mdouchement/standardfile/internal/database" 11 | "github.com/mdouchement/standardfile/internal/model" 12 | "github.com/mdouchement/standardfile/internal/sferror" 13 | "github.com/o1egl/paseto/v2" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | // Defines token types. 18 | const ( 19 | TypeAccessToken = "access_token" 20 | TypeRefreshToken = "refresh_token" 21 | ) 22 | 23 | type ( 24 | // A Manager manages sessions. 25 | Manager interface { 26 | JWTSigningKey() []byte 27 | SessionSecret() []byte 28 | // Token generates the session's token for the given type t. 29 | Token(session *model.Session, t string) (string, error) 30 | // ParseToken parses the given raw token and returns the session_id and token. 31 | ParseToken(token string) (string, string, error) 32 | // Generate creates a new session without user information. 33 | Generate() *model.Session 34 | // Validate validates an access token. 35 | Validate(userID, token string) (*model.Session, error) 36 | // AccessTokenExprireAt returns the expiration date of the access token. 37 | AccessTokenExprireAt(session *model.Session) time.Time 38 | // Regenerate regenerates the session's tokens. 39 | Regenerate(session *model.Session) error 40 | // UserFromToken the user for the given token. 41 | UserFromToken(token any) (*model.User, error) 42 | } 43 | 44 | manager struct { 45 | db database.Client 46 | // JWT params 47 | signingKey []byte 48 | // Session params 49 | sessionSecret []byte 50 | accessTokenExpirationTime time.Duration 51 | refreshTokenExpirationTime time.Duration 52 | } 53 | ) 54 | 55 | // NewManager returns a new manager. 56 | func NewManager(db database.Client, signingKey, sessionSecret []byte, accessTokenExpirationTime, refreshTokenExpirationTime time.Duration) Manager { 57 | return &manager{ 58 | db: db, 59 | signingKey: signingKey, 60 | sessionSecret: sessionSecret, 61 | accessTokenExpirationTime: accessTokenExpirationTime, 62 | refreshTokenExpirationTime: refreshTokenExpirationTime, 63 | } 64 | } 65 | 66 | func (m *manager) JWTSigningKey() []byte { 67 | return m.signingKey 68 | } 69 | 70 | func (m *manager) SessionSecret() []byte { 71 | return m.sessionSecret 72 | } 73 | 74 | func (m *manager) Token(session *model.Session, t string) (string, error) { 75 | iat := session.ExpireAt.Add(-m.refreshTokenExpirationTime) 76 | 77 | claims := &paseto.JSONToken{ 78 | Issuer: "standardfile", 79 | Audience: t, 80 | Subject: session.ID, 81 | IssuedAt: iat.UTC(), 82 | NotBefore: iat.UTC(), 83 | Expiration: time.Now().Add(-72 * time.Hour).UTC(), 84 | } 85 | 86 | switch t { 87 | case TypeAccessToken: 88 | claims.Jti = session.AccessToken 89 | claims.Expiration = m.AccessTokenExprireAt(session) 90 | case TypeRefreshToken: 91 | claims.Jti = session.RefreshToken 92 | claims.Expiration = session.ExpireAt 93 | } 94 | 95 | return paseto.Encrypt(m.sessionSecret, claims, []byte{}) 96 | } 97 | 98 | func (m *manager) ParseToken(token string) (string, string, error) { 99 | var tk middlewarex.Token 100 | err := paseto.Decrypt(token, m.sessionSecret, &tk.JSONToken, &tk.Footer) 101 | return tk.Subject, tk.Jti, err 102 | } 103 | 104 | func (m *manager) Generate() *model.Session { 105 | return &model.Session{ 106 | ExpireAt: time.Now().Add(m.refreshTokenExpirationTime).UTC(), 107 | AccessToken: SecureToken(8), 108 | RefreshToken: SecureToken(8), 109 | } 110 | } 111 | 112 | func (m *manager) Validate(id, token string) (*model.Session, error) { 113 | // Check if there is an active session. 114 | session, err := m.db.FindSessionByAccessToken(id, token) 115 | if err != nil { 116 | if m.db.IsNotFound(err) { 117 | return nil, sferror.NewWithTagCode( 118 | http.StatusUnauthorized, 119 | "invalid-auth", 120 | "Invalid login credentials.", 121 | ) 122 | } 123 | return nil, errors.Wrap(err, "could not get access to database") 124 | } 125 | 126 | // Validate session. 127 | if m.isSessionExpired(session) { 128 | return nil, sferror.NewWithTagCode(http.StatusUnauthorized, "invalid-auth", "Invalid login credentials.") 129 | } 130 | 131 | if m.isAccessTokenExpired(session) { 132 | return nil, sferror.NewWithTagCode(sferror.StatusExpiredAccessToken, "expired-access-token", "The provided access token has expired.") 133 | } 134 | 135 | return session, nil 136 | } 137 | 138 | func (m *manager) AccessTokenExprireAt(session *model.Session) time.Time { 139 | return session.ExpireAt.Add(-m.refreshTokenExpirationTime).Add(m.accessTokenExpirationTime) 140 | } 141 | 142 | func (m *manager) Regenerate(session *model.Session) error { 143 | if m.isSessionExpired(session) { 144 | return sferror.NewWithTagCode( 145 | http.StatusBadRequest, 146 | "expired-refresh-token", 147 | "The refresh token has expired.", 148 | ) 149 | } 150 | 151 | session.AccessToken = SecureToken(8) 152 | session.RefreshToken = SecureToken(8) 153 | session.ExpireAt = time.Now().Add(m.refreshTokenExpirationTime) 154 | 155 | return errors.Wrap(m.db.Save(session), "could not save session after refreshing session") 156 | } 157 | 158 | func (m *manager) UserFromToken(token any) (*model.User, error) { 159 | if jwt, ok := token.(*jwt.Token); ok { 160 | return m.JWT(jwt) 161 | } 162 | return m.Paseto(token.(middlewarex.Token)) 163 | } 164 | 165 | func (m *manager) Paseto(token middlewarex.Token) (*model.User, error) { 166 | session, err := m.Validate(token.Subject, token.Jti) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | // Get current_user. 172 | user, err := m.db.FindUser(session.UserID) 173 | if err != nil { 174 | if m.db.IsNotFound(err) { 175 | return nil, sferror.NewWithTagCode( 176 | http.StatusUnauthorized, 177 | "invalid-auth", 178 | "Invalid login credentials.", 179 | ) 180 | } 181 | return nil, errors.Wrap(err, "could not get access to database") 182 | } 183 | 184 | return user, nil 185 | } 186 | 187 | func (m *manager) JWT(token *jwt.Token) (*model.User, error) { 188 | claims, ok := token.Claims.(jwt.MapClaims) 189 | if !ok { 190 | panic("token implementation has wrong type of claims") 191 | } 192 | 193 | // Get current_user. 194 | user, err := m.db.FindUser(claims["user_uuid"].(string)) 195 | if err != nil { 196 | if m.db.IsNotFound(err) { 197 | return nil, sferror.NewWithTagCode( 198 | http.StatusUnauthorized, 199 | "invalid-auth", 200 | "Invalid login credentials.", 201 | ) 202 | } 203 | return nil, errors.Wrap(err, "could not get access to database") 204 | } 205 | 206 | // Check if password has changed since token was generated. 207 | var iat int64 208 | switch v := claims["iat"].(type) { 209 | case float64: 210 | iat = int64(v) 211 | case json.Number: 212 | iat, _ = v.Int64() 213 | default: 214 | panic("unsuported iat underlying type") 215 | } 216 | 217 | if iat < user.PasswordUpdatedAt { 218 | return nil, sferror.NewWithTagCode(http.StatusUnauthorized, "invalid-auth", "Revoked token.") 219 | } 220 | 221 | return user, nil 222 | } 223 | 224 | func (m *manager) isSessionExpired(session *model.Session) bool { 225 | return session.ExpireAt.Before(time.Now()) 226 | } 227 | 228 | func (m *manager) isAccessTokenExpired(session *model.Session) bool { 229 | return m.AccessTokenExprireAt(session).Before(time.Now()) 230 | } 231 | -------------------------------------------------------------------------------- /internal/server/session/secure_token.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/subtle" 6 | "math/big" 7 | mrand "math/rand" 8 | "time" 9 | ) 10 | 11 | // SecureToken generates a unique random token. 12 | // Length should be 24 to match ActiveRecord::SecureToken used by the reference implementation. 13 | func SecureToken(length int) string { 14 | const base58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 15 | 16 | pass := make([]byte, length) 17 | chars := []byte(base58) 18 | mrand.New(mrand.NewSource(time.Now().UnixNano())).Shuffle(len(chars), func(i, j int) { 19 | chars[i], chars[j] = chars[j], chars[i] 20 | }) 21 | max := big.NewInt(int64(len(chars))) 22 | 23 | for i := 0; i < length; i++ { 24 | n, err := rand.Int(rand.Reader, max) 25 | if err != nil { 26 | panic(err) // should never occured because max >= 0 27 | } 28 | pass[i] = chars[int(n.Int64())] 29 | } 30 | 31 | return string(pass) 32 | } 33 | 34 | // SecureCompare compares the givens strings in a constant time. 35 | // So length info is not leaked via timing attacks. 36 | func SecureCompare(s1, s2 string) bool { 37 | return subtle.ConstantTimeCompare([]byte(s1), []byte(s2)) == 1 38 | } 39 | -------------------------------------------------------------------------------- /internal/server/session/secure_token_test.go: -------------------------------------------------------------------------------- 1 | package session_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/internal/server/session" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSecureToken(t *testing.T) { 11 | assert.Panics(t, func() { session.SecureToken(-1) }) 12 | assert.Len(t, session.SecureToken(24), 24) 13 | assert.Regexp(t, `^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$`, session.SecureToken(24)) 14 | 15 | n := 8192 16 | h := make(map[string]bool, 0) 17 | for i := 0; i < n; i++ { 18 | h[session.SecureToken(24)] = true 19 | } 20 | assert.Len(t, h, n, "tokens must be unique") 21 | } 22 | 23 | func TestSecureCompare(t *testing.T) { 24 | assert.True(t, session.SecureCompare("123456789", "123456789")) 25 | assert.False(t, session.SecureCompare("123456789", "123456780")) 26 | } 27 | -------------------------------------------------------------------------------- /internal/server/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/mdouchement/standardfile/internal/model" 5 | "github.com/mdouchement/standardfile/pkg/libsf" 6 | ) 7 | 8 | // SessionProtocolVersion is the account version starting the support of sessions. 9 | const SessionProtocolVersion = libsf.ProtocolVersion4 10 | 11 | // UserSupportsJWT returns true if the user supports the JWT authentication model. 12 | func UserSupportsJWT(user *model.User) bool { 13 | return libsf.VersionLesser(SessionProtocolVersion, user.Version) 14 | } 15 | 16 | // UserSupportsSessions returns true if the user supports the sessions authentication model. 17 | func UserSupportsSessions(user *model.User) bool { 18 | return libsf.VersionGreaterOrEqual(SessionProtocolVersion, user.Version) 19 | } 20 | -------------------------------------------------------------------------------- /internal/server/session/sesstion_test.go: -------------------------------------------------------------------------------- 1 | package session_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/internal/model" 7 | "github.com/mdouchement/standardfile/internal/server/session" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestUserSupportsJWT(t *testing.T) { 12 | u := &model.User{Version: "003"} 13 | assert.True(t, session.UserSupportsJWT(u)) 14 | 15 | u.Version = "004" 16 | assert.False(t, session.UserSupportsJWT(u)) 17 | } 18 | 19 | func TestUserSupportsSessions(t *testing.T) { 20 | u := &model.User{Version: "003"} 21 | assert.False(t, session.UserSupportsSessions(u)) 22 | 23 | u.Version = "004" 24 | assert.True(t, session.UserSupportsSessions(u)) 25 | } 26 | -------------------------------------------------------------------------------- /internal/server/session_handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/mdouchement/standardfile/internal/database" 8 | "github.com/mdouchement/standardfile/internal/server/serializer" 9 | sessionpkg "github.com/mdouchement/standardfile/internal/server/session" 10 | "github.com/mdouchement/standardfile/internal/sferror" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type ( 15 | sess struct { 16 | db database.Client 17 | sessions sessionpkg.Manager 18 | } 19 | 20 | refreshSessionParams struct { 21 | AccessToken string `json:"access_token"` 22 | RefreshToken string `json:"refresh_token"` 23 | } 24 | 25 | deleteSessionParams struct { 26 | ID string `json:"uuid"` 27 | } 28 | ) 29 | 30 | // List lists all active sessions for the current user. 31 | func (s *sess) List(c echo.Context) error { 32 | session := currentSession(c) 33 | user := currentUser(c) 34 | 35 | sessions, err := s.db.FindActiveSessionsByUserID(user.ID) 36 | if err != nil && !s.db.IsNotFound(err) { 37 | return errors.Wrap(err, "could not get active sessions") 38 | } 39 | 40 | for _, s := range sessions { 41 | if s.ID == session.ID { 42 | s.Current = true 43 | break 44 | } 45 | } 46 | 47 | return c.JSON(http.StatusOK, serializer.Sessions(sessions)) 48 | } 49 | 50 | // Refresh obtains a new pair of access token and refresh token. 51 | func (s *sess) Refresh(c echo.Context) error { 52 | // Filter params 53 | var params refreshSessionParams 54 | if err := c.Bind(¶ms); err != nil { 55 | return c.JSON(http.StatusBadRequest, sferror.NewWithTagCode( 56 | http.StatusBadRequest, 57 | "invalid-parameters", 58 | "Invalid request body.", 59 | )) 60 | } 61 | 62 | if params.AccessToken == "" || params.RefreshToken == "" { 63 | return c.JSON(http.StatusBadRequest, sferror.NewWithTagCode( 64 | http.StatusBadRequest, 65 | "invalid-parameters", 66 | "Please provide all required parameters.", 67 | )) 68 | } 69 | 70 | sida, access, erra := s.sessions.ParseToken(params.AccessToken) 71 | sidr, refresh, errr := s.sessions.ParseToken(params.RefreshToken) 72 | if erra != nil || errr != nil || sida != sidr { 73 | return c.JSON(http.StatusBadRequest, sferror.NewWithTagCode( 74 | http.StatusBadRequest, 75 | "invalid-parameters", 76 | "The provided parameters are not valid.", 77 | )) 78 | } 79 | 80 | // Retrieve session 81 | session, err := s.db.FindSessionByTokens(sida, access, refresh) 82 | if err != nil { 83 | if s.db.IsNotFound(err) { 84 | return c.JSON(http.StatusBadRequest, sferror.NewWithTagCode( 85 | http.StatusBadRequest, 86 | "invalid-parameters", 87 | "The provided parameters are not valid.", 88 | )) 89 | } 90 | return errors.Wrap(err, "could not get refresh session") 91 | } 92 | 93 | // Regenerate tokens 94 | if err = s.sessions.Regenerate(session); err != nil { 95 | return c.JSON(http.StatusBadRequest, sferror.NewWithTagCode( 96 | http.StatusBadRequest, 97 | "expired-refresh-token", 98 | "The refresh token has expired.", 99 | )) 100 | } 101 | 102 | access, err = s.sessions.Token(session, sessionpkg.TypeAccessToken) 103 | if err != nil { 104 | return errors.Wrap(err, "could not generate access token") 105 | } 106 | refresh, err = s.sessions.Token(session, sessionpkg.TypeRefreshToken) 107 | if err != nil { 108 | return errors.Wrap(err, "could not generate refresh token") 109 | } 110 | 111 | return c.JSON(http.StatusOK, echo.Map{ 112 | "session": echo.Map{ 113 | "access_token": access, 114 | "refresh_token": refresh, 115 | "access_expiration": s.sessions.AccessTokenExprireAt(session).UTC().UnixMilli(), 116 | "refresh_expiration": session.ExpireAt.UTC().UnixMilli(), 117 | }, 118 | }) 119 | } 120 | 121 | // Delete terminates the specified session by UUID. 122 | func (s *sess) Delete(c echo.Context) error { 123 | // Filter params 124 | params := deleteSessionParams{ 125 | ID: c.Param("id"), // Handle /v1/sessions/:id 126 | } 127 | if params.ID == "" { 128 | // Handle /session 129 | if err := c.Bind(¶ms); err != nil { 130 | return c.JSON(http.StatusBadRequest, sferror.New("Could not get session UUID.")) 131 | } 132 | } 133 | 134 | if params.ID == "" { 135 | return c.JSON(http.StatusBadRequest, sferror.New("Please provide the session identifier.")) 136 | } 137 | 138 | if params.ID == currentSession(c).ID { 139 | return c.JSON(http.StatusBadRequest, sferror.New("You can not delete your current session.")) 140 | } 141 | 142 | // Retrieve session 143 | session, err := s.db.FindSessionByUserID(params.ID, currentUser(c).ID) 144 | if err != nil { 145 | if s.db.IsNotFound(err) { 146 | return c.JSON(http.StatusBadRequest, sferror.New("No session exists with the provided identifier.")) 147 | } 148 | return errors.Wrap(err, "could not get user session") 149 | } 150 | 151 | if err = s.db.Delete(session); err != nil { 152 | return err 153 | } 154 | return c.NoContent(http.StatusNoContent) 155 | } 156 | 157 | // DeleteAll terminates all sessions, except the current one. 158 | func (s *sess) DeleteAll(c echo.Context) error { 159 | sessions, err := s.db.FindSessionsByUserID(currentUser(c).ID) 160 | if err != nil && !s.db.IsNotFound(err) { 161 | return err 162 | } 163 | 164 | current := currentSession(c) 165 | for _, session := range sessions { 166 | if session.ID == current.ID { 167 | continue 168 | } 169 | 170 | if err = s.db.Delete(session); err != nil { 171 | return err 172 | } 173 | } 174 | 175 | return c.NoContent(http.StatusNoContent) 176 | } 177 | -------------------------------------------------------------------------------- /internal/server/subscription_handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/valyala/fastjson" 9 | ) 10 | 11 | type subscription struct { 12 | SubscriptionPayload []byte 13 | FeaturesPayload []byte 14 | } 15 | 16 | func (h *subscription) SubscriptionV1(c echo.Context) error { 17 | user := currentUser(c) 18 | 19 | // The official Standard Notes client has a race condition, 20 | // the features endpoint will only be called when delaying response... 21 | time.Sleep(1 * time.Second) 22 | 23 | // Overrides some fields of the raw payload to match the current user. 24 | v, err := fastjson.ParseBytes(h.SubscriptionPayload) 25 | if err != nil { 26 | return err 27 | } 28 | v.Get("meta", "auth").Set("userUuid", new(fastjson.Arena).NewString(user.ID)) 29 | v.Get("data", "user").Set("uuid", new(fastjson.Arena).NewString(user.ID)) 30 | v.Get("data", "user").Set("email", new(fastjson.Arena).NewString(user.Email)) 31 | 32 | c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 33 | return c.String(http.StatusOK, v.String()) 34 | } 35 | 36 | func (h *subscription) Features(c echo.Context) error { 37 | user := currentUser(c) 38 | 39 | // Overrides some fields of the raw payload to match the current user. 40 | v, err := fastjson.ParseBytes(h.FeaturesPayload) 41 | if err != nil { 42 | return err 43 | } 44 | v.Get("meta", "auth").Set("userUuid", new(fastjson.Arena).NewString(user.ID)) 45 | v.Get("data").Set("userUuid", new(fastjson.Arena).NewString(user.ID)) 46 | 47 | c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 48 | return c.String(http.StatusOK, v.String()) 49 | } 50 | -------------------------------------------------------------------------------- /internal/sferror/error.go: -------------------------------------------------------------------------------- 1 | package sferror 2 | 3 | import "net/http" 4 | 5 | // StatusExpiredAccessToken is an HTTP status code used when an access token is expired. 6 | const StatusExpiredAccessToken = 498 7 | 8 | type ( 9 | // An SFError represents the error format that can be rendered by stnadardfile server. 10 | SFError struct { 11 | HTTPCode int `json:"-"` 12 | FieldError err `json:"error"` 13 | } 14 | 15 | err struct { 16 | Tag string `json:"tag,omitempty"` 17 | Message string `json:"message"` 18 | } 19 | ) 20 | 21 | // StatusCode returns the HTTP status code. 22 | func StatusCode(err error) int { 23 | if sferr, ok := err.(*SFError); ok { 24 | return sferr.HTTPCode 25 | } 26 | return http.StatusInternalServerError 27 | } 28 | 29 | // New returns a new SFError with the given message. 30 | func New(message string) *SFError { 31 | return &SFError{FieldError: err{Message: message}} 32 | } 33 | 34 | // NewWithTagCode returns a new SFError with the given code, tag and message. 35 | func NewWithTagCode(code int, tag, message string) *SFError { 36 | return &SFError{HTTPCode: code, FieldError: err{Tag: tag, Message: message}} 37 | } 38 | 39 | // Error implements error interface. 40 | func (e *SFError) Error() string { 41 | return e.FieldError.Message 42 | } 43 | -------------------------------------------------------------------------------- /internal/sferror/error_test.go: -------------------------------------------------------------------------------- 1 | package sferror_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/internal/sferror" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSFError(t *testing.T) { 11 | err := sferror.New("some message") 12 | 13 | assert.Equal(t, "some message", err.Error()) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/libsf/authentication.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/sha512" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | 10 | "golang.org/x/crypto/pbkdf2" 11 | ) 12 | 13 | var ( 14 | // ErrUnsupportedVersion is raised when user params version is lesser than `002`. 15 | ErrUnsupportedVersion = errors.New("libsf: unsupported version") 16 | // ErrLowPasswordCost occurred when cost of password is too low for the used KDF. 17 | ErrLowPasswordCost = errors.New("libsf: low password cost") 18 | ) 19 | 20 | type ( 21 | // An Auth holds all the params needed to create the credentials and cipher keys. 22 | Auth interface { 23 | // Email returns the email used for authentication. 24 | Email() string 25 | // Identifier returns the identifier (email) used for authentication. 26 | Identifier() string 27 | // Version returns the encryption scheme version. 28 | Version() string 29 | // IntegrityCheck checks if the Auth params are valid. 30 | IntegrityCheck() error 31 | // SymmetricKeyPair returns a KeyChain for the given uip (plaintext password of the user). 32 | // https://github.com/standardfile/standardfile.github.io/blob/master/doc/spec.md#client-instructions 33 | SymmetricKeyPair(uip string) *KeyChain 34 | } 35 | 36 | auth struct { 37 | FieldVersion string `json:"version"` 38 | FieldIdentifier string `json:"identifier"` 39 | FieldCost int `json:"pw_cost,omitempty"` // Before protocol 004 40 | FieldNonce string `json:"pw_nonce"` 41 | FieldOrigination string `json:"origination,omitempty"` // Since protocol 004 42 | } 43 | ) 44 | 45 | func (a *auth) Email() string { 46 | return a.FieldIdentifier 47 | } 48 | 49 | func (a *auth) Identifier() string { 50 | return a.FieldIdentifier 51 | } 52 | 53 | func (a *auth) Version() string { 54 | return a.FieldVersion 55 | } 56 | 57 | func (a *auth) IntegrityCheck() error { 58 | switch a.FieldVersion { 59 | case ProtocolVersion4: 60 | // nothing 61 | case ProtocolVersion3: 62 | if a.FieldCost < 110000 { 63 | return ErrLowPasswordCost 64 | } 65 | case ProtocolVersion2: 66 | if a.FieldCost < 3000 { 67 | return ErrLowPasswordCost 68 | } 69 | case ProtocolVersion1: 70 | fallthrough 71 | default: 72 | return ErrUnsupportedVersion 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (a *auth) SymmetricKeyPair(uip string) *KeyChain { 79 | switch a.FieldVersion { 80 | case ProtocolVersion4: 81 | return a.SymmetricKeyPair4(uip) 82 | default: 83 | return a.SymmetricKeyPair3(uip) 84 | } 85 | } 86 | 87 | func (a *auth) SymmetricKeyPair3(uip string) *KeyChain { 88 | token := fmt.Sprintf("%s:SF:%s:%d:%s", a.FieldIdentifier, a.FieldVersion, a.FieldCost, a.FieldNonce) 89 | salt := fmt.Sprintf("%x", sha256.Sum256([]byte(token))) // Hexadecimal sum 90 | 91 | // We need 3 keys of 32 length each. 92 | k := pbkdf2.Key([]byte(uip), []byte(salt), a.FieldCost, 3*32, sha512.New) 93 | key := hex.EncodeToString(k) 94 | s := len(key) / 3 95 | 96 | return &KeyChain{ 97 | Version: a.FieldVersion, 98 | Password: key[:s], 99 | MasterKey: key[s : s*2], 100 | AuthKey: key[s*2:], 101 | } 102 | } 103 | 104 | func (a *auth) SymmetricKeyPair4(uip string) *KeyChain { 105 | payload := fmt.Sprintf("%s:%s", a.FieldIdentifier, a.FieldNonce) 106 | hash := sha256.Sum256([]byte(payload)) 107 | // Taking the first 16 bytes of the hash is the same 108 | // as taking the 32 first characters of the hexa salt as described in specifications. 109 | salt := hash[:16] 110 | 111 | key, _ := kdf4([]byte(uip), salt) 112 | return &KeyChain{ 113 | Version: a.FieldVersion, 114 | MasterKey: hex.EncodeToString(key[:32]), 115 | Password: hex.EncodeToString(key[32:]), 116 | ItemsKey: map[string]string{}, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /pkg/libsf/authentication_test.go: -------------------------------------------------------------------------------- 1 | package libsf_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/pkg/libsf" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAuth_Email(t *testing.T) { 11 | email := "george.abitbol@nowhere.lan" 12 | auth := libsf.NewAuth(email, "003", "nonce", 42) 13 | 14 | assert.Equal(t, email, auth.Email()) 15 | } 16 | 17 | func TestAuth_IntegrityCheck(t *testing.T) { 18 | data := []struct { 19 | version string 20 | cost int 21 | err error 22 | }{ 23 | { 24 | version: libsf.ProtocolVersion3, 25 | cost: 110000, 26 | err: nil, 27 | }, 28 | { 29 | version: libsf.ProtocolVersion3, 30 | cost: 109999, 31 | err: libsf.ErrLowPasswordCost, 32 | }, 33 | { 34 | version: libsf.ProtocolVersion2, 35 | cost: 3000, 36 | err: nil, 37 | }, 38 | { 39 | version: libsf.ProtocolVersion2, 40 | cost: 2999, 41 | err: libsf.ErrLowPasswordCost, 42 | }, 43 | { 44 | version: libsf.ProtocolVersion1, 45 | cost: 3000, 46 | err: libsf.ErrUnsupportedVersion, 47 | }, 48 | { 49 | version: libsf.ProtocolVersion1, 50 | cost: 2999, 51 | err: libsf.ErrUnsupportedVersion, 52 | }, 53 | { 54 | version: "", 55 | cost: 3000, 56 | err: libsf.ErrUnsupportedVersion, 57 | }, 58 | { 59 | version: "", 60 | cost: 2999, 61 | err: libsf.ErrUnsupportedVersion, 62 | }, 63 | } 64 | 65 | for _, d := range data { 66 | auth := libsf.NewAuth("george.abitbol@nowhere.lan", d.version, "nonce", d.cost) 67 | 68 | assert.Equal(t, d.err, auth.IntegrityCheck()) 69 | } 70 | } 71 | 72 | func TestAuth_SymmetricKeyPair(t *testing.T) { 73 | auth := libsf.NewAuth("george.abitbol@nowhere.lan", libsf.ProtocolVersion3, "nonce", 420000) 74 | keychain := auth.SymmetricKeyPair("password42") 75 | 76 | assert.Equal(t, libsf.ProtocolVersion3, keychain.Version) 77 | assert.Equal(t, "91fe137892ea5016105162767c66088474f47eee039187d695bc129cc01afc6e", keychain.Password) 78 | assert.Equal(t, "b0edcb1b9bcdfe797a557c47a0045d72c2ad06bbc3e47f98b3676a8284a895fd", keychain.MasterKey) 79 | assert.Equal(t, "3ef83ac304168b6950ca059365a3b8d00d251b8d67ef3965210c20207de388dd", keychain.AuthKey) 80 | 81 | // 82 | 83 | auth = libsf.NewAuth("george.abitbol@nowhere.lan", libsf.ProtocolVersion4, "nonce", 0) 84 | keychain = auth.SymmetricKeyPair("password42") 85 | 86 | assert.Equal(t, libsf.ProtocolVersion4, keychain.Version) 87 | assert.Equal(t, "d89dc5c8a7719daf1160b9f2f4d858fe3d51d960b44f1487c5e2002fbb0d7b2b", keychain.Password) 88 | assert.Equal(t, "e669ef0a61bc253a884201cc1aceabfff78f29d0515aeceb8f3fdb80e35c3f79", keychain.MasterKey) 89 | assert.Equal(t, "", keychain.AuthKey) 90 | 91 | // From SNJS tests 92 | auth = libsf.NewAuth("foo@bar.com", libsf.ProtocolVersion4, "baaec0131d677cf993381367eb082fe377cefe70118c1699cb9b38f0bc850e7b", 0) 93 | keychain = auth.SymmetricKeyPair("very_secure") 94 | 95 | assert.Equal(t, libsf.ProtocolVersion4, keychain.Version) 96 | assert.Equal(t, "83707dfc837b3fe52b317be367d3ed8e14e903b2902760884fd0246a77c2299d", keychain.Password) 97 | assert.Equal(t, "5d68e78b56d454e32e1f5dbf4c4e7cf25d74dc1efc942e7c9dfce572c1f3b943", keychain.MasterKey) 98 | assert.Equal(t, "", keychain.AuthKey) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/libsf/crypto.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "crypto/rand" 5 | ) 6 | 7 | // GenerateRandomBytes returns securely generated random bytes. 8 | // It will return an error if the system's secure random 9 | // number generator fails to function correctly, in which 10 | // case the caller should not continue. 11 | func GenerateRandomBytes(n int) ([]byte, error) { 12 | b := make([]byte, n) 13 | _, err := rand.Read(b) 14 | // err == nil only if len(b) == n 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return b, nil 20 | } 21 | -------------------------------------------------------------------------------- /pkg/libsf/crypto_test.go: -------------------------------------------------------------------------------- 1 | package libsf_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/pkg/libsf" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGenerateRandomBytes(t *testing.T) { 11 | for _, v := range []int{1, 8, 16, 32, 128, 512, 8192} { 12 | salt, err := libsf.GenerateRandomBytes(v) 13 | assert.NoError(t, err) 14 | assert.Equal(t, int(v), len(salt)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pkg/libsf/doc.go: -------------------------------------------------------------------------------- 1 | // 2 | // libsf is client that interacts with StandardFile/StandardNotes API for syncing encrypted notes. 3 | // 4 | 5 | // Create client 6 | // 7 | // client, err := libsf.NewDefaultClient("https://notes.nas.lan") 8 | // if err != nil { 9 | // log.Fatal(err) 10 | // } 11 | // 12 | // Authenticate 13 | // 14 | // email := "george.abitbol@nas.lan" 15 | // password := "12345678" 16 | // 17 | // auth, err := client.GetAuthParams(email) 18 | // if err != nil { 19 | // log.Fatal(err) 20 | // } 21 | // 22 | // err = auth.IntegrityCheck() 23 | // if err != nil { 24 | // log.Fatal(err) 25 | // } 26 | // 27 | // // Create keychain containing all the keys used for encryption and authentication. 28 | // keychain := auth.SymmetricKeyPair(password) 29 | // 30 | // err = client.Login(auth.Email(), keychain.Password) 31 | // if err != nil { 32 | // log.Fatal(err) 33 | // } 34 | // 35 | // Get all items 36 | // 37 | // items := libsf.NewSyncItems() 38 | // items, err = client.SyncItems(items) // No sync_token and limit are setted so we get all items. 39 | // if err != nil { 40 | // log.Fatal(err) 41 | // } 42 | // 43 | // // Append `SN|ItemsKey` to the KeyChain. 44 | // for _, item := range items.Retrieved { 45 | // if item.ContentType != libsf.ContentTypeItemsKey { 46 | // continue 47 | // } 48 | // 49 | // err = item.Unseal(keychain) 50 | // if err != nil { 51 | // log.Fatal(err) 52 | // } 53 | // } 54 | // 55 | // var last int 56 | // for i, item := range items.Retrieved { 57 | // switch item.ContentType { 58 | // case libsf.ContentTypeUserPreferences: 59 | // // Unseal Preferences item using keychain. 60 | // err = item.Unseal(keychain) 61 | // if err != nil { 62 | // log.Fatal(err) 63 | // } 64 | // 65 | // // Parse metadata. 66 | // if err = item.Note.ParseRaw(); err != nil { 67 | // log.Fatal(err) 68 | // } 69 | // 70 | // fmt.Println("Items are sorted by:", item.Note.GetSortingField()) 71 | // case libsf.ContentTypeNote: 72 | // // Unseal Note item using keychain. 73 | // err = item.Unseal(keychain) 74 | // if err != nil { 75 | // log.Fatal(err) 76 | // } 77 | // 78 | // // Parse metadata. 79 | // if err = item.Note.ParseRaw(); err != nil { 80 | // log.Fatal(err) 81 | // } 82 | // 83 | // fmt.Println("Title:", item.Note.Title) 84 | // fmt.Println("Content:", item.Note.Text) 85 | // 86 | // last = i 87 | // } 88 | // } 89 | // 90 | // Update an item 91 | // 92 | // item := items.Retrieved[last] 93 | // item.Note.Title += " updated" 94 | // item.Note.Text += " updated" 95 | // 96 | // item.Note.SetUpdatedAtNow() 97 | // item.Note.SaveRaw() 98 | // 99 | // err = item.Seal(keychain) 100 | // if err != nil { 101 | // log.Fatal(err) 102 | // } 103 | // 104 | // // Syncing updated item. 105 | // items = libsf.NewSyncItems() 106 | // items.Items = append(items.Items, item) 107 | // items, err = client.SyncItems(items) 108 | // if err != nil { 109 | // log.Fatal(err) 110 | // } 111 | // 112 | // if len(items.Conflicts) > 0 { 113 | // log.Fatal("items conflict") 114 | // } 115 | // fmt.Println("Updated!") 116 | package libsf 117 | -------------------------------------------------------------------------------- /pkg/libsf/error.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | ) 7 | 8 | // An SFError reprensents an HTTP error returned by StandardFile server. 9 | type SFError struct { 10 | StatusCode int 11 | Err struct { 12 | Message string `json:"message"` 13 | } `json:"error"` 14 | } 15 | 16 | func parseSFError(r io.Reader, code int) error { 17 | var sferr SFError 18 | dec := json.NewDecoder(r) 19 | if err := dec.Decode(&sferr); err != nil { 20 | return err 21 | } 22 | sferr.StatusCode = code 23 | return &sferr 24 | } 25 | 26 | func (e *SFError) Error() string { 27 | return e.Err.Message 28 | } 29 | -------------------------------------------------------------------------------- /pkg/libsf/export_test.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | // This file is only for test purpose and is only loaded by test framework. 4 | 5 | // NewAuth returns a new Auth with the given parameters for test purpose. 6 | func NewAuth(email, version, nonce string, cost int) Auth { 7 | return &auth{ 8 | FieldIdentifier: email, 9 | FieldVersion: version, 10 | FieldCost: cost, 11 | FieldNonce: nonce, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/libsf/item.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | const ( 11 | // ContentTypeUserPreferences for items that holds user's preferences. 12 | ContentTypeUserPreferences = "SN|UserPreferences" 13 | // ContentTypePrivileges for items that holds note's privileges. 14 | ContentTypePrivileges = "SN|Privileges" 15 | // ContentTypeComponent are items that describes an editor extension. 16 | ContentTypeComponent = "SN|Component" 17 | // ContentTypeItemsKey are items used to encrypt Note items. 18 | ContentTypeItemsKey = "SN|ItemsKey" 19 | // ContentTypeNote are the items with user's written data. 20 | ContentTypeNote = "Note" 21 | ) 22 | 23 | type ( 24 | // A SyncItems is used when a client want to sync items. 25 | SyncItems struct { 26 | // Common fields 27 | API string `json:"api"` // Since 20190520 28 | ComputeIntegrity bool `json:"compute_integrity,omitempty"` 29 | Limit int `json:"limit,omitempty"` 30 | SyncToken string `json:"sync_token,omitempty"` 31 | CursorToken string `json:"cursor_token,omitempty"` 32 | ContentType string `json:"content_type,omitempty"` // optional, only return items of these type if present 33 | 34 | // Fields used for request 35 | Items []*Item `json:"items,omitempty"` 36 | 37 | // Fields used in response 38 | Retrieved []*Item `json:"retrieved_items,omitempty"` 39 | Saved []*Item `json:"saved_items,omitempty"` 40 | 41 | Unsaved []*UnsavedItem `json:"unsaved,omitempty"` // Before 20190520 (Since 20161215 at least) 42 | Conflicts []*ConflictItem `json:"conflicts,omitempty"` // Since 20190520 43 | } 44 | 45 | // An Item holds all the data created by end user. 46 | Item struct { 47 | ID string `json:"uuid"` 48 | CreatedAt *time.Time `json:"created_at"` 49 | UpdatedAt *time.Time `json:"updated_at"` 50 | 51 | UserID string `json:"user_uuid"` 52 | Content string `json:"content"` 53 | ContentType string `json:"content_type"` 54 | ItemsKeyID string `json:"items_key_id"` // Since 20200115 55 | EncryptedItemKey string `json:"enc_item_key"` 56 | Deleted bool `json:"deleted"` 57 | 58 | // Internal 59 | Version string `json:"-"` 60 | AuthParams Auth `json:"-"` 61 | Note *Note `json:"-"` 62 | 63 | key vault 64 | content vault 65 | } 66 | 67 | // An UnsavedItem is an object containing an item that has not been saved. 68 | // Used before API version 20190520. 69 | UnsavedItem struct { 70 | Item Item `json:"item"` 71 | Error struct { 72 | Message string `json:"message"` 73 | Tag string `json:"tag"` 74 | } `json:"error"` 75 | } 76 | 77 | // A ConflictItem is an object containing an item that can't be saved caused by conflicts. 78 | // Used since API version 20190520. 79 | ConflictItem struct { 80 | UnsavedItem Item `json:"unsaved_item,omitempty"` 81 | ServerItem Item `json:"server_item,omitempty"` 82 | Type string `json:"type"` 83 | } 84 | ) 85 | 86 | // NewSyncItems returns an empty SyncItems with initilized defaults. 87 | func NewSyncItems() SyncItems { 88 | return SyncItems{ 89 | Items: []*Item{}, 90 | Retrieved: []*Item{}, 91 | Saved: []*Item{}, 92 | Unsaved: []*UnsavedItem{}, 93 | Conflicts: []*ConflictItem{}, 94 | } 95 | } 96 | 97 | // Seal encrypts Note to item's Content. 98 | func (i *Item) Seal(keychain *KeyChain) error { 99 | // 100 | // Key 101 | // 102 | 103 | ik, err := keychain.GenerateItemEncryptionKey() 104 | if err != nil { 105 | return errors.Wrap(err, "could not generate encryption key") 106 | } 107 | 108 | old := i.key 109 | i.key, err = create(i.Version, i.ID) 110 | if err != nil { 111 | return errors.Wrap(err, "could not create vault") 112 | } 113 | i.key.setup(i, old) 114 | 115 | err = i.key.seal(keyKeyChain(keychain, i), []byte(ik)) 116 | if err != nil { 117 | return errors.Wrap(err, "EncryptedItemKey") 118 | } 119 | 120 | i.EncryptedItemKey, err = i.key.serialize() 121 | if err != nil { 122 | return errors.Wrap(err, "EncryptedItemKey") 123 | } 124 | 125 | // 126 | // Content 127 | // 128 | 129 | note, err := json.Marshal(i.Note) 130 | if err != nil { 131 | return errors.Wrap(err, "could not serialize note") 132 | } 133 | 134 | old = i.content 135 | i.content, err = create(i.Version, i.ID) 136 | if err != nil { 137 | return errors.Wrap(err, "could not create content vault") 138 | } 139 | i.content.setup(i, old) 140 | 141 | err = i.content.seal(contentKeyChain(i.Version, ik), note) 142 | if err != nil { 143 | return errors.Wrap(err, "Content") 144 | } 145 | 146 | i.Content, err = i.content.serialize() 147 | return errors.Wrap(err, "Content") 148 | } 149 | 150 | // Unseal decrypts the item's Content into Note. 151 | // `SN|ItemsKey` are append in the provided KeyChain. 152 | func (i *Item) Unseal(keychain *KeyChain) error { 153 | // 154 | // Key 155 | // 156 | 157 | if i.EncryptedItemKey == "" { 158 | return errors.New("missing item encryption key") 159 | } 160 | 161 | v, err := parse(i.EncryptedItemKey, i.ID) 162 | if err != nil { 163 | return errors.Wrap(err, "EncryptedItemKey") 164 | } 165 | i.key = v 166 | i.key.configure(i) 167 | 168 | ik, err := i.key.unseal(keyKeyChain(keychain, i)) 169 | if err != nil { 170 | return errors.Wrap(err, "EncryptedItemKey") 171 | } 172 | 173 | // 174 | // Content 175 | // 176 | 177 | v, err = parse(i.Content, i.ID) 178 | if err != nil { 179 | return errors.Wrap(err, "Content") 180 | } 181 | i.content = v 182 | 183 | payload, err := i.content.unseal(contentKeyChain(i.Version, string(ik))) 184 | if err != nil { 185 | return errors.Wrap(err, "Content") 186 | } 187 | 188 | switch i.ContentType { 189 | case ContentTypeItemsKey: 190 | v := &struct { 191 | ItemKeys string `json:"itemsKey"` 192 | }{} 193 | 194 | err = json.Unmarshal(payload, v) 195 | keychain.ItemsKey[i.ID] = v.ItemKeys 196 | return errors.Wrap(err, "could not parse items key") 197 | case ContentTypeUserPreferences: 198 | fallthrough 199 | case ContentTypeNote: 200 | i.Note = new(Note) 201 | err = json.Unmarshal(payload, i.Note) 202 | return errors.Wrap(err, "could not parse note") 203 | } 204 | 205 | return errors.Errorf("Unsupported unseal for %s", i.ContentType) 206 | } 207 | -------------------------------------------------------------------------------- /pkg/libsf/keychain.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "crypto/sha512" 5 | "encoding/hex" 6 | 7 | "github.com/pkg/errors" 8 | "golang.org/x/crypto/pbkdf2" 9 | ) 10 | 11 | // A KeyChain contains all the keys used for encryption and authentication. 12 | type KeyChain struct { 13 | Version string `json:"version"` 14 | Password string `json:"password,omitempty"` // Server's password 15 | MasterKey string `json:"mk"` 16 | AuthKey string `json:"ak,omitempty"` // Before protocol 004 17 | ItemsKey map[string]string `json:"-"` // Since protocol 004 18 | } 19 | 20 | func keyKeyChain(k *KeyChain, i *Item) *KeyChain { 21 | switch k.Version { 22 | case ProtocolVersion4: 23 | if i.ContentType != ContentTypeItemsKey { 24 | return &KeyChain{Version: k.Version, MasterKey: k.ItemsKey[i.ItemsKeyID]} 25 | } 26 | } 27 | 28 | return k 29 | } 30 | 31 | func contentKeyChain(version string, k string) *KeyChain { 32 | switch version { 33 | case ProtocolVersion2: 34 | fallthrough 35 | case ProtocolVersion3: 36 | // Split item key in encryption key and auth key 37 | return &KeyChain{Version: version, MasterKey: k[:len(k)/2], AuthKey: k[len(k)/2:]} 38 | case ProtocolVersion4: 39 | return &KeyChain{Version: version, MasterKey: k} 40 | } 41 | 42 | return &KeyChain{} 43 | } 44 | 45 | // GenerateItemEncryptionKey generates a key used to encrypt item's content. 46 | // ProtocolVersion3 is a 512 length bytes key that will be split in half, each being 256 bits. 47 | // ProtocolVersion4 is a 32 length bytes key that be used as is. 48 | func (k *KeyChain) GenerateItemEncryptionKey() (string, error) { 49 | switch k.Version { 50 | case ProtocolVersion2: 51 | fallthrough 52 | case ProtocolVersion3: 53 | passphrase, err := GenerateRandomBytes(512) 54 | if err != nil { 55 | return "", errors.Wrap(err, "vaut3: passphrase") 56 | } 57 | 58 | salt, err := GenerateRandomBytes(512) 59 | if err != nil { 60 | return "", errors.Wrap(err, "vaut3: salt") 61 | } 62 | 63 | ik := pbkdf2.Key(passphrase, salt, 1, 2*32, sha512.New) 64 | return hex.EncodeToString(ik), nil 65 | // 66 | // 67 | case ProtocolVersion4: 68 | ik, err := GenerateRandomBytes(32) 69 | if err != nil { 70 | return "", errors.Wrap(err, "vaut4: key") 71 | } 72 | return hex.EncodeToString(ik), nil 73 | } 74 | 75 | return "", errors.Errorf("Unsupported version: %s", k.Version) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/libsf/keychain_test.go: -------------------------------------------------------------------------------- 1 | package libsf_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/pkg/libsf" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestKeychain_GenerateItemEncryptionKey(t *testing.T) { 11 | keychain := &libsf.KeyChain{} 12 | 13 | keychain.Version = libsf.ProtocolVersion2 14 | ik, err := keychain.GenerateItemEncryptionKey() 15 | assert.NoError(t, err) 16 | assert.Equal(t, 128, len(ik)) 17 | 18 | keychain.Version = libsf.ProtocolVersion3 19 | ik, err = keychain.GenerateItemEncryptionKey() 20 | assert.NoError(t, err) 21 | assert.Equal(t, 128, len(ik)) 22 | 23 | keychain.Version = libsf.ProtocolVersion4 24 | ik, err = keychain.GenerateItemEncryptionKey() 25 | assert.NoError(t, err) 26 | assert.Equal(t, 64, len(ik)) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/libsf/note.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/valyala/fastjson" 9 | ) 10 | 11 | const noteTimeLayout = "2006-01-02T15:04:05.000Z" 12 | 13 | // A Note is plaintext Item content. 14 | type Note struct { 15 | Title string `json:"title"` 16 | Text string `json:"text"` 17 | PreviewPlain string `json:"preview_plain"` 18 | PreviewHTML string `json:"preview_html"` 19 | References json.RawMessage `json:"references"` // unstructured data 20 | AppData json.RawMessage `json:"appData"` // unstructured data 21 | 22 | appdata *fastjson.Value 23 | } 24 | 25 | // ParseRaw parses unstructured raw fields. 26 | // Needed before using other methods on Note object. 27 | func (n *Note) ParseRaw() error { 28 | v, err := fastjson.Parse(string(n.AppData)) 29 | if err != nil { 30 | return errors.Wrap(err, "could not parse raw data") 31 | } 32 | 33 | n.appdata = v 34 | return nil 35 | } 36 | 37 | // SaveRaw persists the unstructured fields to raw data. 38 | func (n *Note) SaveRaw() { 39 | n.AppData = json.RawMessage(n.appdata.String()) 40 | } 41 | 42 | // UpdatedAt returns the last update time of the note. 43 | // If not found or error, "zero" time is returned. 44 | func (n *Note) UpdatedAt() time.Time { 45 | if n.appdata.Exists("org.standardnotes.sn", "client_updated_at") { 46 | s := string(n.appdata.GetStringBytes("org.standardnotes.sn", "client_updated_at")) 47 | 48 | t, err := time.Parse(noteTimeLayout, s) 49 | if err != nil { 50 | return time.Time{} 51 | } 52 | 53 | return t 54 | } 55 | 56 | return time.Time{} 57 | } 58 | 59 | // SetUpdatedAtNow sets current time as last update time. 60 | func (n *Note) SetUpdatedAtNow() { 61 | s := time.Now().Format(noteTimeLayout) 62 | 63 | n.appdata. 64 | Get("org.standardnotes.sn"). 65 | Set("client_updated_at", new(fastjson.Arena).NewString(s)) 66 | } 67 | 68 | // GetSortingField returns the field on which all notes are sorted. 69 | // Only for `SN|UserPreferences` items, it returns an empty string if nothing found. 70 | func (n *Note) GetSortingField() string { 71 | if n.appdata.Exists("org.standardnotes.sn", "sortBy") { 72 | return string( 73 | n.appdata.Get("org.standardnotes.sn", "sortBy").GetStringBytes(), 74 | ) 75 | } 76 | return "" 77 | } 78 | -------------------------------------------------------------------------------- /pkg/libsf/session.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | // A Session contains details about a session. 9 | type Session struct { 10 | AccessToken string `json:"access_token"` 11 | RefreshToken string `json:"refresh_token"` 12 | AccessExpiration time.Time `json:"access_expiration"` 13 | RefreshExpiration time.Time `json:"refresh_expiration"` 14 | } 15 | 16 | // Defined returns true if session's fields are defined. 17 | func (s Session) Defined() bool { 18 | return s.AccessToken != "" && s.RefreshToken != "" && 19 | !s.AccessExpiration.IsZero() && !s.RefreshExpiration.IsZero() 20 | } 21 | 22 | // AccessExpiredAt returns true if the access token is expired at the given time. 23 | func (s Session) AccessExpiredAt(t time.Time) bool { 24 | return !s.Defined() || t.After(s.AccessExpiration) 25 | } 26 | 27 | // AccessExpired returns true if the access token is expired. 28 | func (s Session) AccessExpired() bool { 29 | return s.AccessExpiredAt(time.Now()) 30 | } 31 | 32 | // RefreshExpired returns true if the refresh token is expired. 33 | func (s Session) RefreshExpired() bool { 34 | return !s.Defined() || time.Now().After(s.RefreshExpiration) 35 | } 36 | 37 | func (s *Session) UnmarshalJSON(data []byte) error { 38 | session := struct { 39 | AccessToken string `json:"access_token"` 40 | RefreshToken string `json:"refresh_token"` 41 | AccessExpiration int64 `json:"access_expiration"` 42 | RefreshExpiration int64 `json:"refresh_expiration"` 43 | }{} 44 | 45 | err := json.Unmarshal(data, &session) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | s.AccessToken = session.AccessToken 51 | s.RefreshToken = session.RefreshToken 52 | s.AccessExpiration = time.UnixMilli(session.AccessExpiration) 53 | s.RefreshExpiration = time.UnixMilli(session.RefreshExpiration) 54 | return nil 55 | } 56 | 57 | func (s Session) MarshalJSON() ([]byte, error) { 58 | session := struct { 59 | AccessToken string `json:"access_token"` 60 | RefreshToken string `json:"refresh_token"` 61 | AccessExpiration int64 `json:"access_expiration"` 62 | RefreshExpiration int64 `json:"refresh_expiration"` 63 | }{ 64 | AccessToken: s.AccessToken, 65 | RefreshToken: s.RefreshToken, 66 | AccessExpiration: s.AccessExpiration.UnixMilli(), 67 | RefreshExpiration: s.RefreshExpiration.UnixMilli(), 68 | } 69 | 70 | return json.Marshal(session) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/libsf/token.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // TimeFromToken retrieves datetime from cursor/sync token. 13 | func TimeFromToken(token string) time.Time { 14 | raw, err := base64.URLEncoding.DecodeString(token) // meh, there aren't none ASCII characters in a Unix timestamp. 15 | if err != nil { 16 | log.Println("GetTimeFromToken:", err) 17 | return time.Now().UTC() 18 | } 19 | 20 | parts := strings.Split(string(raw), ":") 21 | if parts[0] == "1" { 22 | // Do not support v1 `1:474536275' (Unix timestamp in seconds) 23 | log.Println("GetTimeFromToken: unsupported v1 token date") 24 | return time.Now().UTC() 25 | } 26 | 27 | // v2 token `1:4745362752134567' (Unix timestamp in nanoseconds) 28 | timestamp, err := strconv.ParseInt(parts[1], 10, 64) 29 | if err != nil { 30 | log.Println("GetTimeFromToken:", err) 31 | return time.Now().UTC() 32 | } 33 | return time.Unix(0, timestamp).UTC() 34 | } 35 | 36 | // TokenFromTime generates cursor/sync token for given time. 37 | func TokenFromTime(t time.Time) (token string) { 38 | token = fmt.Sprintf("2:%d", t.UTC().UnixNano()) 39 | 40 | // meh, there aren't none ASCII characters in a Unix timestamp. 41 | return base64.URLEncoding.EncodeToString([]byte(token)) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/libsf/token_test.go: -------------------------------------------------------------------------------- 1 | package libsf_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/mdouchement/standardfile/pkg/libsf" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTimeFromToken(t *testing.T) { 14 | now := time.Now() 15 | pretoken := fmt.Sprintf("2:%d", now.UnixNano()) 16 | token := base64.URLEncoding.EncodeToString([]byte(pretoken)) 17 | 18 | assert.WithinDuration(t, now, libsf.TimeFromToken(token), 1*time.Nanosecond) 19 | } 20 | 21 | func TestTokenFromTime(t *testing.T) { 22 | now := time.Now() 23 | pretoken := fmt.Sprintf("2:%d", now.UnixNano()) 24 | token := base64.URLEncoding.EncodeToString([]byte(pretoken)) 25 | 26 | assert.Equal(t, token, libsf.TokenFromTime(now)) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/libsf/vault.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type vault interface { 10 | setup(i *Item, old vault) 11 | seal(keychain *KeyChain, payload []byte) error 12 | serialize() (string, error) 13 | unseal(keychain *KeyChain) ([]byte, error) 14 | configure(i *Item) 15 | } 16 | 17 | //// 18 | /// 19 | // 20 | 21 | func create(version, id string) (vault, error) { 22 | switch version { 23 | case ProtocolVersion2: 24 | fallthrough 25 | case ProtocolVersion3: 26 | return &vault3{ 27 | version: version, 28 | uuid: id, 29 | }, nil 30 | case ProtocolVersion4: 31 | return &vault4{ 32 | version: version, 33 | auth: authenticatedData{ 34 | Version: version, 35 | UserID: id, 36 | }, 37 | }, nil 38 | default: 39 | return nil, errors.New("unsupported secret version") 40 | } 41 | } 42 | 43 | //// 44 | /// 45 | // 46 | 47 | func parse(secret, id string) (vault, error) { 48 | components := strings.Split(secret, ":") 49 | 50 | if len(components) == 0 { 51 | return nil, errors.New("invalid secret format/length") 52 | } 53 | 54 | switch components[0] { 55 | case ProtocolVersion2: 56 | fallthrough 57 | case ProtocolVersion3: 58 | return parse3(components, id) 59 | case ProtocolVersion4: 60 | return parse4(components) 61 | default: 62 | return nil, errors.New("unsupported secret version") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/libsf/vault3.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/hmac" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "encoding/hex" 10 | "encoding/json" 11 | "fmt" 12 | 13 | "github.com/d1str0/pkcs7" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | type vault3 struct { 18 | version string 19 | auth string // encrypted ciphertext hmac 20 | uuid string // Item ID 21 | iv string // AES's iv 22 | ciphertext string 23 | params Auth // AuthParams json+base64 encoded 24 | } 25 | 26 | //// 27 | /// 28 | // 29 | 30 | func parse3(components []string, id string) (vault, error) { 31 | v := &vault3{} 32 | 33 | if len(components) < 5 || len(components) > 6 { 34 | return v, errors.New("invalid secret format") 35 | } 36 | 37 | v.version = components[0] 38 | v.auth = components[1] 39 | v.uuid = components[2] 40 | if v.uuid != id { 41 | return v, errors.New("missmatch between key params UUID and item UUID") 42 | } 43 | 44 | v.iv = components[3] 45 | v.ciphertext = components[4] 46 | 47 | if len(components) == 6 { 48 | params, err := base64.StdEncoding.DecodeString(components[5]) 49 | if err != nil { 50 | return v, errors.Wrap(err, "could not decode params") 51 | } 52 | 53 | var a auth 54 | err = json.Unmarshal(params, &a) 55 | if err != nil { 56 | return v, errors.Wrap(err, "could not parse params") 57 | } 58 | v.params = &a 59 | } 60 | 61 | return v, nil 62 | } 63 | 64 | //// 65 | /// 66 | // 67 | 68 | func (v *vault3) seal(keychain *KeyChain, payload []byte) error { 69 | dek, err := hex.DecodeString(keychain.MasterKey) 70 | if err != nil { 71 | return errors.Wrap(err, "could not decode EK") 72 | } 73 | 74 | // 75 | // Encrypting 76 | 77 | block, err := aes.NewCipher(dek) 78 | if err != nil { 79 | return errors.Wrap(err, "could not create cipher") 80 | } 81 | 82 | ciphertext, err := pkcs7.Pad(payload, block.BlockSize()) 83 | if err != nil { 84 | return errors.Wrap(err, "could not pkcs7 pad ciphertext") 85 | } 86 | 87 | div, err := GenerateRandomBytes(block.BlockSize()) 88 | if err != nil { 89 | return errors.Wrap(err, "could not generate IV") 90 | } 91 | 92 | mode := cipher.NewCBCEncrypter(block, div) 93 | mode.CryptBlocks(ciphertext, ciphertext) 94 | 95 | // 96 | // Encoding 97 | 98 | v.iv = hex.EncodeToString(div) 99 | v.ciphertext = base64.StdEncoding.EncodeToString(ciphertext) 100 | v.auth, err = v.computeAuth(keychain.AuthKey) 101 | if err != nil { 102 | return errors.Wrap(err, "authenticate") 103 | } 104 | return nil 105 | } 106 | 107 | //// 108 | /// 109 | // 110 | 111 | // EncryptionKey & AuthKey 112 | func (v *vault3) unseal(keychain *KeyChain) ([]byte, error) { 113 | localAuth, err := v.computeAuth(keychain.AuthKey) 114 | if err != nil { 115 | return nil, errors.Wrap(err, "authenticate") 116 | } 117 | 118 | if localAuth != v.auth { 119 | return nil, errors.New("hash does not match") 120 | } 121 | 122 | // 123 | // Decoding 124 | 125 | dek, err := hex.DecodeString(keychain.MasterKey) 126 | if err != nil { 127 | return nil, errors.Wrap(err, "could not decode EK") 128 | } 129 | 130 | div, err := hex.DecodeString(v.iv) 131 | if err != nil { 132 | return nil, errors.Wrap(err, "could not decode IV") 133 | } 134 | 135 | ciphertext, err := base64.StdEncoding.DecodeString(v.ciphertext) 136 | if err != nil { 137 | return nil, errors.Wrap(err, "could not decode ciphertext") 138 | } 139 | 140 | // 141 | // Decrypting 142 | 143 | block, err := aes.NewCipher(dek) 144 | if err != nil { 145 | return nil, errors.Wrap(err, "could not create cipher") 146 | } 147 | 148 | mode := cipher.NewCBCDecrypter(block, div) 149 | mode.CryptBlocks(ciphertext, ciphertext) 150 | 151 | ciphertext, err = pkcs7.Unpad(ciphertext) 152 | if err != nil { 153 | return nil, errors.Wrap(err, "could not pkcs7 unpad ciphertext") 154 | } 155 | 156 | return ciphertext, nil 157 | } 158 | 159 | //// 160 | /// 161 | // 162 | 163 | func (v *vault3) setup(i *Item, _ vault) { 164 | v.params = i.AuthParams 165 | } 166 | 167 | func (v *vault3) configure(i *Item) { 168 | i.Version = v.version 169 | i.AuthParams = v.params 170 | } 171 | 172 | //// 173 | /// 174 | // 175 | 176 | func (v *vault3) computeAuth(ak string) (string, error) { 177 | dak, err := hex.DecodeString(ak) 178 | if err != nil { 179 | return "", errors.Wrap(err, "could not decode AK") 180 | } 181 | 182 | ciphertextToAuth := fmt.Sprintf("%s:%s:%s:%s", v.version, v.uuid, v.iv, v.ciphertext) 183 | 184 | mac := hmac.New(sha256.New, dak) 185 | if _, err = mac.Write([]byte(ciphertextToAuth)); err != nil { 186 | return "", errors.Wrap(err, "could not hmac256") 187 | } 188 | 189 | return hex.EncodeToString(mac.Sum(nil)), nil 190 | } 191 | 192 | //// 193 | /// 194 | // 195 | 196 | func (v *vault3) serialize() (string, error) { 197 | if v.params == nil { 198 | return fmt.Sprintf("%s:%s:%s:%s:%s", v.version, v.auth, v.uuid, v.iv, v.ciphertext), nil 199 | } 200 | 201 | a, err := json.Marshal(v.params) 202 | if err != nil { 203 | return "", errors.Wrap(err, "could not serialize params") 204 | } 205 | 206 | return fmt.Sprintf("%s:%s:%s:%s:%s:%s", v.version, v.auth, v.uuid, v.iv, v.ciphertext, base64.StdEncoding.EncodeToString(a)), nil 207 | } 208 | -------------------------------------------------------------------------------- /pkg/libsf/vault4.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | "golang.org/x/crypto/argon2" 12 | "golang.org/x/crypto/chacha20poly1305" 13 | ) 14 | 15 | type ( 16 | vault4 struct { 17 | version string 18 | nonce string 19 | ciphertext string 20 | rawauth string 21 | auth authenticatedData 22 | additionaldata string 23 | } 24 | 25 | authenticatedData struct { 26 | from string 27 | Version string `json:"v"` 28 | UserID string `json:"u"` 29 | KeyParams auth `json:"kp,omitempty"` 30 | } 31 | ) 32 | 33 | //// 34 | /// 35 | // 36 | 37 | func parse4(components []string) (vault, error) { 38 | v := &vault4{} 39 | 40 | if len(components) < 4 || len(components) > 5 { 41 | return v, errors.New("invalid secret format") 42 | } 43 | 44 | // https://github.com/standardnotes/app/blob/main/packages/snjs/specification.md#encryption---specifics 45 | v.version = components[0] 46 | v.nonce = components[1] 47 | v.ciphertext = components[2] 48 | v.rawauth = components[3] 49 | if len(components) > 4 { 50 | // This part is not defined in the specification but implemented in StandardNotes official implementation. 51 | // https://github.com/standardnotes/app/commit/b032eb9c9b4b98a1a256d3d03863866bb4136ec8#diff-cb607afd3ffe76488f6ba1f7885d16f853810e799c9f223a1dd7673d15928396 52 | v.additionaldata = components[4] // Default value is `e30=' (aka `{}' in base64). 53 | } 54 | 55 | auth, err := base64.StdEncoding.DecodeString(v.rawauth) 56 | if err != nil { 57 | return v, errors.Wrap(err, "could not decode params") 58 | } 59 | 60 | err = json.Unmarshal(auth, &v.auth) 61 | if err != nil { 62 | return v, errors.Wrap(err, "could not parse params") 63 | } 64 | 65 | return v, nil 66 | } 67 | 68 | //// 69 | /// 70 | // 71 | 72 | func (v *vault4) seal(keychain *KeyChain, payload []byte) error { 73 | dek, err := hex.DecodeString(keychain.MasterKey) 74 | if err != nil { 75 | return errors.Wrap(err, "could not decode EK") 76 | } 77 | 78 | auth := v.auth.toSortedKeysJSON() 79 | v.rawauth = base64.StdEncoding.EncodeToString(auth) 80 | 81 | // 82 | // Encrypting 83 | 84 | nonce, err := GenerateRandomBytes(24) 85 | if err != nil { 86 | return errors.Wrap(err, "could not generate nonce") 87 | } 88 | 89 | aead, err := chacha20poly1305.NewX(dek) 90 | if err != nil { 91 | return errors.Wrap(err, "could not create cipher") 92 | } 93 | 94 | ciphertext := aead.Seal(nil, nonce, payload, []byte(v.rawauth)) 95 | 96 | // 97 | // Encoding 98 | 99 | v.nonce = hex.EncodeToString(nonce) 100 | v.ciphertext = base64.StdEncoding.EncodeToString(ciphertext) 101 | return nil 102 | } 103 | 104 | //// 105 | /// 106 | // 107 | 108 | // EncryptionKey & AuthKey 109 | func (v *vault4) unseal(keychain *KeyChain) ([]byte, error) { 110 | // 111 | // Decoding 112 | 113 | dek, err := hex.DecodeString(keychain.MasterKey) 114 | if err != nil { 115 | return nil, errors.Wrap(err, "could not decode EK") 116 | } 117 | 118 | nonce, err := hex.DecodeString(v.nonce) 119 | if err != nil { 120 | return nil, errors.Wrap(err, "could not decode nonce") 121 | } 122 | 123 | ciphertext, err := base64.StdEncoding.DecodeString(v.ciphertext) 124 | if err != nil { 125 | return nil, errors.Wrap(err, "could not decode ciphertext") 126 | } 127 | 128 | // 129 | // Decrypting 130 | 131 | aead, err := chacha20poly1305.NewX(dek) 132 | if err != nil { 133 | return nil, errors.Wrap(err, "could not create cipher") 134 | } 135 | 136 | ciphertext, err = aead.Open(nil, nonce, ciphertext, []byte(v.rawauth)) 137 | if err != nil { 138 | return nil, errors.Wrap(err, "could not decrypt") 139 | } 140 | 141 | return ciphertext, nil 142 | } 143 | 144 | //// 145 | /// 146 | // 147 | 148 | func (v *vault4) setup(i *Item, old vault) { 149 | v.auth.KeyParams = *i.AuthParams.(*auth) 150 | v.auth.from = i.ContentType 151 | 152 | if vault, ok := old.(*vault4); ok { 153 | // Forward additional data from vault used to unseal to the new one created for sealing. 154 | v.additionaldata = vault.additionaldata 155 | } 156 | } 157 | 158 | func (v *vault4) configure(i *Item) { 159 | i.Version = v.version 160 | i.AuthParams = &v.auth.KeyParams 161 | } 162 | 163 | //// 164 | /// 165 | // 166 | 167 | func (v *vault4) serialize() (string, error) { 168 | payload := fmt.Sprintf("%s:%s:%s:%s", v.version, v.nonce, v.ciphertext, v.rawauth) 169 | if v.additionaldata != "" { 170 | payload = fmt.Sprintf("%s:%s", payload, v.additionaldata) 171 | } 172 | return payload, nil 173 | } 174 | 175 | //// 176 | /// 177 | // 178 | 179 | func (d *authenticatedData) toSortedKeysJSON() []byte { 180 | values := []string{} 181 | if d.from == ContentTypeItemsKey { 182 | auth := []string{} 183 | auth = append(auth, fmt.Sprintf(`"identifier":"%s"`, d.KeyParams.FieldIdentifier)) 184 | auth = append(auth, fmt.Sprintf(`"origination":"%s"`, d.KeyParams.FieldOrigination)) 185 | auth = append(auth, fmt.Sprintf(`"pw_nonce":"%s"`, d.KeyParams.FieldNonce)) 186 | auth = append(auth, fmt.Sprintf(`"version":"%s"`, d.KeyParams.Version())) 187 | 188 | values = append(values, fmt.Sprintf(`"kp":{%s}`, strings.Join(auth, ","))) 189 | } 190 | values = append(values, fmt.Sprintf(`"u":"%s"`, d.UserID)) 191 | values = append(values, fmt.Sprintf(`"v":"%s"`, d.Version)) 192 | 193 | return []byte(fmt.Sprintf("{%s}", strings.Join(values, ","))) 194 | } 195 | 196 | //// 197 | /// 198 | // 199 | 200 | // nolint:deadcode,unused 201 | func kdf4s(password, salt string) ([]byte, error) { 202 | s, err := hex.DecodeString(salt) 203 | if err != nil { 204 | return nil, errors.Wrap(err, "salt is not an hexadecimal string") 205 | } 206 | 207 | return kdf4([]byte(password), s) 208 | } 209 | 210 | func kdf4(password, salt []byte) (k []byte, err error) { 211 | if len(salt) == 0 { 212 | salt, err = GenerateRandomBytes(16) 213 | if err != nil { 214 | return nil, errors.Wrap(err, "could not generate salt argon2id") 215 | } 216 | } 217 | 218 | return argon2.IDKey(password, salt, 5, 64<<10, 1, 64), nil 219 | } 220 | -------------------------------------------------------------------------------- /pkg/libsf/version.go: -------------------------------------------------------------------------------- 1 | package libsf 2 | 3 | import "strconv" 4 | 5 | const ( 6 | // APIVersion20161215 allows to use the API version 20161215. 7 | APIVersion20161215 = "20161215" 8 | // APIVersion20190520 allows to use the API version 20190520. 9 | APIVersion20190520 = "20190520" 10 | // APIVersion20200115 allows to use the API version 20200115. 11 | APIVersion20200115 = "20200115" 12 | 13 | // APIVersion is the version used by default client. 14 | APIVersion = APIVersion20200115 15 | ) 16 | 17 | const ( 18 | // ProtocolVersion1 allows to use the SF protocol 001. 19 | ProtocolVersion1 = "001" 20 | // ProtocolVersion2 allows to use the SF protocol 002. 21 | ProtocolVersion2 = "002" 22 | // ProtocolVersion3 allows to use the SF protocol 003. 23 | ProtocolVersion3 = "003" 24 | // ProtocolVersion4 allows to use the SF protocol 004. 25 | ProtocolVersion4 = "004" 26 | ) 27 | 28 | // VersionGreaterOrEqual returns true if current is not empty and greater or equal to version. 29 | func VersionGreaterOrEqual(version, current string) bool { 30 | if current == "" { 31 | return false 32 | } 33 | 34 | v, err := strconv.Atoi(version) 35 | if err != nil { 36 | return false 37 | } 38 | 39 | c, err := strconv.Atoi(current) 40 | if err != nil { 41 | return false 42 | } 43 | 44 | return c >= v 45 | } 46 | 47 | // VersionLesser returns true if current is empty or lesser to version. 48 | func VersionLesser(version, current string) bool { 49 | if current == "" { 50 | return true 51 | } 52 | 53 | v, err := strconv.Atoi(version) 54 | if err != nil { 55 | return false 56 | } 57 | 58 | c, err := strconv.Atoi(current) 59 | if err != nil { 60 | return false 61 | } 62 | 63 | return c < v 64 | } 65 | -------------------------------------------------------------------------------- /pkg/libsf/version_test.go: -------------------------------------------------------------------------------- 1 | package libsf_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mdouchement/standardfile/pkg/libsf" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestVersionGreaterOrEqual(t *testing.T) { 11 | assert.False(t, libsf.VersionGreaterOrEqual("bad", "1")) 12 | assert.False(t, libsf.VersionGreaterOrEqual("1", "bad")) 13 | 14 | assert.False(t, libsf.VersionGreaterOrEqual(libsf.ProtocolVersion3, libsf.ProtocolVersion2)) 15 | assert.True(t, libsf.VersionGreaterOrEqual(libsf.ProtocolVersion3, libsf.ProtocolVersion3)) 16 | assert.True(t, libsf.VersionGreaterOrEqual(libsf.ProtocolVersion3, libsf.ProtocolVersion4)) 17 | 18 | assert.False(t, libsf.VersionGreaterOrEqual(libsf.APIVersion20190520, libsf.APIVersion20161215)) 19 | assert.True(t, libsf.VersionGreaterOrEqual(libsf.APIVersion20190520, libsf.APIVersion20190520)) 20 | assert.True(t, libsf.VersionGreaterOrEqual(libsf.APIVersion20190520, libsf.APIVersion20200115)) 21 | } 22 | 23 | func TestVersionLesser(t *testing.T) { 24 | assert.False(t, libsf.VersionLesser("bad", "1")) 25 | assert.False(t, libsf.VersionLesser("1", "bad")) 26 | 27 | assert.True(t, libsf.VersionLesser(libsf.ProtocolVersion3, libsf.ProtocolVersion2)) 28 | assert.False(t, libsf.VersionLesser(libsf.ProtocolVersion3, libsf.ProtocolVersion3)) 29 | assert.False(t, libsf.VersionLesser(libsf.ProtocolVersion3, libsf.ProtocolVersion4)) 30 | 31 | assert.True(t, libsf.VersionLesser(libsf.APIVersion20190520, libsf.APIVersion20161215)) 32 | assert.False(t, libsf.VersionLesser(libsf.APIVersion20190520, libsf.APIVersion20190520)) 33 | assert.False(t, libsf.VersionLesser(libsf.APIVersion20190520, libsf.APIVersion20200115)) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/stormbinc/binc.go: -------------------------------------------------------------------------------- 1 | package stormbinc 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/ugorji/go/codec" 7 | ) 8 | 9 | const name = "binc" 10 | 11 | // Codec that encodes to and decodes from Binc. 12 | // See https://github.com/ugorji/binc 13 | var Codec = new(bincCodec) 14 | 15 | type bincCodec int 16 | 17 | func (c bincCodec) Marshal(v any) ([]byte, error) { 18 | var b bytes.Buffer 19 | enc := codec.NewEncoder(&b, &codec.BincHandle{}) 20 | err := enc.Encode(v) 21 | if err != nil { 22 | return nil, err 23 | } 24 | return b.Bytes(), nil 25 | } 26 | 27 | func (c bincCodec) Unmarshal(b []byte, v any) error { 28 | r := bytes.NewReader(b) 29 | dec := codec.NewDecoder(r, &codec.BincHandle{}) 30 | return dec.Decode(v) 31 | } 32 | 33 | func (c bincCodec) Name() string { 34 | return name 35 | } 36 | -------------------------------------------------------------------------------- /pkg/stormcbor/cbor.go: -------------------------------------------------------------------------------- 1 | package stormcbor 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/ugorji/go/codec" 7 | ) 8 | 9 | const name = "cbor" 10 | 11 | // Codec that encodes to and decodes from CBOR (Concise Binary Object Representation). 12 | // http://cbor.io/ 13 | // https://tools.ietf.org/html/rfc7049 14 | var Codec = new(cborCodec) 15 | 16 | type cborCodec int 17 | 18 | func (c cborCodec) Marshal(v any) ([]byte, error) { 19 | var b bytes.Buffer 20 | enc := codec.NewEncoder(&b, &codec.CborHandle{}) 21 | err := enc.Encode(v) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return b.Bytes(), nil 26 | } 27 | 28 | func (c cborCodec) Unmarshal(b []byte, v any) error { 29 | r := bytes.NewReader(b) 30 | dec := codec.NewDecoder(r, &codec.CborHandle{}) 31 | return dec.Decode(v) 32 | } 33 | 34 | func (c cborCodec) Name() string { 35 | return name 36 | } 37 | -------------------------------------------------------------------------------- /pkg/stormsql/select.go: -------------------------------------------------------------------------------- 1 | package stormsql 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/araddon/dateparse" 8 | "github.com/asdine/storm/v3/q" 9 | "github.com/pkg/errors" 10 | "github.com/xwb1989/sqlparser" 11 | ) 12 | 13 | // A SelectClause contains all the parsed SQL data. 14 | type SelectClause struct { 15 | SelectedFields []string 16 | Count bool 17 | Tablename string 18 | Matcher q.Matcher 19 | Skip int 20 | Limit int 21 | OrderBy []string 22 | OrderByReversed bool 23 | } 24 | 25 | // ParseSelect parses the given SELECT statement. 26 | func ParseSelect(sql string) (*SelectClause, error) { 27 | stmt, err := sqlparser.Parse(sql) 28 | if err != nil { 29 | return nil, errors.Wrap(err, "could not parse SQL") 30 | } 31 | 32 | s, ok := stmt.(*sqlparser.Select) 33 | if !ok { 34 | return nil, errors.New("not a select statement") 35 | } 36 | 37 | var sc SelectClause 38 | 39 | // SELECT * ... 40 | // SELECT UserID,UpdatedAt ... 41 | for _, se := range s.SelectExprs { 42 | switch v := se.(type) { 43 | case *sqlparser.StarExpr: 44 | sc.SelectedFields = []string{} 45 | case *sqlparser.AliasedExpr: 46 | switch v := v.Expr.(type) { 47 | case *sqlparser.ColName: 48 | sc.SelectedFields = append(sc.SelectedFields, v.Name.String()) 49 | case *sqlparser.FuncExpr: 50 | sc.SelectedFields = []string{} 51 | sc.Count = v.Name.String() == "count" 52 | } 53 | default: 54 | return nil, errors.New("unsupported select expression") 55 | } 56 | } 57 | 58 | // FROM users 59 | sc.Tablename = sqlparser.GetTableName(s.From[0].(*sqlparser.AliasedTableExpr).Expr).String() 60 | 61 | // WHERE 62 | sc.Matcher = q.And() 63 | if s.Where != nil { 64 | sc.Matcher = parsWhereExpr(s.Where.Expr) 65 | } 66 | 67 | // LIMIT 5 68 | // LIMIT 2,5 69 | if s.Limit != nil { 70 | if s.Limit.Offset != nil { 71 | sc.Skip = parseSQLVal(s.Limit.Offset.(*sqlparser.SQLVal)).(int) 72 | } 73 | sc.Limit = parseSQLVal(s.Limit.Rowcount.(*sqlparser.SQLVal)).(int) 74 | } 75 | 76 | // ORDER BY UpdatedAt 77 | // ORDER BY UpdatedAt DESC 78 | // ORDER BY UpdatedAt DESC, CreatedAt ASC => All will be DESC due to strom limitation 79 | for _, ob := range s.OrderBy { 80 | if ob.Direction == "desc" { 81 | sc.OrderByReversed = true 82 | } 83 | sc.OrderBy = append(sc.OrderBy, ob.Expr.(*sqlparser.ColName).Name.String()) 84 | } 85 | 86 | return &sc, nil 87 | } 88 | 89 | // FIXME replace panic by returned errors 90 | func parsWhereExpr(expr sqlparser.Expr) q.Matcher { 91 | switch v := expr.(type) { 92 | // 93 | // 94 | // 95 | case *sqlparser.ComparisonExpr: 96 | field := v.Left.(*sqlparser.ColName).Name.String() 97 | var value any 98 | 99 | // Parse value 100 | switch sqlvalue := v.Right.(type) { 101 | case sqlparser.BoolVal: 102 | value = sqlvalue 103 | case sqlparser.ValTuple: 104 | var tuple []any 105 | for _, t := range sqlvalue { 106 | tuple = append(tuple, parseSQLVal(t.(*sqlparser.SQLVal))) 107 | } 108 | value = tuple 109 | case *sqlparser.SQLVal: 110 | value = parseSQLVal(sqlvalue) 111 | default: 112 | fmt.Printf("%#v\n", v) 113 | panic("unsupported Val") 114 | } 115 | 116 | // Parse operator 117 | switch v.Operator { 118 | case "=": 119 | return q.Eq(field, value) 120 | case "!=": 121 | return q.Not(q.Eq(field, value)) 122 | case ">": 123 | return q.Gt(field, value) 124 | case ">=": 125 | return q.Gte(field, value) 126 | case "in": 127 | return q.In(field, value) 128 | case "<": 129 | return q.Lt(field, value) 130 | case "<=": 131 | return q.Lte(field, value) 132 | case "like": 133 | return q.Re(field, fmt.Sprintf("%v", value)) 134 | default: 135 | fmt.Printf("%#v\n", v.Operator) 136 | panic("unsupported Operator") 137 | } 138 | // 139 | // 140 | // 141 | case *sqlparser.IsExpr: 142 | switch v.Operator { 143 | case "is not null": 144 | return q.Not(q.Eq(v.Expr.(*sqlparser.ColName).Name.String(), nil)) 145 | default: 146 | fmt.Printf("%#v\n", v) 147 | panic("unsupported IsExpr") 148 | } 149 | // 150 | // 151 | // 152 | case *sqlparser.AndExpr: 153 | return q.And( 154 | parsWhereExpr(v.Left), 155 | parsWhereExpr(v.Right), 156 | ) 157 | // 158 | // 159 | // 160 | case *sqlparser.OrExpr: 161 | return q.Or( 162 | parsWhereExpr(v.Left), 163 | parsWhereExpr(v.Right), 164 | ) 165 | // 166 | // 167 | // 168 | default: 169 | fmt.Printf("%#v\n", v) 170 | panic("unsupported where expr type") 171 | } 172 | } 173 | 174 | func parseSQLVal(v *sqlparser.SQLVal) (value any) { 175 | switch v.Type { 176 | case sqlparser.StrVal: 177 | value = string(v.Val) 178 | 179 | // Try to convert to time.Time if possible 180 | if t, err := dateparse.ParseAny(string(v.Val)); err == nil { 181 | value = t.UTC() 182 | } 183 | case sqlparser.IntVal: 184 | value, _ = strconv.Atoi(string(v.Val)) 185 | case sqlparser.FloatVal: 186 | value, _ = strconv.ParseFloat(string(v.Val), 64) 187 | case sqlparser.HexNum: 188 | value, _ = strconv.ParseInt(string(v.Val), 16, 64) 189 | case sqlparser.HexVal: 190 | b, err := v.HexDecode() 191 | if err != nil { 192 | panic(err) 193 | } 194 | value = b 195 | case sqlparser.ValArg: 196 | panic("unsupported ValArg") // TODO 197 | case sqlparser.BitVal: 198 | value = v.Val[0] == 1 199 | } 200 | 201 | return value 202 | } 203 | -------------------------------------------------------------------------------- /pkg/structs/fields.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import "github.com/oleiade/reflections" 4 | 5 | // GetField returns the value of the provided obj field. obj can whether be a structure or pointer to structure. 6 | func GetField(obj any, name string) any { 7 | v, err := reflections.GetField(obj, name) 8 | if err != nil { 9 | panic(err) 10 | } 11 | 12 | return v 13 | } 14 | 15 | // SetField sets the provided obj field with provided value. 16 | // obj param has to be a pointer to a struct, otherwise it will soundly fail. 17 | // Provided value type should match with the struct field you're trying to set. 18 | func SetField(obj any, name string, value any) { 19 | if err := reflections.SetField(obj, name, value); err != nil { 20 | panic(err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /standardfile.yml: -------------------------------------------------------------------------------- 1 | # Secrets can optionally be provided by the systemd LoadCredential directive. Example: 2 | # LoadCredential=secret_key:/var/lib/standardfile/secret_key.txt 3 | # LoadCredential=session.secret:/var/lib/standardfile/session_secret.txt 4 | # 5 | # Unix socket can be supported by setting `address: "unix:/var/run/standarfile.sock"`. 6 | # An additional parameter can be added to define custom unix permissions `socket_mode: 0660`. 7 | 8 | # Address to bind 9 | address: "localhost:5000" 10 | cors: 11 | # allow_origins of your self-hosted standardnotes/web:latest 12 | allow_origins: 13 | - http://localhost:3000 # Dev web app 14 | allow_methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"] 15 | # Disable registration 16 | no_registration: false 17 | # Show real version in `GET /version' 18 | show_real_version: false 19 | # Database folder path; empty value means current directory 20 | database_path: "" 21 | # Secret key used for JWT authentication (before 004 and 20200115) 22 | # If missing, will be read from $CREDENTIALS_DIRECTORY/secret_key file 23 | secret_key: jwt-development 24 | # Session used for authentication (since 004 and 20200115) 25 | session: 26 | # If missing, will be read from $CREDENTIALS_DIRECTORY/session.secret file 27 | secret: paseto-development 28 | access_token_ttl: 1440h # 60 days expressed in Golang's time.Duration format 29 | refresh_token_ttl: 8760h # 1 year 30 | 31 | # This option enables paid features in the official StandardNotes client. 32 | # This option is enabled by providing the JSON's filename containg 33 | # the official JSON data returned by `GET /v1/users/:id/subscription'. 34 | # 35 | # If you want to enables these features, you should consider to 36 | # donate to the StandardNotes project as they say: 37 | # 38 | # Building Standard Notes has high costs. If everyone evaded contributing financially, 39 | # we would no longer be here to continue to build upon and improve these services for you. 40 | # Please consider [donating](https://standardnotes.com/donate) if you do not plan on purchasing a subscription. 41 | # https://docs.standardnotes.com/self-hosting/subscriptions/ 42 | # 43 | # This project https://github.com/mdouchement/standardfile does not intend to 44 | # conflict with the business model of StandardNotes project or seek compensation. 45 | # subscription_file: subscription.json 46 | 47 | # The file must match the match the roles defined in the subscription_file. 48 | # It must contains the official JSON data returned by `GET /v1/users/:id/features'. 49 | # features_file: features.json 50 | -------------------------------------------------------------------------------- /tools/console/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/asdine/storm/v3" 9 | "github.com/mdouchement/standardfile/internal/database" 10 | "github.com/mdouchement/standardfile/internal/model" 11 | "github.com/mdouchement/standardfile/pkg/stormsql" 12 | "github.com/pkg/errors" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // go run tools/console/main.go standardfile.db " SELECT count(*) FROM items WHERE UserID = 'f2a98ab0-2c40-42b4-be08-da3b771be935' AND UpdatedAt > '2019-02-16 20:52:55'; " 17 | 18 | func main() { 19 | c := &cobra.Command{ 20 | Use: "console", 21 | Short: "SQL console for standardfile database", 22 | Args: cobra.ExactArgs(2), 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | // 25 | // 26 | sc, err := stormsql.ParseSelect(args[1]) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | // 32 | // 33 | fmt.Println("Opening", args[0]) 34 | db, err := storm.Open(args[0], database.StormCodec) 35 | if err != nil { 36 | return errors.Wrap(err, "could not open database") 37 | } 38 | defer db.Close() 39 | 40 | // 41 | // Prepare request 42 | // 43 | 44 | query := db.Select(sc.Matcher) 45 | if sc.Skip > 0 { 46 | query.Skip(sc.Skip) 47 | } 48 | if sc.Limit > 0 { 49 | query.Limit(sc.Limit) 50 | } 51 | if len(sc.OrderBy) > 0 { 52 | query.OrderBy(sc.OrderBy...) 53 | if sc.OrderByReversed { 54 | query.Reverse() 55 | } 56 | } 57 | 58 | // Execute 59 | 60 | if sc.Count { 61 | return count(sc, query) 62 | } 63 | 64 | return list(sc, query) 65 | }, 66 | } 67 | 68 | if err := c.Execute(); err != nil { 69 | log.Fatalf("%+v", err) 70 | } 71 | } 72 | 73 | func count(sc *stormsql.SelectClause, query storm.Query) error { 74 | var records any 75 | switch sc.Tablename { 76 | case "users": 77 | records = &model.User{} 78 | case "items": 79 | records = &model.Item{} 80 | default: 81 | return errors.Errorf("unknown tablename: %s", sc.Tablename) 82 | } 83 | 84 | n, err := query.Count(records) 85 | 86 | if err != nil { 87 | return errors.Wrap(err, "could not perform query") 88 | } 89 | 90 | fmt.Println("Count:", n) 91 | 92 | return nil 93 | } 94 | 95 | func list(sc *stormsql.SelectClause, query storm.Query) error { 96 | var records any 97 | switch sc.Tablename { 98 | case "users": 99 | records = &[]*model.User{} 100 | case "items": 101 | records = &[]*model.Item{} 102 | default: 103 | return errors.Errorf("unknown tablename: %s", sc.Tablename) 104 | } 105 | 106 | err := query.Find(records) 107 | if err == storm.ErrNotFound { 108 | fmt.Println("[]") 109 | return nil 110 | } 111 | 112 | if err != nil { 113 | return errors.Wrap(err, "could not perform query") 114 | } 115 | 116 | jsondump(records) 117 | 118 | return nil 119 | } 120 | 121 | func jsondump(v any) { 122 | d, err := json.MarshalIndent(v, "", " ") 123 | if err != nil { 124 | panic(err) 125 | } 126 | fmt.Println(string(d)) 127 | } 128 | -------------------------------------------------------------------------------- /tools/rmuser/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/asdine/storm/v3" 8 | "github.com/asdine/storm/v3/q" 9 | "github.com/mdouchement/standardfile/internal/database" 10 | "github.com/mdouchement/standardfile/internal/model" 11 | "github.com/pkg/errors" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func main() { 16 | c := &cobra.Command{ 17 | Use: "rmuser", 18 | Short: "Remove a user from the database", 19 | Args: cobra.ExactArgs(2), 20 | RunE: func(_ *cobra.Command, args []string) error { 21 | // 22 | // 23 | fmt.Println("Opening", args[0]) 24 | db, err := storm.Open(args[0], database.StormCodec) 25 | if err != nil { 26 | return errors.Wrap(err, "could not open database") 27 | } 28 | defer db.Close() 29 | 30 | // Fetch user 31 | var user model.User 32 | err = db.One("Email", args[1], &user) 33 | if err != nil { 34 | if err == storm.ErrNotFound { 35 | fmt.Println("No account for this email") 36 | return nil 37 | } 38 | return errors.Wrap(err, "find user by mail") 39 | } 40 | 41 | fmt.Println("User found:", user.ID) 42 | 43 | // Deleting user's items 44 | err = db.Select(q.Eq("UserID", user.ID)).Delete(&model.Item{}) 45 | if err != nil && err != storm.ErrNotFound { 46 | return errors.Wrap(err, "delete items") 47 | } 48 | fmt.Println("Items removed") 49 | 50 | // Delete user 51 | err = db.DeleteStruct(&user) 52 | if err != nil && err != storm.ErrNotFound { 53 | return errors.Wrap(err, "delete user") 54 | } 55 | fmt.Println("User removed") 56 | 57 | return nil 58 | }, 59 | } 60 | 61 | if err := c.Execute(); err != nil { 62 | log.Fatalf("%+v", err) 63 | } 64 | } 65 | --------------------------------------------------------------------------------