├── authorized_keys └── .gitkeep ├── storage ├── migrations │ ├── 20250228212742_initialize.down.sql │ └── 20250228212742_initialize.up.sql ├── lockerroom.go ├── storage_test.go ├── ephemeral.go ├── storage.go ├── ephemeral_test.go ├── sql.go └── sql_test.go ├── shell.nix ├── .gitignore ├── flake.nix ├── .github ├── workflows │ ├── go.yml │ ├── release_mac.yml │ └── release_linux.yml └── dependabot.yml ├── Dockerfile ├── git └── hooks │ └── pre-commit ├── go.mod ├── main_test.go ├── LICENSE ├── flake.lock ├── sync ├── response_test.go ├── response.go ├── handle_test.go ├── auth.go ├── user_management.go ├── handle.go ├── algo.go ├── algo_test.go ├── auth_test.go ├── conflict.go └── conflict_test.go ├── timew-sync-server.service ├── data ├── parse.go ├── interval_test.go ├── parse_test.go └── interval.go ├── README.md ├── go.sum └── main.go /authorized_keys/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/migrations/20250228212742_initialize.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS interval; 2 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | fetchTarball { 3 | url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz"; 4 | sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; } 5 | ) { 6 | src = ./.; 7 | }).shellNix 8 | -------------------------------------------------------------------------------- /storage/migrations/20250228212742_initialize.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS interval ( 2 | user_id integer NOT NULL, 3 | start_time datetime NOT NULL, 4 | end_time datetime NOT NULL, 5 | tags text, 6 | annotation text, 7 | PRIMARY KEY (user_id, start_time, end_time, tags, annotation) 8 | ); 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Go related 2 | 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | # Project related 20 | 21 | authorized_keys/* 22 | !authorized_keys/.gitkeep 23 | 24 | # Database 25 | *.sqlite 26 | 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils }: 8 | (flake-utils.lib.eachDefaultSystem (system: 9 | 10 | let pkgs = nixpkgs.legacyPackages.${system}; in 11 | { 12 | 13 | devShell = pkgs.mkShell { 14 | buildInputs = with pkgs; [ 15 | go 16 | ]; 17 | }; 18 | 19 | } 20 | )); 21 | 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go-version: ["1.23", "1.24"] 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v6 17 | with: 18 | go-version: ${{ matrix.go-version }} 19 | 20 | - name: Build 21 | run: go build -v ./... 22 | 23 | - name: Test 24 | run: go test -v ./... 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS build 2 | 3 | ENV APP_HOME /go/src 4 | 5 | # Install build dependencies 6 | RUN apk add build-base 7 | 8 | # Copy and build project 9 | COPY . $APP_HOME 10 | 11 | WORKDIR $APP_HOME 12 | RUN go mod download 13 | RUN go mod verify 14 | RUN go build -o /bin/timew-server 15 | 16 | # Assemble the resulting image 17 | FROM alpine 18 | 19 | RUN mkdir authorized_keys 20 | COPY --from=build /bin/timew-server /bin/server 21 | 22 | EXPOSE 8080 23 | 24 | ENTRYPOINT [ "/bin/server" ] 25 | CMD [ "start" ] 26 | 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /git/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright 2012 The Go Authors. All rights reserved. 3 | # Use of this source code is governed by a BSD-style 4 | # license that can be found in the LICENSE file. 5 | 6 | # git gofmt pre-commit hook 7 | # 8 | # To use, store as .git/hooks/pre-commit inside your repository and make sure 9 | # it has execute permissions. 10 | # 11 | # This script does not handle file names that contain spaces. 12 | 13 | gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$') 14 | [ -z "$gofiles" ] && exit 0 15 | 16 | unformatted=$(gofmt -l "$gofiles") 17 | [ -z "$unformatted" ] && exit 0 18 | 19 | # Some files are not gofmt'd. Print message and fail. 20 | 21 | echo >&2 "Go files must be formatted with gofmt. Please run:" 22 | for fn in $unformatted; do 23 | echo >&2 " gofmt -w $PWD/$fn" 24 | done 25 | 26 | exit 1 27 | -------------------------------------------------------------------------------- /.github/workflows/release_mac.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish release binaries for macOS 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v6 17 | with: 18 | go-version: "1.22" 19 | 20 | - name: Test 21 | run: go test -v ./... 22 | 23 | - name: Build x86_64 macOS 24 | run: GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o timew-sync-server-x86_64-macos 25 | 26 | - name: Build aarch64 macOS 27 | run: GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -o timew-sync-server-aarch64-macos 28 | 29 | - name: Release 30 | uses: softprops/action-gh-release@v2.4.2 31 | with: 32 | files: | 33 | timew-sync-server-x86_64-macos 34 | timew-sync-server-aarch64-macos 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/timewarrior-synchronize/timew-sync-server 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/DATA-DOG/go-sqlmock v1.5.2 9 | github.com/golang-migrate/migrate/v4 v4.19.0 10 | github.com/google/go-cmp v0.7.0 11 | github.com/lestrrat-go/jwx v1.2.31 12 | github.com/mattn/go-sqlite3 v1.14.32 13 | ) 14 | 15 | require ( 16 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 17 | github.com/goccy/go-json v0.10.3 // indirect 18 | github.com/hashicorp/errwrap v1.1.0 // indirect 19 | github.com/hashicorp/go-multierror v1.1.1 // indirect 20 | github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect 21 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 22 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 23 | github.com/lestrrat-go/iter v1.0.2 // indirect 24 | github.com/lestrrat-go/option v1.0.1 // indirect 25 | github.com/pkg/errors v0.9.1 // indirect 26 | golang.org/x/crypto v0.36.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /.github/workflows/release_linux.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish release binaries for Linux 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v6 17 | with: 18 | go-version: "1.22" 19 | 20 | - uses: mlugg/setup-zig@v2 21 | with: 22 | version: "0.13.0" 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | 27 | - name: Build x86_64 Linux 28 | run: GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CC="zig cc -target x86_64-linux-musl" go build -o timew-sync-server-x86_64-linux 29 | 30 | - name: Build aarch64 Linux 31 | run: GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC="zig cc -target aarch64-linux-musl" go build -o timew-sync-server-aarch64-linux 32 | 33 | - name: Release 34 | uses: softprops/action-gh-release@v2.4.2 35 | with: 36 | files: | 37 | timew-sync-server-x86_64-linux 38 | timew-sync-server-aarch64-linux 39 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package main 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 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 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1701680307, 9 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1701436327, 24 | "narHash": "sha256-tRHbnoNI8SIM5O5xuxOmtSLnswEByzmnQcGGyNRjxsE=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "91050ea1e57e50388fa87a3302ba12d188ef723a", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /sync/response_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package sync 18 | 19 | import ( 20 | "github.com/google/go-cmp/cmp" 21 | "testing" 22 | ) 23 | 24 | func TestToString(t *testing.T) { 25 | testInput := ErrorResponseBody{ 26 | Message: "Houston, we have a problem", 27 | Details: "We've had a Main B Bus Undervolt.", 28 | } 29 | 30 | expected := `{"message":"Houston, we have a problem","details":"We've had a Main B Bus Undervolt."}` 31 | 32 | result := testInput.ToString() 33 | 34 | if diff := cmp.Diff(expected, result); diff != "" { 35 | t.Errorf("Result differs from expected: \n%s", diff) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /sync/response.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "encoding/json" 22 | ) 23 | 24 | // An ErrorResponseBody represents a JSON message that is sent to the 25 | // client when an error occurs 26 | type ErrorResponseBody struct { 27 | Message string `json:"message"` 28 | Details string `json:"details"` 29 | } 30 | 31 | // Returns a string representation of this error reponse which will be 32 | // sent to the client 33 | func (e ErrorResponseBody) ToString() string { 34 | // Assume that JSON marshalling always is successful 35 | result, _ := json.Marshal(e) 36 | return string(result) 37 | } 38 | -------------------------------------------------------------------------------- /sync/handle_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "net/http" 22 | "net/http/httptest" 23 | "testing" 24 | ) 25 | 26 | func TestSendResponse(t *testing.T) { 27 | // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. 28 | rr := httptest.NewRecorder() 29 | sendResponse(rr, http.StatusOK, "test") 30 | // Check the status code is what we expect. 31 | if status := rr.Code; status != http.StatusOK { 32 | t.Errorf("handler returned wrong status code: got %v want %v", 33 | status, http.StatusOK) 34 | } 35 | 36 | // Check the response body is what we expect. 37 | expected := `test` 38 | if rr.Body.String() != expected { 39 | t.Errorf("handler returned unexpected body: got %v want %v", 40 | rr.Body.String(), expected) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /timew-sync-server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=timewarrior synchronization server 3 | 4 | [Service] 5 | User=timew-sync-server 6 | Group=timew-sync-server 7 | Type=simple 8 | 9 | ############################################################################### 10 | # # 11 | # Replace this with the path to the actual binary and port you want to use. # 12 | # # 13 | # Always run timew-sync-server behind a reverse proxy when exposed publicly. # 14 | # # 15 | ############################################################################### 16 | ExecStart=/usr/bin/timew-sync-server start -port 8710 17 | Restart=always 18 | RestartSec=5 19 | 20 | ############################################################################### 21 | # # 22 | # Systemd will create the data directory for timew-sync-server with the # 23 | # appropriate permissions. Remember back up the contents of this directory. # 24 | # # 25 | ############################################################################### 26 | StateDirectory=timew-sync-server 27 | StateDirectoryMode=0700 28 | WorkingDirectory=/var/lib/timew-sync-server 29 | 30 | CapabilityBoundingSet= 31 | LockPersonality=true 32 | MemoryDenyWriteExecute=true 33 | NoNewPrivileges=true 34 | PrivateDevices=true 35 | PrivateMounts=true 36 | PrivateTmp=true 37 | ProtectClock=true 38 | ProtectControlGroups=true 39 | ProtectHome=true 40 | ProtectHostname=true 41 | ProtectKernelLogs=true 42 | ProtectKernelModules=true 43 | ProtectKernelTunables=true 44 | ProtectSystem=strict 45 | RestrictAddressFamilies=AF_INET 46 | RestrictAddressFamilies=AF_INET6 47 | RestrictRealtime=true 48 | RestrictSUIDSGID=true 49 | 50 | [Install] 51 | WantedBy=multi-user.target 52 | -------------------------------------------------------------------------------- /storage/lockerroom.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package storage 18 | 19 | import ( 20 | "sync" 21 | ) 22 | 23 | // A LockerRoom is a collection of Mutexes mapped to user ids 24 | type LockerRoom struct { 25 | globalLock sync.Mutex 26 | locks map[UserId]*sync.Mutex 27 | } 28 | 29 | // Sets up this LockerRoom instance 30 | func (lr *LockerRoom) InitializeLockerRoom() { 31 | lr.locks = make(map[UserId]*sync.Mutex) 32 | } 33 | 34 | // Creates an entry into the locks map if the user does not exist yet 35 | func (lr *LockerRoom) createUserIfNotExists(userId UserId) { 36 | lr.globalLock.Lock() 37 | defer lr.globalLock.Unlock() 38 | 39 | if lr.locks[userId] == nil { 40 | lr.locks[userId] = &sync.Mutex{} 41 | } 42 | } 43 | 44 | // Acquire the lock for this user id 45 | func (lr *LockerRoom) Lock(userId UserId) { 46 | lr.createUserIfNotExists(userId) 47 | 48 | lr.locks[userId].Lock() 49 | } 50 | 51 | // Release the lock for this user id 52 | func (lr *LockerRoom) Unlock(userId UserId) { 53 | lr.locks[userId].Unlock() 54 | } 55 | -------------------------------------------------------------------------------- /sync/auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 - Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "fmt" 22 | "github.com/lestrrat-go/jwx/jwa" 23 | "github.com/lestrrat-go/jwx/jwk" 24 | "github.com/lestrrat-go/jwx/jwt" 25 | "github.com/timewarrior-synchronize/timew-sync-server/data" 26 | "log" 27 | "net/http" 28 | "path/filepath" 29 | "time" 30 | ) 31 | 32 | // Authenticate returns true iff the JWT specified in the HTTP requests' Bearer token was signed by the correct user. 33 | // If any step of the authentication process fails or there is no matching public key, Authenticate returns false 34 | func Authenticate(r *http.Request, body data.SyncRequest) bool { 35 | keySet, err := GetKeySet(body.UserID) 36 | if err != nil { 37 | log.Printf("Error during Authentication. Unable to obtain keys for user %v", body.UserID) 38 | return false 39 | } 40 | 41 | return AuthenticateWithKeySet(r, body.UserID, keySet) 42 | 43 | } 44 | 45 | // AuthenticateWithKeySet returns true iff the JWT in the Bearer token can be validated in verified with a key in the 46 | // given key set 47 | func AuthenticateWithKeySet(r *http.Request, userID int64, keySet jwk.Set) bool { 48 | for i := 0; i < keySet.Len(); i++ { 49 | key, ok := keySet.Get(i) 50 | if !ok { 51 | continue 52 | } 53 | 54 | token, err := jwt.ParseHeader(r.Header, "Authorization", jwt.WithValidate(true), 55 | jwt.WithVerify(jwa.RS256, key), jwt.WithAcceptableSkew(time.Duration(10e10))) 56 | if err != nil { 57 | continue 58 | } 59 | 60 | id, ok := token.Get("userID") 61 | if !ok { 62 | continue 63 | } 64 | 65 | presumedUserID, ok := id.(float64) 66 | if !ok || int64(presumedUserID) != userID { 67 | continue 68 | } 69 | return true 70 | } 71 | return false 72 | } 73 | 74 | // GetKeySet returns the key set of user with a given userId. Returns an error if the keys file of that user was not 75 | // found or could not be parsed. 76 | func GetKeySet(userId int64) (jwk.Set, error) { 77 | filename := fmt.Sprintf("%d_keys", userId) 78 | path := filepath.Join(PublicKeyLocation, filename) 79 | 80 | keySet, err := jwk.ReadFile(path, jwk.WithPEM(true)) 81 | if err != nil { 82 | log.Printf("Error parsing key set of user %d: %v", userId, err) 83 | return nil, err 84 | } 85 | 86 | return keySet, nil 87 | } 88 | -------------------------------------------------------------------------------- /data/parse.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package data 19 | 20 | import ( 21 | "encoding/json" 22 | "fmt" 23 | ) 24 | 25 | // JSONRequest represents the JSON structure of a sync request. 26 | // It contains the unique client id and an interval diff, stating added and removed intervals as strings. 27 | // It is (and should) only be used for JSON parsing. 28 | type JSONRequest struct { 29 | UserID int64 `json:"userID"` 30 | Added []JSONInterval `json:"added"` 31 | Removed []JSONInterval `json:"removed"` 32 | } 33 | 34 | // SyncRequest represents a sync request. 35 | // It contains the id of the user who is syncing 36 | // and its interval diff. 37 | type SyncRequest struct { 38 | UserID int64 39 | Added []Interval 40 | Removed []Interval 41 | } 42 | 43 | // ParseSyncRequest parses the JSON of a sync request into a 44 | // JSONRequest struct. 45 | func ParseSyncRequest(jsonInput string) (SyncRequest, error) { 46 | var requestData JSONRequest 47 | 48 | err := json.Unmarshal([]byte(jsonInput), &requestData) 49 | if err != nil { 50 | return SyncRequest{}, fmt.Errorf("Error occured during JSON parse: %v", err) 51 | } 52 | 53 | added, err := FromJSONIntervals(requestData.Added) 54 | if err != nil { 55 | return SyncRequest{}, fmt.Errorf("Error occured during parsing of added intervals: %v", err) 56 | } 57 | 58 | removed, err := FromJSONIntervals(requestData.Removed) 59 | if err != nil { 60 | return SyncRequest{}, fmt.Errorf("Error occured during parsing of removed intervals: %v", err) 61 | } 62 | 63 | syncRequest := SyncRequest{ 64 | UserID: requestData.UserID, 65 | Added: added, 66 | Removed: removed, 67 | } 68 | 69 | return syncRequest, err 70 | } 71 | 72 | // ResponseData represents a sync response 73 | // It contains the new interval for the client 74 | type ResponseData struct { 75 | ConflictsOccurred bool `json:"conflictsOccurred"` 76 | Intervals []JSONInterval `json:"intervals"` 77 | } 78 | 79 | // ToJSON creates JSON for response body from interval data and returns it as string 80 | func ToJSON(data []Interval, conflict bool) (string, error) { 81 | response := ResponseData{ 82 | ConflictsOccurred: conflict, 83 | Intervals: ToJSONIntervals(data), 84 | } 85 | 86 | jsonResult, err := json.Marshal(response) 87 | 88 | return string(jsonResult), err 89 | } 90 | -------------------------------------------------------------------------------- /data/interval_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package data 19 | 20 | import ( 21 | "github.com/google/go-cmp/cmp" 22 | "testing" 23 | "time" 24 | ) 25 | 26 | func TestInterval_JSONConversion(t *testing.T) { 27 | testData := Interval{ 28 | Start: time.Date(1991, time.March, 13, 3, 45, 45, 0, time.UTC), 29 | End: time.Date(1991, time.March, 14, 7, 32, 56, 0, time.UTC), 30 | Tags: []string{"tag1", "tag2"}, 31 | Annotation: "Hello World!", 32 | } 33 | 34 | result, _ := testData.ToJSONInterval().ToInterval() 35 | 36 | if diff := cmp.Diff(testData, result); diff != "" { 37 | t.Errorf("Result differs from expected: \n%s", diff) 38 | } 39 | } 40 | 41 | func TestInterval_Serialize(t *testing.T) { 42 | testData := Interval{ 43 | Start: time.Date(1991, time.March, 13, 3, 45, 45, 0, time.UTC), 44 | End: time.Date(1991, time.March, 14, 7, 32, 56, 0, time.UTC), 45 | Tags: []string{"tag1", "tag2"}, 46 | } 47 | 48 | expected := "inc 19910313T034545Z - 19910314T073256Z # tag1 tag2" 49 | 50 | result := testData.Serialize() 51 | 52 | if expected != result { 53 | t.Errorf("Wrong interval format. Expected: \"%s\", got: \"%s\"", expected, result) 54 | } 55 | } 56 | 57 | func TestIntervalsToStrings(t *testing.T) { 58 | loc, _ := time.LoadLocation("UTC") 59 | testData := make([]Interval, 3, 3) 60 | testData[0] = Interval{ 61 | Start: time.Date(2020, 11, 25, 9, 39, 10, 0, loc), 62 | End: time.Date(2020, 11, 25, 9, 39, 43, 0, loc), 63 | Tags: []string{}, 64 | } 65 | testData[1] = Interval{ 66 | Start: time.Date(2020, 11, 25, 9, 52, 40, 0, loc), 67 | End: time.Date(2020, 11, 25, 9, 52, 53, 0, loc), 68 | Tags: []string{"test"}, 69 | } 70 | testData[2] = Interval{ 71 | Start: time.Date(2020, 12, 9, 14, 5, 21, 0, loc), 72 | End: time.Date(2020, 12, 9, 14, 5, 33, 0, loc), 73 | Tags: []string{"a", "b", "c"}, 74 | } 75 | 76 | expected := []string{ 77 | "inc 20201125T093910Z - 20201125T093943Z", 78 | "inc 20201125T095240Z - 20201125T095253Z # test", 79 | "inc 20201209T140521Z - 20201209T140533Z # a b c", 80 | } 81 | 82 | actual := IntervalsToStrings(testData) 83 | if len(actual) != 3 { 84 | t.Errorf("wrong number of strings returned: expected 3 got %v\n", len(actual)) 85 | } 86 | 87 | for i, a := range actual { 88 | if a != expected[i] { 89 | t.Errorf("wrong conversion for interval %v: expected \"%v\" got \"%v\"\n", i, expected[i], a) 90 | } 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /sync/user_management.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "fmt" 22 | "io/ioutil" 23 | "log" 24 | "math" 25 | "os" 26 | "path/filepath" 27 | "strconv" 28 | "strings" 29 | ) 30 | 31 | // GetUsedUserIDs returns a map containing every user id with an existing file [user id]_keys 32 | // in PublicKeyLocation directory 33 | func GetUsedUserIDs() map[int64]bool { 34 | files, err := ioutil.ReadDir(PublicKeyLocation) 35 | if err != nil { 36 | log.Fatal("Error accessing keys-location directory") 37 | } 38 | used := make(map[int64]bool) 39 | 40 | for _, f := range files { 41 | s := strings.Split(f.Name(), "_") 42 | if len(s) != 2 { 43 | continue 44 | } 45 | i, err := strconv.ParseInt(s[0], 10, 64) 46 | if err != nil { 47 | continue 48 | } 49 | if s[1] == "keys" { 50 | used[i] = true 51 | } 52 | } 53 | return used 54 | } 55 | 56 | // GetFreeUserID returns the smallest valid unused user id 57 | func GetFreeUserID() int64 { 58 | used := GetUsedUserIDs() 59 | for i := int64(0); i <= math.MaxInt64; i++ { 60 | if !used[i] { 61 | return i 62 | } 63 | } 64 | log.Fatal("Error obtaining free user id") 65 | return -1 66 | } 67 | 68 | // ReadKey reads the key from a file 69 | func ReadKey(path string) string { 70 | key, err := ioutil.ReadFile(path) 71 | if err != nil { 72 | log.Fatalf("Error reading key file at %v", path) 73 | } 74 | return string(key) 75 | } 76 | 77 | // AddKey adds the given key to the key file of the given user 78 | func AddKey(userID int64, key string) { 79 | if userID < 0 { 80 | log.Fatal("Error adding key. Negative user id not allowed") 81 | } 82 | 83 | destFileName := fmt.Sprintf("%d_keys", userID) 84 | destFile, err := os.OpenFile(filepath.Join(PublicKeyLocation, destFileName), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) 85 | if err != nil { 86 | log.Fatalf("Error adding key. Unable to create new key file or write to existing key file with user id %v", userID) 87 | } 88 | defer destFile.Close() 89 | if key == "" { 90 | return 91 | } 92 | stat, err := destFile.Stat() 93 | if err != nil { 94 | log.Fatal("Unable to obtain kye file length") 95 | } 96 | if stat.Size() > 0 { 97 | key = "\n" + key 98 | } 99 | if _, err = destFile.WriteString(key); err != nil { 100 | destFile.Close() 101 | log.Fatalf("Error adding key. Unable to write to key file with user id %v", userID) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /sync/handle.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | _ "github.com/lestrrat-go/jwx" 22 | "github.com/timewarrior-synchronize/timew-sync-server/data" 23 | "github.com/timewarrior-synchronize/timew-sync-server/storage" 24 | "io" 25 | "io/ioutil" 26 | "log" 27 | "net/http" 28 | ) 29 | 30 | var PublicKeyLocation string 31 | 32 | // HandleSyncRequest receives sync requests and starts the sync 33 | // process with the received data. 34 | func HandleSyncRequest(w http.ResponseWriter, req *http.Request, noAuth bool) { 35 | requestBody, err := ioutil.ReadAll(req.Body) 36 | if err != nil { 37 | log.Printf("Error reading HTTP request, ignoring request: %v", err) 38 | return 39 | } 40 | 41 | requestData, err := data.ParseSyncRequest(string(requestBody)) 42 | if err != nil { 43 | log.Printf("Error parsing sync request, ignoring request: %v", err) 44 | errorResponse := ErrorResponseBody{ 45 | Message: "An error occurred while parsing the request", 46 | Details: err.Error(), 47 | } 48 | sendResponse(w, http.StatusBadRequest, errorResponse.ToString()) 49 | return 50 | } 51 | 52 | // Authentication 53 | if !noAuth { 54 | authenticated := Authenticate(req, requestData) 55 | if !authenticated { 56 | errorResponse := ErrorResponseBody{ 57 | Message: "An error occurred during authentication", 58 | Details: "", 59 | } 60 | sendResponse(w, http.StatusUnauthorized, errorResponse.ToString()) 61 | return 62 | } 63 | } 64 | 65 | syncData, conflict, err := Sync(requestData, storage.GlobalStorage) 66 | if err != nil { 67 | log.Printf("Synchronization failed, ignoring request: %v", err) 68 | errorResponse := ErrorResponseBody{ 69 | Message: "An error occurred while performing the synchronization", 70 | Details: err.Error(), 71 | } 72 | sendResponse(w, http.StatusInternalServerError, errorResponse.ToString()) 73 | return 74 | } 75 | 76 | responseBody, err := data.ToJSON(syncData, conflict) 77 | if err != nil { 78 | log.Printf("Error creating response JSON, ignoring request: %v", err) 79 | errorResponse := ErrorResponseBody{ 80 | Message: "An error occurred while creating the response", 81 | Details: err.Error(), 82 | } 83 | sendResponse(w, http.StatusInternalServerError, errorResponse.ToString()) 84 | return 85 | } 86 | 87 | sendResponse(w, http.StatusOK, responseBody) 88 | } 89 | 90 | // sendResponse writes data to response buffer 91 | func sendResponse(w http.ResponseWriter, statusCode int, data string) { 92 | w.Header().Set("content-type", "application/json; charset=utf-8") 93 | w.WriteHeader(statusCode) 94 | 95 | _, err := io.WriteString(w, data) 96 | if err != nil { 97 | log.Printf("Error writing response to ResponseWriter") 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /data/parse_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package data 18 | 19 | import ( 20 | "github.com/google/go-cmp/cmp" 21 | "testing" 22 | "time" 23 | ) 24 | 25 | func TestParseJSON(t *testing.T) { 26 | testInput := ` 27 | { 28 | "userID": 1, 29 | "added": [ 30 | { 31 | "start": "20200301T120000Z", 32 | "end": "20200301T153000Z", 33 | "tags": ["prank", "add"], 34 | "annotation": "annotation 1" 35 | } 36 | ], 37 | "removed": [ 38 | { 39 | "start": "20200401T120000Z", 40 | "end": "20200401T153000Z", 41 | "tags": ["prank", "remove"], 42 | "annotation": "all your codebase are belong to us" 43 | } 44 | ] 45 | }` 46 | 47 | expected := SyncRequest{ 48 | UserID: 1, 49 | Added: []Interval{ 50 | { 51 | Start: time.Date(2020, time.March, 1, 12, 0, 0, 0, time.UTC), 52 | End: time.Date(2020, time.March, 1, 15, 30, 0, 0, time.UTC), 53 | Tags: []string{"prank", "add"}, 54 | Annotation: "annotation 1", 55 | }, 56 | }, 57 | Removed: []Interval{ 58 | { 59 | Start: time.Date(2020, time.April, 1, 12, 0, 0, 0, time.UTC), 60 | End: time.Date(2020, time.April, 1, 15, 30, 0, 0, time.UTC), 61 | Tags: []string{"prank", "remove"}, 62 | Annotation: "all your codebase are belong to us", 63 | }, 64 | }, 65 | } 66 | 67 | result, err := ParseSyncRequest(testInput) 68 | if err != nil { 69 | t.Errorf("Unexpected Error: %v", err) 70 | } 71 | 72 | if diff := cmp.Diff(expected, result); diff != "" { 73 | t.Errorf("Result differs from expected: \n%s", diff) 74 | } 75 | 76 | } 77 | 78 | func TestToJSON_noConflict(t *testing.T) { 79 | testInput := []Interval{ 80 | { 81 | Start: time.Date(2020, time.April, 1, 12, 0, 0, 0, time.UTC), 82 | End: time.Date(2020, time.April, 1, 15, 30, 0, 0, time.UTC), 83 | Tags: []string{"prank", "laugh"}, 84 | Annotation: "Sample Annotation", 85 | }, 86 | } 87 | 88 | expected := `{"conflictsOccurred":false,"intervals":[{"start":"20200401T120000Z","end":"20200401T153000Z","tags":["prank","laugh"],"annotation":"Sample Annotation"}]}` 89 | 90 | result, err := ToJSON(testInput, false) 91 | if err != nil { 92 | t.Errorf("Unexpected Error: %v", err) 93 | } 94 | 95 | if diff := cmp.Diff(expected, result); diff != "" { 96 | t.Errorf("Result differs from expected: \n%s", diff) 97 | } 98 | 99 | } 100 | 101 | func TestToJSON_withConflict(t *testing.T) { 102 | var testInput []Interval 103 | 104 | expected := `{"conflictsOccurred":true,"intervals":[]}` 105 | 106 | result, err := ToJSON(testInput, true) 107 | if err != nil { 108 | t.Errorf("Unexpected Error: %e", err) 109 | } 110 | 111 | if diff := cmp.Diff(expected, result); diff != "" { 112 | t.Errorf("Result differs from expected: \n%s", diff) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /sync/algo.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "fmt" 22 | "github.com/timewarrior-synchronize/timew-sync-server/data" 23 | "github.com/timewarrior-synchronize/timew-sync-server/storage" 24 | ) 25 | 26 | // Sync updates the stored state in passed storage.Storage for the user issuing the sync request. If something fails it 27 | //tries to restore the state 28 | // prior to the syncRequest. This is not always possible though. The error message denotes whether restoring state was 29 | //successful. 30 | // Later atomicity should be guaranteed by storage. 31 | // Iff no errors occur Sync returns the synced interval data of the user issuing the sync request. 32 | func Sync(syncRequest data.SyncRequest, store storage.Storage) ([]data.Interval, bool, error) { 33 | // acquire lock and release it after syncing 34 | store.Lock(storage.UserId(syncRequest.UserID)) 35 | defer store.Unlock(storage.UserId(syncRequest.UserID)) 36 | 37 | // First, create a backup 38 | backup, err := store.GetIntervals(storage.UserId(syncRequest.UserID)) 39 | if err != nil { 40 | return nil, false, fmt.Errorf("fatal error: Could not retrieve stored intervals for backup. " + 41 | "Stored state did not change") 42 | } 43 | 44 | // Apply diff 45 | diffErr := store.ModifyIntervals(storage.UserId(syncRequest.UserID), syncRequest.Added, syncRequest.Removed) 46 | if diffErr != nil { 47 | restoreError := store.SetIntervals(storage.UserId(syncRequest.UserID), backup) // try to restore backup 48 | if restoreError != nil { 49 | return nil, false, fmt.Errorf("fatal error: Failed to apply diff %v. "+ 50 | "Also could not restore server state", diffErr) 51 | } else { 52 | return nil, false, fmt.Errorf("fatal error: Failed to apply diff %v. "+ 53 | "Stored state unchanged", diffErr) 54 | } 55 | } 56 | 57 | conflict, solveErr := SolveConflict(syncRequest.UserID, store) 58 | if solveErr != nil { 59 | restoreError := store.SetIntervals(storage.UserId(syncRequest.UserID), backup) // try to restore backup 60 | if restoreError != nil { 61 | return nil, conflict, fmt.Errorf("fatal error: Failed to solve conflicts %v. "+ 62 | "Also could not restore server state", solveErr) 63 | } else { 64 | return nil, conflict, fmt.Errorf("fatal error: Failed to solve conflicts %v. "+ 65 | "Stored state unchanged", solveErr) 66 | } 67 | } 68 | 69 | result, err2 := store.GetIntervals(storage.UserId(syncRequest.UserID)) 70 | if err2 != nil { 71 | restoreError := store.SetIntervals(storage.UserId(syncRequest.UserID), backup) // trying to restore backup 72 | if restoreError != nil { 73 | return nil, conflict, fmt.Errorf("fatal error: Failed to retrieve intervals from storage. " + 74 | "Also could not restore server state") 75 | } else { 76 | return nil, conflict, fmt.Errorf("fatal error: Failed to retrieve intervals from storage. " + 77 | "Stored state did not change") 78 | } 79 | } 80 | return result, conflict, nil 81 | } 82 | -------------------------------------------------------------------------------- /storage/storage_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package storage 18 | 19 | import ( 20 | "github.com/timewarrior-synchronize/timew-sync-server/data" 21 | "reflect" 22 | "testing" 23 | "time" 24 | ) 25 | 26 | func TestIntervalToKey(t *testing.T) { 27 | start := time.Date(2020, time.April, 1, 12, 0, 0, 0, time.UTC) 28 | end := time.Date(2020, time.April, 1, 15, 30, 0, 0, time.UTC) 29 | annotation := "test" 30 | testInput := data.Interval{ 31 | Start: start, 32 | End: end, 33 | Tags: []string{"prank", "laugh"}, 34 | Annotation: annotation, 35 | } 36 | 37 | expected := IntervalKey{ 38 | Start: start, 39 | End: end, 40 | Tags: `["prank","laugh"]`, 41 | Annotation: annotation, 42 | } 43 | 44 | result := IntervalToKey(testInput) 45 | if !result.Start.Equal(expected.Start) { 46 | t.Errorf("Expected Start time to be %v got %v", expected.Start, result.Start) 47 | } 48 | if !result.End.Equal(expected.End) { 49 | t.Errorf("Expected End time to be %v got %v", expected.End, result.End) 50 | } 51 | if result.Annotation != expected.Annotation { 52 | t.Errorf("Expected Annotation to be %v got %v", expected.Annotation, result.Annotation) 53 | } 54 | if result.Tags != expected.Tags { 55 | t.Errorf("Expected Tags tp be %v got %v", expected.Tags, result.Tags) 56 | } 57 | } 58 | 59 | func TestKeyToInterval(t *testing.T) { 60 | start := time.Date(2020, time.April, 1, 12, 0, 0, 0, time.UTC) 61 | end := time.Date(2020, time.April, 1, 15, 30, 0, 0, time.UTC) 62 | annotation := "test" 63 | testInput := IntervalKey{ 64 | Start: start, 65 | End: end, 66 | Tags: `["prank","laugh"]`, 67 | Annotation: annotation, 68 | } 69 | expected := data.Interval{ 70 | Start: start, 71 | End: end, 72 | Tags: []string{"prank", "laugh"}, 73 | Annotation: annotation, 74 | } 75 | result := KeyToInterval(testInput) 76 | if !result.Start.Equal(expected.Start) { 77 | t.Errorf("Expected Start time to be %v got %v", expected.Start, result.Start) 78 | } 79 | if !result.End.Equal(expected.End) { 80 | t.Errorf("Expected End time to be %v got %v", expected.End, result.End) 81 | } 82 | if result.Annotation != expected.Annotation { 83 | t.Errorf("Expected Annotation to be %v got %v", expected.Annotation, result.Annotation) 84 | } 85 | if !reflect.DeepEqual(result, expected) { 86 | t.Errorf("Expected Tags tp be %v got %v", expected.Tags, result.Tags) 87 | } 88 | } 89 | 90 | func TestConvertToIntervals(t *testing.T) { 91 | // test empty slice 92 | if !reflect.DeepEqual(ConvertToIntervals([]IntervalKey{}), []data.Interval{}) { 93 | t.Errorf("Empty slice does not map to emtpy slice") 94 | } 95 | } 96 | 97 | func TestConvertToKeys(t *testing.T) { 98 | // test empty slice 99 | if !reflect.DeepEqual(ConvertToKeys([]data.Interval{}), []IntervalKey{}) { 100 | t.Errorf("Empty slice does not map to emtpy slice") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /storage/ephemeral.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package storage 19 | 20 | import ( 21 | "github.com/timewarrior-synchronize/timew-sync-server/data" 22 | "log" 23 | ) 24 | 25 | // Ephemeral represents storage of user interval data. 26 | // It contains the time intervals. 27 | // Each interval is represented as a string in intervals. 28 | // Data is not stored persistently. 29 | type Ephemeral struct { 30 | LockerRoom 31 | intervals map[UserId]intervalSet 32 | } 33 | 34 | // intervalSet represents a set of intervals 35 | type intervalSet map[IntervalKey]bool 36 | 37 | // Initialize runs all necessary setup for this Storage instance 38 | func (ep *Ephemeral) Initialize() error { 39 | ep.intervals = make(map[UserId]intervalSet) 40 | ep.InitializeLockerRoom() 41 | return nil 42 | } 43 | 44 | // GetIntervals returns all intervals stored for a specific user 45 | func (ep *Ephemeral) GetIntervals(userId UserId) ([]data.Interval, error) { 46 | intervals := make([]IntervalKey, len(ep.intervals[userId])) 47 | 48 | i := 0 49 | for interval := range ep.intervals[userId] { 50 | intervals[i] = interval 51 | i++ 52 | } 53 | 54 | return ConvertToIntervals(intervals), nil 55 | } 56 | 57 | // SetIntervals replaces all intervals of a specific user 58 | func (ep *Ephemeral) SetIntervals(userId UserId, intervals []data.Interval) error { 59 | keys := ConvertToKeys(intervals) 60 | ep.intervals[userId] = make(intervalSet, len(keys)) 61 | for _, key := range keys { 62 | ep.intervals[userId][key] = true 63 | } 64 | log.Printf("ephemeral: Set Intervals of User %v\n", userId) 65 | 66 | return nil 67 | } 68 | 69 | // AddInterval adds a single interval to the intervals stored for a user 70 | func (ep *Ephemeral) AddInterval(userId UserId, interval data.Interval) error { 71 | if ep.intervals[userId] == nil { 72 | ep.intervals[userId] = make(intervalSet) 73 | } 74 | 75 | ep.intervals[userId][IntervalToKey(interval)] = true 76 | log.Printf("ephemeral: Added an Interval to User %v\n", userId) 77 | 78 | return nil 79 | } 80 | 81 | // RemoveInterval removes an interval from the intervals stored for a user 82 | func (ep *Ephemeral) RemoveInterval(userId UserId, interval data.Interval) error { 83 | delete(ep.intervals[userId], IntervalToKey(interval)) 84 | log.Printf("ephemeral: Removed an Interval of User %v\n", userId) 85 | 86 | return nil 87 | } 88 | 89 | // ModifyIntervals atomically adds and deletes a specified set 90 | // of intervals 91 | func (ep *Ephemeral) ModifyIntervals(userId UserId, add []data.Interval, del []data.Interval) error { 92 | for _, interval := range del { 93 | delete(ep.intervals[userId], IntervalToKey(interval)) 94 | } 95 | 96 | if ep.intervals[userId] == nil { 97 | ep.intervals[userId] = make(intervalSet, len(add)) 98 | } 99 | for _, interval := range add { 100 | ep.intervals[userId][IntervalToKey(interval)] = true 101 | } 102 | 103 | log.Printf("ephemeral: Modified Intervals of User %v\n", userId) 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timewarrior Sync Server 2 | This repository contains the Server of the Timewarrior Sync project. 3 | 4 | # Setup 5 | 6 | ## Building from source 7 | 8 | First you need to build the server: 9 | ```sh 10 | go build -o timew-server 11 | ``` 12 | 13 | If you haven't already, create a folder named `authorized_keys` in the same folder as your executable: 14 | ```sh 15 | mkdir authorized_keys 16 | ``` 17 | 18 | Now you can start using your server: 19 | ```sh 20 | ./timew-server start 21 | ``` 22 | 23 | ## Using Nix 24 | 25 | `timew-sync-server` is included in Nixpkgs. To install 26 | `timew-sync-server` into your current environment, use: 27 | 28 | ```sh 29 | nix-env -i timew-sync-server 30 | ``` 31 | 32 | Then, follow the instructions above (create a directory for the keys 33 | and start the server). 34 | 35 | ## Using docker 36 | 37 | You can build a docker image using the provided `Dockerfile`: 38 | ```sh 39 | # Build a docker image tagged timew-server 40 | docker build -t timew-server . 41 | ``` 42 | 43 | To start the server, use: 44 | ```sh 45 | # Running from the docker image tagged timew-server 46 | docker run -p 8080:8080 timew-server 47 | 48 | # Start an existing docker container 49 | docker start 50 | ``` 51 | 52 | Subcommands can be used via `docker exec`: 53 | ```sh 54 | docker exec -it server 55 | ``` 56 | 57 | # Usage 58 | 59 | ## Starting the server 60 | 61 | Start the server using the `start` subcommand: 62 | ```sh 63 | ./timew-server start 64 | ``` 65 | 66 | The `start` subcommand supports the following (optional) flags: 67 | - `--config-file`: (reserved, not used yet) Specifies the path to the configuration file 68 | - `--port`: Specifies the port. Default: 8080 69 | - `--keys-location`: Specifies the folder holding the authorized keys. Default: `authorized_keys` 70 | - `--no-auth`: Deactivates client authentication. Only for testing purposes. 71 | - `--sqlite-db`: Path to the sqlite database. Default: `db.sqlite` 72 | 73 | ## Adding users 74 | 75 | New users can be registered using the `add-user` subcommand: 76 | ```sh 77 | ./timew-server add-user 78 | ``` 79 | 80 | If no additional flags are specified, this command will return the new user id. 81 | 82 | The `add-user` subcommand supports the following flags: 83 | - `--path`: Specifies the path to a public key and associates it with the user. 84 | - `--keys-location`: Specifies the folder holding the authorized keys. Default: `authorized_keys` 85 | 86 | **Note:** If you are running our provided Docker image, see the note under `Adding keys to users`. 87 | 88 | ## Adding keys to users 89 | 90 | New keys can be added using the `add-key` subcommand: 91 | ```sh 92 | ./timew-server add-key --path public-key.pem --id 93 | ``` 94 | 95 | The `add-key` subcommand supports the following flags: 96 | - `--path` (**required**): Specifies the path to a public key and associates it with the user. 97 | - `--id` (**required**): Specifies the user id. 98 | - `--keys-location`: Specifies the folder holding the authorized keys. Default: `authorized_keys` 99 | 100 | **Note:** If you are running the server inside a docker container, you have to copy the key into the container first: 101 | 102 | ```sh 103 | # Copy the key into /public-key.pem 104 | docker cp public-key.pem :/public-key.pem 105 | 106 | # Add the key 107 | docker exec -it server add-key --path /public-key.pem --id 108 | ``` 109 | 110 | # Development 111 | 112 | The code has to be formatted using `go fmt` before commits. To enforce this, we provide a pre-commit-hook. It can be setup by copying it into to your `.git/hooks/pre-commit`: 113 | 114 | ```sh 115 | cp git/hooks/pre-commit .git/hooks/pre-commit 116 | ``` 117 | 118 | # Acknowledgements 119 | This project was developed during the so-called "Bachelorpraktikum" at TU Darmstadt. It was supervised by the Department of Biology, [Computer-aided Synthetic Biology](https://www.bio.tu-darmstadt.de/forschung/ressearch_groups/Kabisch_Start.en.jsp). For more information visit [kabisch-lab.de](http://kabisch-lab.de). 120 | 121 | This work was supported by the BMBF-funded de.NBI Cloud within the German Network for Bioinformatics Infrastructure (de.NBI) (031A532B, 031A533A, 031A533B, 031A534A, 031A535A, 031A537A, 031A537B, 031A537C, 031A537D, 031A538A). 122 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package storage 19 | 20 | import ( 21 | "encoding/json" 22 | "github.com/timewarrior-synchronize/timew-sync-server/data" 23 | "log" 24 | "time" 25 | ) 26 | 27 | // A UserId represents a unique ID assigned to each user of the 28 | // timewarrior sync server 29 | type UserId int64 30 | 31 | type IntervalKey struct { 32 | Start time.Time 33 | End time.Time 34 | Tags string 35 | Annotation string 36 | } 37 | 38 | // ConvertToKeys converts a slice of data.Interval to a slice of IntervalKey 39 | func ConvertToKeys(data []data.Interval) []IntervalKey { 40 | result := make([]IntervalKey, len(data), len(data)) 41 | for i, interval := range data { 42 | result[i] = IntervalToKey(interval) 43 | } 44 | return result 45 | } 46 | 47 | // ConvertToIntervals converts a slice of IntervalKey to a slice of data.Interval 48 | func ConvertToIntervals(keys []IntervalKey) []data.Interval { 49 | result := make([]data.Interval, len(keys), len(keys)) 50 | for i, key := range keys { 51 | result[i] = KeyToInterval(key) 52 | } 53 | return result 54 | } 55 | 56 | // IntervalToKey converts a data.Interval struct to an IntervalKey struct which can be used as key in maps 57 | func IntervalToKey(data data.Interval) IntervalKey { 58 | result, err := json.Marshal(data.Tags) 59 | if err != nil { 60 | log.Printf("Error parsing tag Array %v to json string", data.Tags) 61 | } 62 | return IntervalKey{ 63 | Start: data.Start, 64 | End: data.End, 65 | Tags: string(result), 66 | Annotation: data.Annotation, 67 | } 68 | } 69 | 70 | // KeyToInterval converts an IntervalKey struct to a data.Interval struct. 71 | func KeyToInterval(key IntervalKey) data.Interval { 72 | result := data.Interval{ 73 | Start: key.Start, 74 | End: key.End, 75 | Tags: nil, 76 | Annotation: key.Annotation, 77 | } 78 | err := json.Unmarshal([]byte(key.Tags), &result.Tags) 79 | if err != nil { 80 | log.Printf("Error parsing Tags json-String %v to slice of string", key.Tags) 81 | } 82 | return result 83 | } 84 | 85 | // Storage defines an interface for accessing stored intervals. 86 | // Every User has a set of intervals, which can be accessed and modified independently. 87 | type Storage interface { 88 | // Initialize runs all necessary setup for this Storage instance 89 | Initialize() error 90 | 91 | // Acquire the lock for this user id 92 | Lock(userId UserId) 93 | 94 | // Release the lock for this user id 95 | Unlock(userId UserId) 96 | 97 | // GetIntervals returns all intervals associated with a user 98 | GetIntervals(userId UserId) ([]data.Interval, error) 99 | 100 | // SetIntervals overrides all intervals of a user 101 | SetIntervals(userId UserId, intervals []data.Interval) error 102 | 103 | // ModifyIntervals atomically adds and deletes a specified set 104 | // of intervals 105 | ModifyIntervals(userId UserId, add []data.Interval, del []data.Interval) error 106 | 107 | // AddInterval adds an interval to a user's intervals 108 | AddInterval(userId UserId, interval data.Interval) error 109 | 110 | // RemoveInterval removes an interval from a user's intervals 111 | RemoveInterval(userId UserId, interval data.Interval) error 112 | } 113 | 114 | var GlobalStorage Storage 115 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 2 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 7 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 8 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 9 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 10 | github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= 11 | github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= 12 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 13 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 14 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 15 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 16 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 17 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 18 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 19 | github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= 20 | github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= 21 | github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= 22 | github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 23 | github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 24 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 25 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 26 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 27 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 28 | github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54= 29 | github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c= 30 | github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 31 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 32 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 33 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 34 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 35 | github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 36 | github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 37 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 38 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 43 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 45 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 47 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 49 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 50 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | -------------------------------------------------------------------------------- /sync/algo_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package sync 18 | 19 | import ( 20 | "github.com/timewarrior-synchronize/timew-sync-server/data" 21 | "github.com/timewarrior-synchronize/timew-sync-server/storage" 22 | "testing" 23 | "time" 24 | ) 25 | 26 | func contains(slice []data.Interval, interval data.Interval) bool { 27 | keySlice := storage.ConvertToKeys(slice) 28 | for _, a := range keySlice { 29 | if a == storage.IntervalToKey(interval) { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | 36 | func TestSync(t *testing.T) { 37 | store := storage.Ephemeral{} 38 | serverState := []data.Interval{ 39 | { 40 | Start: time.Time{}, 41 | End: time.Time{}, 42 | Tags: nil, 43 | Annotation: "a", 44 | }, 45 | { 46 | Start: time.Time{}, 47 | End: time.Time{}, 48 | Tags: nil, 49 | Annotation: "b", 50 | }, 51 | { 52 | Start: time.Time{}, 53 | End: time.Time{}, 54 | Tags: nil, 55 | Annotation: "c", 56 | }, 57 | { 58 | Start: time.Time{}, 59 | End: time.Time{}, 60 | Tags: nil, 61 | Annotation: "x", 62 | }, 63 | } 64 | added := []data.Interval{ 65 | { 66 | Start: time.Time{}, 67 | End: time.Time{}, 68 | Tags: nil, 69 | Annotation: "a", 70 | }, 71 | { 72 | Start: time.Time{}, 73 | End: time.Time{}, 74 | Tags: nil, 75 | Annotation: "b", 76 | }, 77 | { 78 | Start: time.Time{}, 79 | End: time.Time{}, 80 | Tags: nil, 81 | Annotation: "d", 82 | }, 83 | { 84 | Start: time.Time{}, 85 | End: time.Time{}, 86 | Tags: nil, 87 | Annotation: "e", 88 | }, 89 | } 90 | removed := []data.Interval{ 91 | { 92 | Start: time.Time{}, 93 | End: time.Time{}, 94 | Tags: nil, 95 | Annotation: "c", 96 | }, 97 | { 98 | Start: time.Time{}, 99 | End: time.Time{}, 100 | Tags: nil, 101 | Annotation: "e", 102 | }, 103 | { 104 | Start: time.Time{}, 105 | End: time.Time{}, 106 | Tags: nil, 107 | Annotation: "f", 108 | }, 109 | } 110 | expected := []data.Interval{ 111 | { 112 | Start: time.Time{}, 113 | End: time.Time{}, 114 | Tags: nil, 115 | Annotation: "a", 116 | }, 117 | { 118 | Start: time.Time{}, 119 | End: time.Time{}, 120 | Tags: nil, 121 | Annotation: "b", 122 | }, 123 | { 124 | Start: time.Time{}, 125 | End: time.Time{}, 126 | Tags: nil, 127 | Annotation: "d", 128 | }, 129 | { 130 | Start: time.Time{}, 131 | End: time.Time{}, 132 | Tags: nil, 133 | Annotation: "e", 134 | }, 135 | { 136 | Start: time.Time{}, 137 | End: time.Time{}, 138 | Tags: nil, 139 | Annotation: "x", 140 | }, 141 | } 142 | 143 | req := data.SyncRequest{ 144 | UserID: 0, 145 | Added: added, 146 | Removed: removed, 147 | } 148 | store.Initialize() 149 | store.SetIntervals(storage.UserId(0), serverState) 150 | result, _, err := Sync(req, &store) 151 | 152 | if err != nil { 153 | t.Errorf("Sync failed with error %v", err) 154 | } 155 | 156 | if len(result) != len(expected) { 157 | t.Errorf("Sync result wrong. Expected %v got %v", expected, result) 158 | } 159 | for _, interval := range expected { 160 | if !contains(result, interval) { 161 | t.Errorf("Sync result does not contain interval %v", interval) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /data/interval.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package data 19 | 20 | import ( 21 | "fmt" 22 | "strings" 23 | "time" 24 | ) 25 | 26 | // layout used by timewarrior. Needed for time conversion. See e.g. time.Parse 27 | const timeLayout = "20060102T150405Z" 28 | 29 | // Interval represents a timewarrior interval. 30 | // It contains a start and end time and the tags associated 31 | // with the interval. 32 | type Interval struct { 33 | Start time.Time 34 | End time.Time 35 | Tags []string 36 | Annotation string 37 | } 38 | 39 | // JSONInterval represents the JSON structure of an interval. As we 40 | // represent times in strings, these need to be converted to and from 41 | // time.Time instances. 42 | type JSONInterval struct { 43 | Start string `json:"start"` 44 | End string `json:"end"` 45 | Tags []string `json:"tags"` 46 | Annotation string `json:"annotation"` 47 | } 48 | 49 | // Converts this JSON interval representation into our internal 50 | // representation of an interval. An error might occur during parsing 51 | // of either the start or end time. 52 | func (json JSONInterval) ToInterval() (Interval, error) { 53 | start, err := time.Parse(timeLayout, json.Start) 54 | if err != nil { 55 | return Interval{}, fmt.Errorf("Error while start time: %v", err) 56 | } 57 | 58 | end, err := time.Parse(timeLayout, json.End) 59 | if err != nil { 60 | return Interval{}, fmt.Errorf("Error while end time: %v", err) 61 | } 62 | 63 | return Interval{ 64 | Start: start, 65 | End: end, 66 | Tags: json.Tags, 67 | Annotation: json.Annotation, 68 | }, nil 69 | } 70 | 71 | // Converts this interval into an instance of a JSONInterval struct 72 | // which can be marshalled to JSON. 73 | func (interval Interval) ToJSONInterval() JSONInterval { 74 | start := interval.Start.Format(timeLayout) 75 | end := interval.End.Format(timeLayout) 76 | 77 | return JSONInterval{ 78 | Start: start, 79 | End: end, 80 | Tags: interval.Tags, 81 | Annotation: interval.Annotation, 82 | } 83 | } 84 | 85 | // Convenience wrapper around ToInterval() which batch processes a 86 | // slice of JSONInterval 87 | func FromJSONIntervals(intervals []JSONInterval) ([]Interval, error) { 88 | result := make([]Interval, len(intervals)) 89 | 90 | for i, x := range intervals { 91 | interval, err := x.ToInterval() 92 | 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | result[i] = interval 98 | } 99 | 100 | return result, nil 101 | } 102 | 103 | // Convenience wrapper around ToJSONInterval() which batch processes a 104 | // slice of Interval 105 | func ToJSONIntervals(intervals []Interval) []JSONInterval { 106 | result := make([]JSONInterval, len(intervals)) 107 | 108 | for i, x := range intervals { 109 | result[i] = x.ToJSONInterval() 110 | } 111 | 112 | return result 113 | } 114 | 115 | // Serialize converts an Interval struct into a string in timewarrior format. 116 | func (interval Interval) Serialize() string { 117 | startTime := interval.Start.Format(timeLayout) 118 | endTime := interval.End.Format(timeLayout) 119 | 120 | intervalTime := fmt.Sprintf("inc %v - %v", startTime, endTime) 121 | 122 | if len(interval.Tags) > 0 { 123 | intervalTime = fmt.Sprintf("%v # %v", intervalTime, strings.Join(interval.Tags, " ")) 124 | } 125 | 126 | return intervalTime 127 | } 128 | 129 | // A String method for the interval struct. 130 | // This convenience method implements the fmt.Stringer interface 131 | // and converts the Interval into a human readable format. 132 | func (interval Interval) String() string { 133 | return interval.Serialize() 134 | } 135 | 136 | // IntervalsToStrings converts a slice of Interval structs to a slice of the corresponding timewarrior interval strings 137 | // Important: the LastModified information is not contained in the string representation 138 | func IntervalsToStrings(intervals []Interval) []string { 139 | result := make([]string, len(intervals), len(intervals)) 140 | 141 | for i, element := range intervals { 142 | result[i] = element.Serialize() 143 | } 144 | return result 145 | } 146 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package main 19 | 20 | import ( 21 | "database/sql" 22 | "flag" 23 | "fmt" 24 | "github.com/timewarrior-synchronize/timew-sync-server/storage" 25 | "github.com/timewarrior-synchronize/timew-sync-server/sync" 26 | "log" 27 | "net/http" 28 | "os" 29 | 30 | _ "github.com/mattn/go-sqlite3" 31 | ) 32 | 33 | var versionFlag bool 34 | var configFilePath string 35 | var portNumber int 36 | var keyDirectoryPath string 37 | var dbPath string 38 | var noAuth bool 39 | var sourcePath string 40 | var userID int64 41 | 42 | func main() { 43 | startCmd := flag.NewFlagSet("start", flag.ExitOnError) 44 | addUserCmd := flag.NewFlagSet("add-user", flag.ExitOnError) 45 | addKeyCmd := flag.NewFlagSet("add-key", flag.ExitOnError) 46 | 47 | startCmd.StringVar(&configFilePath, "config-file", "", "[RESERVED, not used] Path to the configuration file") 48 | startCmd.IntVar(&portNumber, "port", 8080, "Port on which the server will listen for connections") 49 | startCmd.StringVar(&keyDirectoryPath, "keys-location", "authorized_keys", "Path to the users' public keys") 50 | startCmd.StringVar(&dbPath, "sqlite-db", "db.sqlite", "Path to the SQLite database") 51 | startCmd.BoolVar(&noAuth, "no-auth", false, "Run server without client authentication") 52 | 53 | addUserCmd.StringVar(&sourcePath, "path", "", "Supply the path to a PEM RSA key") 54 | addUserCmd.StringVar(&keyDirectoryPath, "keys-location", "authorized_keys", "Path to the users' public keys") 55 | 56 | addKeyCmd.StringVar(&sourcePath, "path", "", "Supply the path to a PEM RSA key") 57 | addKeyCmd.Int64Var(&userID, "id", -1, "Supply user id") 58 | addKeyCmd.StringVar(&keyDirectoryPath, "keys-location", "authorized_keys", "Path to the users' public keys") 59 | 60 | flag.BoolVar(&versionFlag, "version", false, "Print version information") 61 | 62 | if len(os.Args) < 2 { 63 | _, _ = fmt.Fprintf(os.Stderr, "Use commands start, add-user or add-key\n") 64 | os.Exit(1) 65 | } 66 | 67 | switch os.Args[1] { 68 | case "start": 69 | _ = startCmd.Parse(os.Args[2:]) 70 | sync.PublicKeyLocation = keyDirectoryPath 71 | case "add-user": 72 | addUserCase(addUserCmd) 73 | case "add-key": 74 | addKeyCase(addKeyCmd) 75 | default: 76 | flag.Parse() 77 | if versionFlag { 78 | _, _ = fmt.Fprintf(os.Stderr, "timewarrior sync server version %v\n", "1.2.0") 79 | os.Exit(0) 80 | } else { 81 | log.Fatal("Use commands start, add-user or add-key") 82 | } 83 | } 84 | 85 | db, err := sql.Open("sqlite3", dbPath) 86 | if err != nil { 87 | log.Fatalf("Error while opening SQLite database: %v", err) 88 | } 89 | defer db.Close() 90 | sqlStorage := &storage.Sql{DB: db} 91 | 92 | err = sqlStorage.Initialize() 93 | if err != nil { 94 | log.Fatalf("Error while initializing database: %v", err) 95 | } 96 | storage.GlobalStorage = sqlStorage 97 | 98 | handler := func(w http.ResponseWriter, req *http.Request) { 99 | sync.HandleSyncRequest(w, req, noAuth) 100 | } 101 | http.HandleFunc("/api/sync", handler) 102 | 103 | log.Printf("Listening on Port %v", portNumber) 104 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", portNumber), nil)) 105 | } 106 | 107 | // Subcommand for adding a new user 108 | func addUserCase(addUserCmd *flag.FlagSet) { 109 | _ = addUserCmd.Parse(os.Args[2:]) 110 | sync.PublicKeyLocation = keyDirectoryPath 111 | id := sync.GetFreeUserID() 112 | if sourcePath == "" { 113 | sync.AddKey(id, "") 114 | } else { 115 | key := sync.ReadKey(sourcePath) 116 | sync.AddKey(id, key) 117 | } 118 | _, _ = fmt.Fprintf(os.Stderr, "Successfully added new user %v", id) 119 | os.Exit(0) 120 | } 121 | 122 | // Subcommand for adding a new key 123 | func addKeyCase(addKeyCmd *flag.FlagSet) { 124 | _ = addKeyCmd.Parse(os.Args[2:]) 125 | sync.PublicKeyLocation = keyDirectoryPath 126 | if sourcePath == "" { 127 | log.Fatal("Provide a key file with --path [path-to-key-file]") 128 | } 129 | if userID < 0 { 130 | log.Fatal("Provide a non-negative user id with --id [user id]") 131 | } 132 | used := sync.GetUsedUserIDs() 133 | if !used[userID] { 134 | log.Fatalf("User %v does not exist", userID) 135 | } 136 | key := sync.ReadKey(sourcePath) 137 | sync.AddKey(userID, key) 138 | _, _ = fmt.Fprintf(os.Stderr, "Successfully added new key to user %v", userID) 139 | os.Exit(0) 140 | } 141 | -------------------------------------------------------------------------------- /sync/auth_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 - Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "crypto/rand" 22 | "crypto/rsa" 23 | "github.com/lestrrat-go/jwx/jwa" 24 | "github.com/lestrrat-go/jwx/jwk" 25 | "github.com/lestrrat-go/jwx/jwt" 26 | "net/http" 27 | "testing" 28 | "time" 29 | ) 30 | 31 | func TestAuthenticateWithKeySet_positive(t *testing.T) { 32 | raw1, err1 := rsa.GenerateKey(rand.Reader, 1024) 33 | raw2, err2 := rsa.GenerateKey(rand.Reader, 1024) 34 | key1, err3 := jwk.New(raw1) 35 | key2, err4 := jwk.New(raw2) 36 | pub1, err5 := jwk.PublicKeyOf(key1) 37 | pub2, err6 := jwk.PublicKeyOf(key2) 38 | keySet := jwk.NewSet() 39 | keySet.Add(pub1) 40 | keySet.Add(pub2) 41 | token := jwt.New() 42 | token.Set("userID", 42) 43 | token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)) 44 | payload, err7 := jwt.Sign(token, jwa.RS256, key2) 45 | bearer := "Bearer " + string(payload) 46 | req, err8 := http.NewRequest("POST", "", nil) 47 | req.Header.Add("Authorization", bearer) 48 | if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err6 != nil || err7 != nil || 49 | err8 != nil { 50 | t.Errorf("Failed to generate key set in preparation for testing") 51 | } 52 | b := AuthenticateWithKeySet(req, 42, keySet) 53 | if !b { 54 | t.Errorf("Failed to authenticate") 55 | } 56 | 57 | } 58 | 59 | func TestAuthenticateWithKeySet_negative(t *testing.T) { 60 | raw1, err1 := rsa.GenerateKey(rand.Reader, 1024) 61 | raw2, err2 := rsa.GenerateKey(rand.Reader, 1024) 62 | key1, err3 := jwk.New(raw1) 63 | key2, err4 := jwk.New(raw2) 64 | pub1, err5 := jwk.PublicKeyOf(key1) 65 | keySet := jwk.NewSet() 66 | keySet.Add(pub1) 67 | token := jwt.New() 68 | token.Set("userID", 42) 69 | token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)) 70 | payload, err7 := jwt.Sign(token, jwa.RS256, key2) 71 | bearer := "Bearer " + string(payload) 72 | req, err8 := http.NewRequest("POST", "", nil) 73 | req.Header.Add("Authorization", bearer) 74 | if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err7 != nil || 75 | err8 != nil { 76 | t.Errorf("Failed to generate key set in preparation for testing") 77 | } 78 | b := AuthenticateWithKeySet(req, 42, keySet) 79 | if b { 80 | t.Errorf("Authenticated falsely") 81 | } 82 | } 83 | 84 | func TestAuthenticateWithKeySet_expired(t *testing.T) { 85 | raw1, err1 := rsa.GenerateKey(rand.Reader, 1024) 86 | raw2, err2 := rsa.GenerateKey(rand.Reader, 1024) 87 | key1, err3 := jwk.New(raw1) 88 | key2, err4 := jwk.New(raw2) 89 | pub1, err5 := jwk.PublicKeyOf(key1) 90 | pub2, err6 := jwk.PublicKeyOf(key2) 91 | keySet := jwk.NewSet() 92 | keySet.Add(pub1) 93 | keySet.Add(pub2) 94 | token := jwt.New() 95 | token.Set("userID", 42) 96 | token.Set(jwt.ExpirationKey, time.Now().Add(-time.Hour)) 97 | payload, err7 := jwt.Sign(token, jwa.RS256, key2) 98 | bearer := "Bearer " + string(payload) 99 | req, err8 := http.NewRequest("POST", "", nil) 100 | req.Header.Add("Authorization", bearer) 101 | if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err6 != nil || err7 != nil || 102 | err8 != nil { 103 | t.Errorf("Failed to generate key set in preparation for testing") 104 | } 105 | b := AuthenticateWithKeySet(req, 42, keySet) 106 | if b { 107 | t.Errorf("Authenticated with expired jwt") 108 | } 109 | } 110 | 111 | func TestAuthenticateWithKeySet_IDMismatch(t *testing.T) { 112 | raw1, err1 := rsa.GenerateKey(rand.Reader, 1024) 113 | raw2, err2 := rsa.GenerateKey(rand.Reader, 1024) 114 | key1, err3 := jwk.New(raw1) 115 | key2, err4 := jwk.New(raw2) 116 | pub1, err5 := jwk.PublicKeyOf(key1) 117 | pub2, err6 := jwk.PublicKeyOf(key2) 118 | keySet := jwk.NewSet() 119 | keySet.Add(pub1) 120 | keySet.Add(pub2) 121 | token := jwt.New() 122 | token.Set("userID", 42) 123 | token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)) 124 | payload, err7 := jwt.Sign(token, jwa.RS256, key2) 125 | bearer := "Bearer " + string(payload) 126 | req, err8 := http.NewRequest("POST", "", nil) 127 | req.Header.Add("Authorization", bearer) 128 | if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err6 != nil || err7 != nil || 129 | err8 != nil { 130 | t.Errorf("Failed to generate key set in preparation for testing") 131 | } 132 | b := AuthenticateWithKeySet(req, 0, keySet) 133 | if b { 134 | t.Errorf("Authenticated with mismatching userIDs") 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /storage/ephemeral_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package storage 19 | 20 | import ( 21 | "github.com/google/go-cmp/cmp" 22 | "github.com/timewarrior-synchronize/timew-sync-server/data" 23 | "testing" 24 | "time" 25 | ) 26 | 27 | func TestEphemeralStorage(t *testing.T) { 28 | var s Storage 29 | intervals := []data.Interval{ 30 | { 31 | Start: time.Date(2020, time.December, 24, 18, 0, 0, 0, time.UTC), 32 | End: time.Date(2020, time.December, 24, 22, 0, 0, 0, time.UTC), 33 | Tags: []string{"Merry", "Christmas"}, 34 | Annotation: "test", 35 | }, 36 | {}, 37 | } 38 | s = &Ephemeral{} 39 | _ = s.Initialize() 40 | _ = s.SetIntervals(0, intervals) 41 | result, _ := s.GetIntervals(0) 42 | 43 | if len(result) != len(intervals) { 44 | t.Errorf("length doesn't match, expected %v, got %v", len(intervals), len(result)) 45 | } 46 | 47 | for _, x := range result { 48 | correct := false 49 | for i, _ := range intervals { 50 | if diff := cmp.Diff(intervals[i], x); diff == "" { 51 | correct = true 52 | } 53 | } 54 | if !correct { 55 | t.Errorf("result: %v not as expected: %v They do not contain exactly the same elements", result, intervals) 56 | } 57 | } 58 | } 59 | 60 | func TestEphemeralStorage_ModifyIntervals(t *testing.T) { 61 | var s Storage 62 | add := []data.Interval{ 63 | { 64 | Start: time.Date(2020, 01, 01, 12, 0, 0, 0, time.UTC), 65 | End: time.Date(2020, 01, 01, 13, 0, 0, 0, time.UTC), 66 | Tags: []string{"Tag3", "Tag4"}, 67 | Annotation: "Annotation2", 68 | }, 69 | } 70 | del := []data.Interval{ 71 | { 72 | Start: time.Date(2021, 01, 01, 12, 0, 0, 0, time.UTC), 73 | End: time.Date(2021, 01, 01, 13, 0, 0, 0, time.UTC), 74 | Tags: []string{"Tag1", "Tag2"}, 75 | Annotation: "Annotation", 76 | }, 77 | } 78 | 79 | s = &Ephemeral{} 80 | _ = s.Initialize() 81 | _ = s.SetIntervals(42, del) 82 | _ = s.ModifyIntervals(42, add, del) 83 | result, _ := s.GetIntervals(42) 84 | 85 | if len(result) != len(add) { 86 | t.Errorf("length doesn't match, expected %v, got %v", len(add), len(result)) 87 | } 88 | 89 | for _, x := range result { 90 | correct := false 91 | for i, _ := range add { 92 | if diff := cmp.Diff(add[i], x); diff == "" { 93 | correct = true 94 | } 95 | } 96 | if !correct { 97 | t.Errorf("result: %v not as expected: %v They do not contain exactly the same elements", result, add) 98 | } 99 | } 100 | } 101 | 102 | func TestEphemeral_ModifyIntervals_add(t *testing.T) { 103 | var s Storage 104 | 105 | add := []data.Interval{ 106 | { 107 | Start: time.Date(2020, 01, 01, 12, 0, 0, 0, time.UTC), 108 | End: time.Date(2020, 01, 01, 13, 0, 0, 0, time.UTC), 109 | Tags: []string{"Tag3", "Tag4"}, 110 | Annotation: "Annotation2", 111 | }, 112 | { 113 | Start: time.Date(2021, 01, 01, 12, 0, 0, 0, time.UTC), 114 | End: time.Date(2021, 01, 01, 13, 0, 0, 0, time.UTC), 115 | Tags: []string{"Tag1", "Tag2"}, 116 | Annotation: "Annotation1", 117 | }, 118 | } 119 | 120 | s = &Ephemeral{} 121 | _ = s.Initialize() 122 | _ = s.ModifyIntervals(0, add, []data.Interval{}) 123 | result, _ := s.GetIntervals(0) 124 | 125 | if len(result) != len(add) { 126 | t.Errorf("length doesn't match, expected %v, got %v", len(add), len(result)) 127 | } 128 | 129 | for _, x := range result { 130 | correct := false 131 | for i, _ := range add { 132 | if diff := cmp.Diff(add[i], x); diff == "" { 133 | correct = true 134 | } 135 | } 136 | if !correct { 137 | t.Errorf("result: %v not as expected: %v They do not contain exactly the same elements", result, add) 138 | } 139 | } 140 | } 141 | 142 | func TestEphemeral_AddInterval(t *testing.T) { 143 | var s Storage 144 | 145 | add := data.Interval{ 146 | Start: time.Date(2020, 01, 01, 12, 0, 0, 0, time.UTC), 147 | End: time.Date(2020, 01, 01, 13, 0, 0, 0, time.UTC), 148 | Tags: []string{"Tag3", "Tag4"}, 149 | Annotation: "Annotation2", 150 | } 151 | 152 | s = &Ephemeral{} 153 | _ = s.Initialize() 154 | _ = s.AddInterval(0, add) 155 | result, _ := s.GetIntervals(0) 156 | 157 | if len(result) != 1 { 158 | t.Errorf("length doesn't match, expected %v, got %v", 1, len(result)) 159 | } 160 | 161 | if diff := cmp.Diff(add, result[0]); diff != "" { 162 | t.Errorf("result: %v not as expected: %v", result, add) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /storage/sql.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package storage 18 | 19 | import ( 20 | "context" 21 | "database/sql" 22 | "embed" 23 | "fmt" 24 | "github.com/golang-migrate/migrate/v4" 25 | "github.com/golang-migrate/migrate/v4/database/sqlite3" 26 | "github.com/golang-migrate/migrate/v4/source/iofs" 27 | "github.com/timewarrior-synchronize/timew-sync-server/data" 28 | "log" 29 | ) 30 | 31 | //go:embed migrations/*.sql 32 | var migrationsFS embed.FS 33 | 34 | type Sql struct { 35 | LockerRoom 36 | DB *sql.DB 37 | } 38 | 39 | // Initialize runs all necessary setup for this Storage instance 40 | func (s *Sql) Initialize() error { 41 | s.InitializeLockerRoom() 42 | 43 | d, err := iofs.New(migrationsFS, "migrations") 44 | if err != nil { 45 | log.Fatalf("Error loading database migrations: %v", err) 46 | } 47 | 48 | instance, err := sqlite3.WithInstance(s.DB, &sqlite3.Config{}) 49 | if err != nil { 50 | log.Fatalf("Error connecting to database for migrations: %v", err) 51 | } 52 | 53 | m, err := migrate.NewWithInstance("iofs", d, "sqlite3", instance) 54 | if err != nil { 55 | log.Fatalf("Error setting up database migrations: %v", err) 56 | } 57 | 58 | err = m.Up() 59 | if err != nil { 60 | if err != migrate.ErrNoChange { 61 | log.Fatalf("Error running database migrations: %v", err) 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // GetIntervals returns all intervals stored for a user 69 | // Returns an error, if there are problems while reading the data 70 | func (s *Sql) GetIntervals(userId UserId) ([]data.Interval, error) { 71 | var intervals []IntervalKey 72 | 73 | q := ` 74 | SELECT start_time, end_time, tags, annotation 75 | FROM interval 76 | WHERE user_id == $1 77 | ` 78 | rows, err := s.DB.Query(q, userId) 79 | if err != nil { 80 | return nil, fmt.Errorf("sql_storage: Error during SQL Query: %w", err) 81 | } 82 | defer rows.Close() 83 | 84 | for rows.Next() { 85 | interval := IntervalKey{} 86 | err = rows.Scan(&interval.Start, &interval.End, &interval.Tags, &interval.Annotation) 87 | if err != nil { 88 | return nil, fmt.Errorf("sql_storage: Error while reading database row: %w", err) 89 | } 90 | 91 | intervals = append(intervals, interval) 92 | } 93 | 94 | return ConvertToIntervals(intervals), nil 95 | } 96 | 97 | // SetIntervals replaces all intervals stored for a user 98 | // Returns an error if an error occurs while replacing the data 99 | func (s *Sql) SetIntervals(userId UserId, intervals []data.Interval) error { 100 | ctx := context.Background() 101 | tx, err := s.DB.BeginTx(ctx, nil) 102 | if err != nil { 103 | return fmt.Errorf("sql_storage: Error while starting transaction: %w", err) 104 | } 105 | 106 | q := ` 107 | DELETE FROM interval 108 | WHERE user_id = $1 109 | ` 110 | _, err = tx.ExecContext(ctx, q, userId) 111 | if err != nil { 112 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 113 | log.Printf("sql_storage: Unable to rollback: %v", rollbackErr) 114 | return err 115 | } 116 | return err 117 | } 118 | 119 | q = ` 120 | INSERT INTO interval (user_id, start_time, end_time, tags, annotation) 121 | VALUES ($1, $2, $3, $4, $5) 122 | ` 123 | keys := ConvertToKeys(intervals) 124 | for _, key := range keys { 125 | _, err = tx.ExecContext(ctx, q, userId, key.Start, key.End, key.Tags, key.Annotation) 126 | if err != nil { 127 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 128 | log.Printf("sql_storage: Unable to rollback: %v", rollbackErr) 129 | return err 130 | } 131 | return err 132 | } 133 | } 134 | 135 | err = tx.Commit() 136 | if err != nil { 137 | return fmt.Errorf("sql_storage: Error during commit: %w", err) 138 | } 139 | 140 | return nil 141 | } 142 | 143 | // AddInterval adds a single interval to the intervals stored for a user 144 | // Returns an error if an error occurs while adding the interval 145 | func (s *Sql) AddInterval(userId UserId, interval data.Interval) error { 146 | q := ` 147 | INSERT OR IGNORE INTO interval (user_id, start_time, end_time, tags, annotation) 148 | VALUES ($1, $2, $3, $4, $5) 149 | ` 150 | key := IntervalToKey(interval) 151 | _, err := s.DB.Exec(q, userId, key.Start, key.End, key.Tags, key.Annotation) 152 | if err != nil { 153 | return fmt.Errorf("sql_storage: Error while adding interval: %w", err) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | // RemoveInterval removes a single interval from the intervals stored for a user 160 | // Returns an error if an error occurs while deleting the interval 161 | func (s *Sql) RemoveInterval(userId UserId, interval data.Interval) error { 162 | q := ` 163 | DELETE FROM interval 164 | WHERE user_id = $1 AND start_time = $2 AND end_time = $3 AND tags = $4 AND annotation = $5 165 | ` 166 | key := IntervalToKey(interval) 167 | _, err := s.DB.Exec(q, userId, key.Start, key.End, key.Tags, key.Annotation) 168 | if err != nil { 169 | return fmt.Errorf("sql_storage: Error while removing interval: %w", err) 170 | } 171 | 172 | return nil 173 | } 174 | 175 | // ModifyIntervals atomically adds and deletes a specified set of 176 | // intervals. Returns an error if an error occurs while modifying the 177 | // data 178 | func (s *Sql) ModifyIntervals(userId UserId, add []data.Interval, del []data.Interval) error { 179 | ctx := context.Background() 180 | tx, err := s.DB.BeginTx(ctx, nil) 181 | if err != nil { 182 | return fmt.Errorf("sql_storage: Error while starting transaction: %w", err) 183 | } 184 | 185 | // Delete the specified intervals 186 | q := ` 187 | DELETE FROM interval 188 | WHERE user_id = $1 AND start_time = $2 AND end_time = $3 AND tags = $4 AND annotation = $5 189 | ` 190 | keysToDelete := ConvertToKeys(del) 191 | for _, key := range keysToDelete { 192 | _, err = tx.ExecContext(ctx, q, userId, key.Start, key.End, key.Tags, key.Annotation) 193 | if err != nil { 194 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 195 | log.Printf("sql_storage: Unable to rollback: %v", rollbackErr) 196 | return err 197 | } 198 | return err 199 | } 200 | } 201 | 202 | // Add the specified intervals 203 | q = ` 204 | INSERT OR IGNORE INTO interval (user_id, start_time, end_time, tags, annotation) 205 | VALUES ($1, $2, $3, $4, $5) 206 | ` 207 | keysToAdd := ConvertToKeys(add) 208 | for _, key := range keysToAdd { 209 | _, err = tx.ExecContext(ctx, q, userId, key.Start, key.End, key.Tags, key.Annotation) 210 | if err != nil { 211 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 212 | log.Printf("sql_storage: Unable to rollback: %v", rollbackErr) 213 | return err 214 | } 215 | return err 216 | } 217 | } 218 | 219 | err = tx.Commit() 220 | if err != nil { 221 | return fmt.Errorf("sql_storage: Error during commit: %w", err) 222 | } 223 | 224 | return nil 225 | } 226 | -------------------------------------------------------------------------------- /sync/conflict.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | 18 | package sync 19 | 20 | import ( 21 | "fmt" 22 | "github.com/timewarrior-synchronize/timew-sync-server/data" 23 | "github.com/timewarrior-synchronize/timew-sync-server/storage" 24 | "sort" 25 | ) 26 | 27 | // SolveConflict merges overlapping intervals of given user. 28 | // It then updates userId's state in store accordingly 29 | // SolveConflict returns true iff a conflict was detected 30 | func SolveConflict(userId int64, store storage.Storage) (bool, error) { 31 | conflictDetected := false 32 | intervals, err := store.GetIntervals(storage.UserId(userId)) 33 | 34 | var removed []data.Interval 35 | var added []data.Interval 36 | if err != nil { 37 | return false, fmt.Errorf("Unable to retrieve User Data for UserId %v from Storage:\n%v", userId, err) 38 | } 39 | 40 | // Sort intervals by ascending start time (in place) 41 | sort.SliceStable(intervals, func(i, j int) bool { 42 | return intervals[i].Start.Before(intervals[j].Start) 43 | }) 44 | 45 | if len(intervals) == 0 { 46 | return false, nil 47 | } 48 | 49 | openInterval := intervals[0] 50 | var nextInterval data.Interval 51 | intervals = intervals[1:] // treat as interval queue sorted by start time 52 | var addedThisIteration []data.Interval 53 | 54 | // loop invariant: 55 | // openInterval.Start <= intervals[i].Start for all 0 <= i < len(intervals) 56 | // and intervals[i].Start <= intervals[i+1].Start for all 0 <= i < len(intervals) - 1 57 | // in short: append([]data.Interval{openInterval}, intervals) is always sorted by start time 58 | for len(intervals) > 0 { 59 | // pop first interval in queue 60 | interval := intervals[0] 61 | intervals = intervals[1:] 62 | 63 | addedThisIteration = []data.Interval{} 64 | 65 | if interval.Start.Equal(openInterval.End) || interval.Start.After(openInterval.End) { 66 | // standard case - no conflict 67 | openInterval = interval 68 | } else { 69 | // If two intervals (in this case openInterval and interval) are in conflict, both intervals are removed and 70 | // one to three new intervals are created. 71 | // 72 | // The "middle" interval is always created, e.g. an interval with the last start time and first end time of 73 | // the two conflicting intervals. If both conflicting intervals have equal start start times and equal end 74 | // times, only this middle interval is created. 75 | // 76 | // The "end" interval is created iff both conflicting intervals do not share the same end time. It starts 77 | // with the earlier end time and ends with the later end time of the conflicting intervals. 78 | // 79 | // The "start" interval is created iff both conflicting intervals do not share the same start time. It 80 | // starts with the earlier start time and ends with the later start time of the conflicting intervals. 81 | // 82 | // The Tags and Annotation fields of the created intervals are: 83 | // (1) just the Tags and Annotation fields of the interval that includes the timespan of the created 84 | // created interval (iff only one such interval exists) 85 | // (2) the merged Tags and Annotation of both intervals as specified in UniteTagsAndAnnotation else 86 | conflictDetected = true 87 | removed = append(removed, openInterval, interval) 88 | 89 | // end section (if exists) 90 | if !openInterval.End.Equal(interval.End) { 91 | if openInterval.End.After(interval.End) { 92 | nextInterval = data.Interval{ 93 | Start: interval.End, 94 | End: openInterval.End, 95 | Tags: openInterval.Tags, 96 | Annotation: openInterval.Annotation, 97 | } 98 | } else { 99 | nextInterval = data.Interval{ 100 | Start: openInterval.End, 101 | End: interval.End, 102 | Tags: interval.Tags, 103 | Annotation: interval.Annotation, 104 | } 105 | } 106 | addedThisIteration = append(addedThisIteration, nextInterval) 107 | } 108 | 109 | // middle section 110 | tags, annotation := UniteTagsAndAnnotation(openInterval, interval) 111 | if openInterval.End.After(interval.End) { 112 | nextInterval = data.Interval{ 113 | Start: interval.Start, // We have to use this start time since this is the middle section and 114 | // and interval.Start >= openInterval.Start by loop invariant 115 | End: interval.End, 116 | Tags: tags, 117 | Annotation: annotation, 118 | } 119 | } else { 120 | nextInterval = data.Interval{ 121 | Start: interval.Start, 122 | End: openInterval.End, 123 | Tags: tags, 124 | Annotation: annotation, 125 | } 126 | } 127 | addedThisIteration = append(addedThisIteration, nextInterval) 128 | 129 | // start section 130 | if !openInterval.Start.Equal(interval.Start) { 131 | nextInterval = data.Interval{ 132 | Start: openInterval.Start, 133 | End: interval.Start, 134 | Tags: openInterval.Tags, 135 | Annotation: openInterval.Annotation, 136 | } 137 | addedThisIteration = append(addedThisIteration, nextInterval) 138 | } 139 | 140 | // getting ready for next iteration 141 | openInterval = nextInterval 142 | added = append(added, addedThisIteration...) 143 | 144 | // reinsert newly created intervals 145 | intervals = append(intervals, addedThisIteration[:len(addedThisIteration)-1]...) 146 | sort.SliceStable(intervals, func(i, j int) bool { 147 | return intervals[i].Start.Before(intervals[j].Start) 148 | }) // Maybe just iterating from left to right over intervals and inserting at the correct time is faster, 149 | // since intervals from addedThisIteration will probably have an "early" start time 150 | } 151 | } 152 | 153 | // Transfer solved conflict state to storage 154 | for _, a := range added { 155 | err = store.AddInterval(storage.UserId(userId), a) 156 | if err != nil { 157 | return conflictDetected, fmt.Errorf("Unable to change User Data for UserId %v in Storage:\n%v", 158 | userId, err) 159 | } 160 | } 161 | for _, r := range removed { 162 | err = store.RemoveInterval(storage.UserId(userId), r) 163 | if err != nil { 164 | return conflictDetected, fmt.Errorf("Unable to change User Data for UserId %v in Storage:\n%v", 165 | userId, err) 166 | } 167 | } 168 | 169 | return conflictDetected, nil 170 | } 171 | 172 | // UniteTagsAndAnnotation computes the new tags and annotation for overlapping intervals and returns tags, annotation. 173 | // Case 1: Iff only one interval has an Annotation, we use this annotation. Case 2: Iff no interval has an annotation, 174 | // we use "" as annotation. Case 3: Iff both intervals have different annotation, we use "" as annotation, and add both 175 | // annotation to tags. Case 4: Iff both intervals have the same annotation, we just use that annotation 176 | // As tags we return the alphabetically sorted union of both intervals' tags (and both annotations in Case 3) 177 | // without duplicates. 178 | func UniteTagsAndAnnotation(a data.Interval, b data.Interval) ([]string, string) { 179 | tags := make([]string, len(a.Tags), len(a.Tags)+len(b.Tags)) 180 | tmp := make([]string, len(b.Tags)) 181 | copy(tags, a.Tags) 182 | copy(tmp, b.Tags) 183 | annotation := "" 184 | tags = append(tags, tmp...) 185 | if a.Annotation != "" && b.Annotation != "" && a.Annotation != b.Annotation { 186 | tags = append(tags, a.Annotation, b.Annotation) 187 | } else if a.Annotation == "" { 188 | annotation = b.Annotation 189 | } else { 190 | annotation = a.Annotation 191 | } 192 | sort.Strings(tags) 193 | i := 1 194 | for i < len(tags) { 195 | if tags[i] == tags[i-1] { 196 | tags = append(tags[:i], tags[i+1:]...) 197 | } else { 198 | i++ 199 | } 200 | } 201 | return tags, annotation 202 | } 203 | -------------------------------------------------------------------------------- /storage/sql_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package storage 18 | 19 | import ( 20 | "fmt" 21 | "github.com/google/go-cmp/cmp" 22 | "github.com/timewarrior-synchronize/timew-sync-server/data" 23 | "testing" 24 | "time" 25 | 26 | "github.com/DATA-DOG/go-sqlmock" 27 | ) 28 | 29 | func TestSql_GetIntervals(t *testing.T) { 30 | db, mock, err := sqlmock.New() 31 | if err != nil { 32 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 33 | } 34 | defer db.Close() 35 | 36 | expected := []data.Interval{ 37 | { 38 | Start: time.Time{}, 39 | End: time.Time{}, 40 | Tags: []string{"Tag1", "Tag2"}, 41 | Annotation: "", 42 | }, 43 | { 44 | Tags: []string{"Tag3", "Tag4"}, 45 | Annotation: "Annotation", 46 | }, 47 | } 48 | 49 | q := ` 50 | SELECT start_time, end_time, tags, annotation 51 | FROM interval 52 | WHERE user_id == ? 53 | ` 54 | columns := []string{"start_time", "end_time", "tags", "annotation"} 55 | mock.ExpectQuery(q). 56 | WithArgs(4). 57 | WillReturnRows( 58 | sqlmock.NewRows(columns). 59 | AddRow(time.Time{}, time.Time{}, IntervalToKey(expected[0]).Tags, ""). 60 | AddRow(time.Time{}, time.Time{}, IntervalToKey(expected[1]).Tags, "Annotation")) 61 | 62 | sql := Sql{DB: db} 63 | result, err := sql.GetIntervals(4) 64 | if err != nil { 65 | t.Errorf("Error '%s' during GetIntervals", err) 66 | } 67 | 68 | if diff := cmp.Diff(expected, result); diff != "" { 69 | t.Errorf("Results differ from expected:\n%s", diff) 70 | } 71 | 72 | if err := mock.ExpectationsWereMet(); err != nil { 73 | t.Errorf("there were unfulfilled expectations: %s", err) 74 | } 75 | } 76 | 77 | func TestSql_SetIntervals(t *testing.T) { 78 | db, mock, err := sqlmock.New() 79 | if err != nil { 80 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 81 | } 82 | defer db.Close() 83 | 84 | testData := []data.Interval{ 85 | { 86 | Start: time.Date(2020, 12, 29, 20, 0, 0, 0, time.UTC), 87 | End: time.Date(2020, 12, 29, 23, 0, 0, 0, time.UTC), 88 | Tags: []string{"Tag1", "Tag2"}, 89 | Annotation: "Annotation", 90 | }, 91 | { 92 | Tags: []string{"Tag3", "Tag4"}, 93 | Annotation: "Annotation2", 94 | }, 95 | } 96 | 97 | mock.ExpectBegin() 98 | q := ` 99 | DELETE FROM interval 100 | WHERE user_id = \$1 101 | ` 102 | mock.ExpectExec(q).WithArgs(42).WillReturnResult(sqlmock.NewResult(0, 0)) 103 | 104 | q = ` 105 | INSERT INTO interval 106 | ` 107 | mock.ExpectExec(q). 108 | WithArgs(42, testData[0].Start, testData[0].End, IntervalToKey(testData[0]).Tags, testData[0].Annotation). 109 | WillReturnResult(sqlmock.NewResult(0, 0)) 110 | mock.ExpectExec(q). 111 | WithArgs(42, testData[1].Start, testData[1].End, IntervalToKey(testData[1]).Tags, testData[1].Annotation). 112 | WillReturnResult(sqlmock.NewResult(0, 0)) 113 | 114 | mock.ExpectCommit() 115 | 116 | sql := Sql{DB: db} 117 | err = sql.SetIntervals(42, testData) 118 | if err != nil { 119 | t.Errorf("Error '%s' during SetIntervals", err) 120 | } 121 | 122 | if err := mock.ExpectationsWereMet(); err != nil { 123 | t.Errorf("there were unfulfilled expectations: %s", err) 124 | } 125 | } 126 | 127 | func TestSql_AddInterval(t *testing.T) { 128 | db, mock, err := sqlmock.New() 129 | if err != nil { 130 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 131 | } 132 | defer db.Close() 133 | 134 | testData := data.Interval{ 135 | Start: time.Date(2003, 3, 12, 7, 20, 15, 0, time.UTC), 136 | End: time.Date(2004, 2, 4, 16, 30, 43, 0, time.UTC), 137 | Tags: []string{"TestTag", "TestTag2"}, 138 | Annotation: "TestAnnotation", 139 | } 140 | 141 | q := ` 142 | INSERT OR IGNORE INTO interval \(user_id, start_time, end_time, tags, annotation\) 143 | VALUES \(\$1, \$2, \$3, \$4, \$5\) 144 | ` 145 | mock.ExpectExec(q). 146 | WithArgs(3, testData.Start, testData.End, IntervalToKey(testData).Tags, testData.Annotation). 147 | WillReturnResult(sqlmock.NewResult(1, 1)) 148 | 149 | sql := Sql{DB: db} 150 | err = sql.AddInterval(3, testData) 151 | if err != nil { 152 | t.Errorf("Error '%s' during AddInterval", err) 153 | } 154 | } 155 | 156 | func TestSql_RemoveInterval(t *testing.T) { 157 | db, mock, err := sqlmock.New() 158 | if err != nil { 159 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 160 | } 161 | defer db.Close() 162 | 163 | testData := data.Interval{ 164 | Start: time.Date(2030, 2, 24, 14, 23, 42, 0, time.UTC), 165 | End: time.Date(2030, 2, 24, 17, 24, 0, 0, time.UTC), 166 | Tags: []string{"Tag1", "Tag2", "Tag3"}, 167 | Annotation: "Annotation", 168 | } 169 | 170 | q := ` 171 | DELETE FROM interval 172 | WHERE user_id = \$1 AND start_time = \$2 AND end_time = \$3 AND tags = \$4 AND annotation = \$5 173 | ` 174 | mock.ExpectExec(q). 175 | WithArgs(0, testData.Start, testData.End, IntervalToKey(testData).Tags, testData.Annotation). 176 | WillReturnResult(sqlmock.NewResult(0, 0)) 177 | 178 | sql := Sql{DB: db} 179 | err = sql.RemoveInterval(0, testData) 180 | if err != nil { 181 | t.Errorf("Error '%s' during RemoveInterval", err) 182 | } 183 | } 184 | 185 | func TestSql_ModifyIntervals(t *testing.T) { 186 | db, mock, err := sqlmock.New() 187 | if err != nil { 188 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 189 | } 190 | defer db.Close() 191 | 192 | add := []data.Interval{ 193 | { 194 | Start: time.Date(2020, 01, 01, 12, 0, 0, 0, time.UTC), 195 | End: time.Date(2020, 01, 01, 13, 0, 0, 0, time.UTC), 196 | Tags: []string{"Tag3", "Tag4"}, 197 | Annotation: "Annotation2", 198 | }, 199 | } 200 | 201 | del := []data.Interval{ 202 | { 203 | Start: time.Date(2021, 01, 01, 12, 0, 0, 0, time.UTC), 204 | End: time.Date(2021, 01, 01, 13, 0, 0, 0, time.UTC), 205 | Tags: []string{"Tag1", "Tag2"}, 206 | Annotation: "Annotation", 207 | }, 208 | } 209 | 210 | mock.ExpectBegin() 211 | q := ` 212 | DELETE FROM interval 213 | WHERE user_id = \$1 AND start_time = \$2 AND end_time = \$3 AND tags = \$4 AND annotation = \$5 214 | ` 215 | mock.ExpectExec(q). 216 | WithArgs(123, del[0].Start, del[0].End, IntervalToKey(del[0]).Tags, del[0].Annotation). 217 | WillReturnResult(sqlmock.NewResult(0, 0)) 218 | 219 | q = ` 220 | INSERT OR IGNORE INTO interval \(user_id, start_time, end_time, tags, annotation\) 221 | VALUES \(\$1, \$2, \$3, \$4, \$5\) 222 | ` 223 | mock.ExpectExec(q). 224 | WithArgs(123, add[0].Start, add[0].End, IntervalToKey(add[0]).Tags, add[0].Annotation). 225 | WillReturnResult(sqlmock.NewResult(0, 0)) 226 | 227 | mock.ExpectCommit() 228 | 229 | sql := Sql{DB: db} 230 | err = sql.ModifyIntervals(123, add, del) 231 | if err != nil { 232 | t.Errorf("Error '%s' during SetIntervals", err) 233 | } 234 | 235 | if err := mock.ExpectationsWereMet(); err != nil { 236 | t.Errorf("there were unfulfilled expectations: %s", err) 237 | } 238 | 239 | } 240 | 241 | func TestSql_ModifyIntervals_Rollback(t *testing.T) { 242 | db, mock, err := sqlmock.New() 243 | if err != nil { 244 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 245 | } 246 | defer db.Close() 247 | 248 | add := []data.Interval{ 249 | { 250 | Start: time.Date(2020, 01, 01, 12, 0, 0, 0, time.UTC), 251 | End: time.Date(2020, 01, 01, 13, 0, 0, 0, time.UTC), 252 | Tags: []string{"Tag3", "Tag4"}, 253 | Annotation: "Annotation2", 254 | }, 255 | } 256 | 257 | del := []data.Interval{ 258 | { 259 | Start: time.Date(2021, 01, 01, 12, 0, 0, 0, time.UTC), 260 | End: time.Date(2021, 01, 01, 13, 0, 0, 0, time.UTC), 261 | Tags: []string{"Tag1", "Tag2"}, 262 | Annotation: "Annotation", 263 | }, 264 | } 265 | 266 | mock.ExpectBegin() 267 | q := ` 268 | DELETE FROM interval 269 | WHERE user_id = \$1 AND start_time = \$2 AND end_time = \$3 AND tags = \$4 AND annotation = \$5 270 | ` 271 | mock.ExpectExec(q). 272 | WithArgs(123, del[0].Start, del[0].End, IntervalToKey(del[0]).Tags, del[0].Annotation). 273 | WillReturnError(fmt.Errorf("Artificial error")) 274 | 275 | mock.ExpectRollback() 276 | 277 | sql := Sql{DB: db} 278 | err = sql.ModifyIntervals(123, add, del) 279 | if err == nil { 280 | t.Errorf("Expected error, but got none") 281 | } 282 | 283 | if err := mock.ExpectationsWereMet(); err != nil { 284 | t.Errorf("there were unfulfilled expectations: %s", err) 285 | } 286 | 287 | } 288 | -------------------------------------------------------------------------------- /sync/conflict_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 - 2021, Jan Bormet, Anna-Felicitas Hausmann, Joachim Schmidt, Vincent Stollenwerk, Arne Turuc 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 15 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package sync 18 | 19 | import ( 20 | "fmt" 21 | "github.com/timewarrior-synchronize/timew-sync-server/data" 22 | "github.com/timewarrior-synchronize/timew-sync-server/storage" 23 | "reflect" 24 | "testing" 25 | "time" 26 | ) 27 | 28 | func elementwiseEqual(aSlice []data.Interval, bSlice []data.Interval) bool { 29 | keyA := storage.ConvertToKeys(aSlice) 30 | keyB := storage.ConvertToKeys(bSlice) 31 | if len(keyA) != len(keyB) { 32 | return false 33 | } 34 | for _, a := range keyA { 35 | match := false 36 | for i, b := range keyB { 37 | if a == b { 38 | match = true 39 | keyB = append(keyB[:i], keyB[i+1:]...) 40 | break 41 | } 42 | } 43 | if !match { 44 | return false 45 | } 46 | } 47 | return true 48 | } 49 | func sliceString(s []data.Interval) { 50 | print("[\n") 51 | for _, i := range s { 52 | fmt.Printf("\n-------------------------------------\nStart = %v\nEnd = %v\nTags = %v\nAnnotation = %v", 53 | i.Start, i.End, i.Tags, i.Annotation) 54 | } 55 | fmt.Printf("\n]\n\n\n") 56 | } 57 | 58 | func TestSolveConflict_MultiConflict(t *testing.T) { 59 | store := storage.Ephemeral{} 60 | serverStateMultiConflict := []data.Interval{ 61 | { 62 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 63 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 64 | Tags: []string{"tag1"}, 65 | Annotation: "all normal here", 66 | }, 67 | { 68 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 69 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 70 | Tags: []string{"starting"}, 71 | Annotation: "problemStart", 72 | }, 73 | { 74 | Start: time.Date(2000, 5, 11, 13, 30, 40, 0, time.UTC), 75 | End: time.Date(2000, 5, 11, 19, 30, 40, 0, time.UTC), 76 | Tags: []string{"middle"}, 77 | Annotation: "problemMiddle", 78 | }, 79 | { 80 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 81 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 82 | Tags: []string{"ending"}, 83 | Annotation: "problemEnd", 84 | }, 85 | { 86 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 87 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 88 | Tags: []string{"tag2"}, 89 | Annotation: "all normal here", 90 | }, 91 | } 92 | 93 | multiConflictExpected := []data.Interval{ 94 | { 95 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 96 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 97 | Tags: []string{"tag1"}, 98 | Annotation: "all normal here", 99 | }, 100 | { 101 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 102 | End: time.Date(2000, 5, 11, 13, 30, 40, 0, time.UTC), 103 | Tags: []string{"starting"}, 104 | Annotation: "problemStart", 105 | }, 106 | { 107 | Start: time.Date(2000, 5, 11, 13, 30, 40, 0, time.UTC), 108 | End: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 109 | Tags: []string{"middle", "problemMiddle", "problemStart", "starting"}, 110 | Annotation: "", 111 | }, 112 | { 113 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 114 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 115 | Tags: []string{"ending", "middle", "problemMiddle", "problemStart", "starting"}, 116 | Annotation: "problemEnd", 117 | }, 118 | { 119 | Start: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 120 | End: time.Date(2000, 5, 11, 19, 30, 40, 0, time.UTC), 121 | Tags: []string{"ending", "middle", "problemEnd", "problemMiddle"}, 122 | Annotation: "", 123 | }, 124 | { 125 | Start: time.Date(2000, 5, 11, 19, 30, 40, 0, time.UTC), 126 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 127 | Tags: []string{"ending"}, 128 | Annotation: "problemEnd", 129 | }, 130 | { 131 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 132 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 133 | Tags: []string{"tag2"}, 134 | Annotation: "all normal here", 135 | }, 136 | } 137 | store.Initialize() 138 | store.SetIntervals(storage.UserId(0), serverStateMultiConflict) 139 | 140 | conflict, err := SolveConflict(0, &store) 141 | if err != nil { 142 | t.Errorf("MultiConflict: Solve failed with error %v", err) 143 | } 144 | if !conflict { 145 | t.Errorf("MultiConflict: Solve did not detected a conflict") 146 | } 147 | result, _ := store.GetIntervals(storage.UserId(0)) 148 | sliceString(multiConflictExpected) 149 | sliceString(result) 150 | if !elementwiseEqual(multiConflictExpected, result) { 151 | t.Errorf("MultiConflict: State after solve wrong. Expected %v got %v", multiConflictExpected, result) 152 | } 153 | } 154 | 155 | func TestSolveConflict_InnerInterval(t *testing.T) { 156 | store := storage.Ephemeral{} 157 | serverInnerInterval := []data.Interval{ 158 | { 159 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 160 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 161 | Tags: []string{"tag1"}, 162 | Annotation: "all normal here", 163 | }, 164 | { 165 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 166 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 167 | Tags: []string{"outer"}, 168 | Annotation: "o", 169 | }, 170 | { 171 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 172 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 173 | Tags: []string{"inner"}, 174 | Annotation: "i", 175 | }, 176 | { 177 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 178 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 179 | Tags: []string{"tag2"}, 180 | Annotation: "all normal here", 181 | }, 182 | } 183 | 184 | innerIntervalExpected := []data.Interval{ 185 | { 186 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 187 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 188 | Tags: []string{"tag1"}, 189 | Annotation: "all normal here", 190 | }, 191 | { 192 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 193 | End: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 194 | Tags: []string{"outer"}, 195 | Annotation: "o", 196 | }, 197 | { // merged 198 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 199 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 200 | Tags: []string{"i", "inner", "o", "outer"}, 201 | Annotation: "", 202 | }, 203 | { 204 | Start: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 205 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 206 | Tags: []string{"outer"}, 207 | Annotation: "o", 208 | }, 209 | { 210 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 211 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 212 | Tags: []string{"tag2"}, 213 | Annotation: "all normal here", 214 | }, 215 | } 216 | store.Initialize() 217 | store.SetIntervals(storage.UserId(0), serverInnerInterval) 218 | 219 | conflict, err := SolveConflict(0, &store) 220 | if err != nil { 221 | t.Errorf("InnerInterval: Solve failed with error %v", err) 222 | } 223 | if !conflict { 224 | t.Errorf("InnerInterval: Solve did not detected a conflict") 225 | } 226 | result, _ := store.GetIntervals(storage.UserId(0)) 227 | if !elementwiseEqual(innerIntervalExpected, result) { 228 | t.Errorf("InnerInterval: State after solve wrong. Expected %v got %v", innerIntervalExpected, result) 229 | } 230 | } 231 | 232 | func TestSolveConflict_SameEnd(t *testing.T) { 233 | store := storage.Ephemeral{} 234 | serverStateSameEnd := []data.Interval{ 235 | { 236 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 237 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 238 | Tags: []string{"tag1"}, 239 | Annotation: "all normal here", 240 | }, 241 | { 242 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 243 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 244 | Tags: []string{"c", "a", "b"}, 245 | Annotation: "problemOne", 246 | }, 247 | { 248 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 249 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 250 | Tags: []string{"e", "d", "c"}, 251 | Annotation: "problemTwo", 252 | }, 253 | { 254 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 255 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 256 | Tags: []string{"tag2"}, 257 | Annotation: "all normal here", 258 | }, 259 | } 260 | 261 | sameEndExpected := []data.Interval{ 262 | { 263 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 264 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 265 | Tags: []string{"tag1"}, 266 | Annotation: "all normal here", 267 | }, 268 | { 269 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 270 | End: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 271 | Tags: []string{"c", "a", "b"}, 272 | Annotation: "problemOne", 273 | }, 274 | { 275 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 276 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 277 | Tags: []string{"a", "b", "c", "d", "e", "problemOne", "problemTwo"}, 278 | Annotation: "", 279 | }, 280 | { 281 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 282 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 283 | Tags: []string{"tag2"}, 284 | Annotation: "all normal here", 285 | }, 286 | } 287 | store.Initialize() 288 | store.SetIntervals(storage.UserId(0), serverStateSameEnd) 289 | 290 | conflict, err := SolveConflict(0, &store) 291 | if err != nil { 292 | t.Errorf("SameEnd: Solve failed with error %v", err) 293 | } 294 | if !conflict { 295 | t.Errorf("SameEnd: Solve did not detected a conflict") 296 | } 297 | result, _ := store.GetIntervals(storage.UserId(0)) 298 | if !elementwiseEqual(sameEndExpected, result) { 299 | t.Errorf("SameEnd: State after solve wrong. Expected %v got %v", sameEndExpected, result) 300 | } 301 | } 302 | 303 | func TestSolveConflict_SameStart(t *testing.T) { 304 | store := storage.Ephemeral{} 305 | serverStateSameStart := []data.Interval{ 306 | { 307 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 308 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 309 | Tags: []string{"tag1"}, 310 | Annotation: "all normal here", 311 | }, 312 | { 313 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 314 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 315 | Tags: []string{"c", "a", "b"}, 316 | Annotation: "problemOne", 317 | }, 318 | { 319 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 320 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 321 | Tags: []string{"e", "d", "c"}, 322 | Annotation: "problemTwo", 323 | }, 324 | { 325 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 326 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 327 | Tags: []string{"tag2"}, 328 | Annotation: "all normal here", 329 | }, 330 | } 331 | 332 | sameStartExpected := []data.Interval{ 333 | { 334 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 335 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 336 | Tags: []string{"tag1"}, 337 | Annotation: "all normal here", 338 | }, 339 | { 340 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 341 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 342 | Tags: []string{"a", "b", "c", "d", "e", "problemOne", "problemTwo"}, 343 | Annotation: "", 344 | }, 345 | { 346 | Start: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 347 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 348 | Tags: []string{"e", "d", "c"}, 349 | Annotation: "problemTwo", 350 | }, 351 | { 352 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 353 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 354 | Tags: []string{"tag2"}, 355 | Annotation: "all normal here", 356 | }, 357 | } 358 | store.Initialize() 359 | store.SetIntervals(storage.UserId(0), serverStateSameStart) 360 | 361 | conflict, err := SolveConflict(0, &store) 362 | if err != nil { 363 | t.Errorf("SameStart: Solve failed with error %v", err) 364 | } 365 | if !conflict { 366 | t.Errorf("SameStart: Solve did not detected a conflict") 367 | } 368 | result, _ := store.GetIntervals(storage.UserId(0)) 369 | if !elementwiseEqual(sameStartExpected, result) { 370 | t.Errorf("SameStart: State after solve wrong. Expected %v got %v", sameStartExpected, result) 371 | } 372 | } 373 | 374 | func TestSolveConflict_Congruent(t *testing.T) { 375 | store := storage.Ephemeral{} 376 | serverStateCongruent := []data.Interval{ 377 | { 378 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 379 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 380 | Tags: []string{"tag1"}, 381 | Annotation: "all normal here", 382 | }, 383 | { 384 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 385 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 386 | Tags: []string{"c", "a", "b"}, 387 | Annotation: "problemOne", 388 | }, 389 | { 390 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 391 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 392 | Tags: []string{"e", "d", "c"}, 393 | Annotation: "problemTwo", 394 | }, 395 | { 396 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 397 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 398 | Tags: []string{"tag2"}, 399 | Annotation: "all normal here", 400 | }, 401 | } 402 | 403 | congruentExpected := []data.Interval{ 404 | { 405 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 406 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 407 | Tags: []string{"tag1"}, 408 | Annotation: "all normal here", 409 | }, 410 | { 411 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 412 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 413 | Tags: []string{"a", "b", "c", "d", "e", "problemOne", "problemTwo"}, 414 | Annotation: "", 415 | }, 416 | { 417 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 418 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 419 | Tags: []string{"tag2"}, 420 | Annotation: "all normal here", 421 | }, 422 | } 423 | store.Initialize() 424 | store.SetIntervals(storage.UserId(0), serverStateCongruent) 425 | 426 | conflict, err := SolveConflict(0, &store) 427 | if err != nil { 428 | t.Errorf("Congruent: Solve failed with error %v", err) 429 | } 430 | if !conflict { 431 | t.Errorf("Congruent: Solve did not detected a conflict") 432 | } 433 | result, _ := store.GetIntervals(storage.UserId(0)) 434 | if !elementwiseEqual(congruentExpected, result) { 435 | t.Errorf("Congruent: State after solve wrong. Expected %v got %v", congruentExpected, result) 436 | } 437 | } 438 | 439 | func TestSolveConflict_Overlap(t *testing.T) { 440 | store := storage.Ephemeral{} 441 | serverStateOverlap := []data.Interval{ 442 | { 443 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 444 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 445 | Tags: []string{"tag1"}, 446 | Annotation: "all normal here", 447 | }, 448 | { 449 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 450 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 451 | Tags: []string{"starting"}, 452 | Annotation: "problemStart", 453 | }, 454 | { 455 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 456 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 457 | Tags: []string{"ending"}, 458 | Annotation: "problemEnd", 459 | }, 460 | { 461 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 462 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 463 | Tags: []string{"tag2"}, 464 | Annotation: "all normal here", 465 | }, 466 | } 467 | 468 | overlapExpected := []data.Interval{ 469 | { 470 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 471 | End: time.Date(2000, 5, 10, 18, 30, 40, 0, time.UTC), 472 | Tags: []string{"tag1"}, 473 | Annotation: "all normal here", 474 | }, 475 | { 476 | Start: time.Date(2000, 5, 11, 12, 30, 40, 0, time.UTC), 477 | End: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 478 | Tags: []string{"starting"}, 479 | Annotation: "problemStart", 480 | }, 481 | { // merged 482 | Start: time.Date(2000, 5, 11, 14, 30, 40, 0, time.UTC), 483 | End: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 484 | Tags: []string{"ending", "problemEnd", "problemStart", "starting"}, 485 | Annotation: "", 486 | }, 487 | { 488 | Start: time.Date(2000, 5, 11, 18, 30, 40, 0, time.UTC), 489 | End: time.Date(2000, 5, 11, 21, 30, 40, 0, time.UTC), 490 | Tags: []string{"ending"}, 491 | Annotation: "problemEnd", 492 | }, 493 | { 494 | Start: time.Date(2000, 5, 12, 12, 30, 40, 0, time.UTC), 495 | End: time.Date(2000, 5, 12, 18, 30, 40, 0, time.UTC), 496 | Tags: []string{"tag2"}, 497 | Annotation: "all normal here", 498 | }, 499 | } 500 | store.Initialize() 501 | store.SetIntervals(storage.UserId(0), serverStateOverlap) 502 | 503 | conflict, err := SolveConflict(0, &store) 504 | if err != nil { 505 | t.Errorf("Overlap: Solve failed with error %v", err) 506 | } 507 | if !conflict { 508 | t.Errorf("Overlap: Solve did not detected a conflict") 509 | } 510 | result, _ := store.GetIntervals(storage.UserId(0)) 511 | if !elementwiseEqual(overlapExpected, result) { 512 | t.Errorf("Overlap: State after solve wrong. Expected %v got %v", overlapExpected, result) 513 | } 514 | } 515 | 516 | func TestSolveConflict_NoConflicts(t *testing.T) { 517 | store := storage.Ephemeral{} 518 | serverStateNoConflicts := []data.Interval{ 519 | { 520 | Start: time.Date(2000, 5, 10, 12, 30, 40, 0, time.UTC), 521 | End: time.Date(2000, 5, 10, 13, 30, 40, 0, time.UTC), // +1h 522 | Tags: []string{"tag1", "tag2"}, 523 | Annotation: "a", 524 | }, 525 | { 526 | Start: time.Date(2000, 4, 10, 13, 30, 40, 0, time.UTC), 527 | End: time.Date(2000, 4, 10, 13, 30, 40, 0, time.UTC), 528 | Tags: []string{"tag1"}, 529 | Annotation: "b", 530 | }, 531 | } 532 | store.Initialize() 533 | store.SetIntervals(storage.UserId(0), serverStateNoConflicts) 534 | 535 | conflict, err := SolveConflict(0, &store) 536 | if err != nil { 537 | t.Errorf("NoConflicts: Solve failed with error %v", err) 538 | } 539 | if conflict { 540 | t.Errorf("NoConflicts: Solve falsely detected a conflict") 541 | } 542 | result, _ := store.GetIntervals(storage.UserId(0)) 543 | if !elementwiseEqual(serverStateNoConflicts, result) { 544 | t.Errorf("NoConflicts: State after solve wrong. Expected %v got %v", serverStateNoConflicts, result) 545 | } 546 | } 547 | 548 | func TestSolveConflict_NoIntervals(t *testing.T) { 549 | store := storage.Ephemeral{} 550 | serverStateNoIntervals := []data.Interval{} 551 | 552 | store.Initialize() 553 | store.SetIntervals(storage.UserId(0), serverStateNoIntervals) 554 | 555 | conflict, err := SolveConflict(0, &store) 556 | if err != nil { 557 | t.Errorf("NoIntervals: Solve failed with error %v", err) 558 | } 559 | if conflict { 560 | t.Errorf("NoIntervals: Solve falsely detected a conflict") 561 | } 562 | result, _ := store.GetIntervals(storage.UserId(0)) 563 | if !elementwiseEqual(serverStateNoIntervals, result) { 564 | t.Errorf("NoIntervals: State after solve wrong. Expected %v got %v", serverStateNoIntervals, result) 565 | } 566 | } 567 | 568 | func TestUniteTagsAndAnnotation_AllEmpty(t *testing.T) { 569 | a := data.Interval{ 570 | Start: time.Time{}, 571 | End: time.Time{}, 572 | Tags: []string{}, 573 | Annotation: "", 574 | } 575 | b := data.Interval{ 576 | Start: time.Time{}, 577 | End: time.Time{}, 578 | Tags: []string{}, 579 | Annotation: "", 580 | } 581 | tags, annotation := UniteTagsAndAnnotation(a, b) 582 | if !reflect.DeepEqual(tags, []string{}) { 583 | t.Errorf("AllEmpty: Tags do not match. Expected %v got %v", []string{}, tags) 584 | } 585 | if annotation != "" { 586 | t.Errorf("AllEmpty: Annotation does not match. Expected %v got %v", "", annotation) 587 | } 588 | } 589 | 590 | func TestUniteTagsAndAnnotation_DifferentAnnotations(t *testing.T) { 591 | a := data.Interval{ 592 | Start: time.Time{}, 593 | End: time.Time{}, 594 | Tags: []string{"tag_a"}, 595 | Annotation: "a", 596 | } 597 | b := data.Interval{ 598 | Start: time.Time{}, 599 | End: time.Time{}, 600 | Tags: []string{"tag_b"}, 601 | Annotation: "b", 602 | } 603 | tagsExpected := []string{"a", "b", "tag_a", "tag_b"} 604 | annotationExpected := "" 605 | tags, annotation := UniteTagsAndAnnotation(a, b) 606 | if !reflect.DeepEqual(tags, tagsExpected) { 607 | t.Errorf("DifferentAnnotations: Tags do not match. Expected %v got %v", []string{}, tags) 608 | } 609 | if annotation != annotationExpected { 610 | t.Errorf("DifferentAnnotations: Annotation does not match. Expected %v got %v", "", annotation) 611 | } 612 | } 613 | 614 | func TestUniteTagsAndAnnotation_AnnotationAPresent(t *testing.T) { 615 | a := data.Interval{ 616 | Start: time.Time{}, 617 | End: time.Time{}, 618 | Tags: []string{"tag_a"}, 619 | Annotation: "a", 620 | } 621 | b := data.Interval{ 622 | Start: time.Time{}, 623 | End: time.Time{}, 624 | Tags: []string{"tag_b"}, 625 | Annotation: "", 626 | } 627 | tagsExpected := []string{"tag_a", "tag_b"} 628 | annotationExpected := "a" 629 | tags, annotation := UniteTagsAndAnnotation(a, b) 630 | if !reflect.DeepEqual(tags, tagsExpected) { 631 | t.Errorf("AnnotationAPresent: Tags do not match. Expected %v got %v", []string{}, tags) 632 | } 633 | if annotation != annotationExpected { 634 | t.Errorf("AnnotationAPresent: Annotation does not match. Expected %v got %v", "", annotation) 635 | } 636 | } 637 | 638 | func TestUniteTagsAndAnnotation_AnnotationBPresent(t *testing.T) { 639 | a := data.Interval{ 640 | Start: time.Time{}, 641 | End: time.Time{}, 642 | Tags: []string{"tag_a"}, 643 | Annotation: "", 644 | } 645 | b := data.Interval{ 646 | Start: time.Time{}, 647 | End: time.Time{}, 648 | Tags: []string{"tag_b"}, 649 | Annotation: "b", 650 | } 651 | tagsExpected := []string{"tag_a", "tag_b"} 652 | annotationExpected := "b" 653 | tags, annotation := UniteTagsAndAnnotation(a, b) 654 | if !reflect.DeepEqual(tags, tagsExpected) { 655 | t.Errorf("AnnotationBPresent: Tags do not match. Expected %v got %v", []string{}, tags) 656 | } 657 | if annotation != annotationExpected { 658 | t.Errorf("AnnotationBPresent: Annotation does not match. Expected %v got %v", "", annotation) 659 | } 660 | } 661 | 662 | func TestUniteTagsAndAnnotation_SameAnnotation(t *testing.T) { 663 | a := data.Interval{ 664 | Start: time.Time{}, 665 | End: time.Time{}, 666 | Tags: []string{"tag_a"}, 667 | Annotation: "same", 668 | } 669 | b := data.Interval{ 670 | Start: time.Time{}, 671 | End: time.Time{}, 672 | Tags: []string{"tag_b"}, 673 | Annotation: "same", 674 | } 675 | tagsExpected := []string{"tag_a", "tag_b"} 676 | annotationExpected := "same" 677 | tags, annotation := UniteTagsAndAnnotation(a, b) 678 | if !reflect.DeepEqual(tags, tagsExpected) { 679 | t.Errorf("SameAnnotation: Tags do not match. Expected %v got %v", []string{}, tags) 680 | } 681 | if annotation != annotationExpected { 682 | t.Errorf("SameAnnotation: Annotation does not match. Expected %v got %v", "", annotation) 683 | } 684 | } 685 | 686 | func TestUniteTagsAndAnnotation_TagOverlap(t *testing.T) { 687 | a := data.Interval{ 688 | Start: time.Time{}, 689 | End: time.Time{}, 690 | Tags: []string{"tag_a", "tag_same"}, 691 | Annotation: "a", 692 | } 693 | b := data.Interval{ 694 | Start: time.Time{}, 695 | End: time.Time{}, 696 | Tags: []string{"tag_b", "tag_same"}, 697 | Annotation: "b", 698 | } 699 | tagsExpected := []string{"a", "b", "tag_a", "tag_b", "tag_same"} 700 | annotationExpected := "" 701 | tags, annotation := UniteTagsAndAnnotation(a, b) 702 | if !reflect.DeepEqual(tags, tagsExpected) { 703 | t.Errorf("TagOverlap: Tags do not match. Expected %v got %v", []string{}, tags) 704 | } 705 | if annotation != annotationExpected { 706 | t.Errorf("TagOverlap: Annotation does not match. Expected %v got %v", "", annotation) 707 | } 708 | } 709 | 710 | func TestUniteTagsAndAnnotation_NoTagsDifferentAnnotation(t *testing.T) { 711 | a := data.Interval{ 712 | Start: time.Time{}, 713 | End: time.Time{}, 714 | Tags: []string{}, 715 | Annotation: "a", 716 | } 717 | b := data.Interval{ 718 | Start: time.Time{}, 719 | End: time.Time{}, 720 | Tags: []string{}, 721 | Annotation: "b", 722 | } 723 | tagsExpected := []string{"a", "b"} 724 | annotationExpected := "" 725 | tags, annotation := UniteTagsAndAnnotation(a, b) 726 | if !reflect.DeepEqual(tags, tagsExpected) { 727 | t.Errorf("NoTagsDifferentAnnotation: Tags do not match. Expected %v got %v", []string{}, tags) 728 | } 729 | if annotation != annotationExpected { 730 | t.Errorf("NoTagsDifferentAnnotation: Annotation does not match. "+ 731 | "Expected %v got %v", "", annotation) 732 | } 733 | } 734 | 735 | func TestUniteTagsAndAnnotation_DifferentAnnotationsPresentInTags(t *testing.T) { 736 | a := data.Interval{ 737 | Start: time.Time{}, 738 | End: time.Time{}, 739 | Tags: []string{"b"}, 740 | Annotation: "a", 741 | } 742 | b := data.Interval{ 743 | Start: time.Time{}, 744 | End: time.Time{}, 745 | Tags: []string{"x", "y", "z"}, 746 | Annotation: "b", 747 | } 748 | tagsExpected := []string{"a", "b", "x", "y", "z"} 749 | annotationExpected := "" 750 | tags, annotation := UniteTagsAndAnnotation(a, b) 751 | if !reflect.DeepEqual(tags, tagsExpected) { 752 | t.Errorf("NoTagsDifferentAnnotation: Tags do not match. Expected %v got %v", []string{}, tags) 753 | } 754 | if annotation != annotationExpected { 755 | t.Errorf("NoTagsDifferentAnnotation: Annotation does not match. Expected %v got %v", "", annotation) 756 | } 757 | } 758 | --------------------------------------------------------------------------------