├── .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 | [](https://pkg.go.dev/github.com/mdouchement/standardfile)
13 | [](https://goreportcard.com/report/github.com/mdouchement/standardfile)
14 | [](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 | 
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 |
--------------------------------------------------------------------------------