Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions nixos/tracksync.nix
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ let
${lib.optionalString (cfg.stateDB != null) "--state-db \"${cfg.stateDB}\""}) && RC=0 || RC=$?

UPLOADED=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.uploaded // 0')
DUPLICATE=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.duplicate // 0')
SKIPPED=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.skipped // 0')
ERRORS=$(echo "$SUMMARY" | ${pkgs.jq}/bin/jq -r '.errors // 0')

Expand All @@ -79,9 +80,9 @@ let
fi

if [ "$RC" = 0 ]; then
${pkgs.libnotify}/bin/notify-send -i emblem-ok "Tracksync" "$UPLOADED uploaded, $SKIPPED skipped" 2>/dev/null || true
${pkgs.libnotify}/bin/notify-send -i emblem-ok "Tracksync" "$UPLOADED uploaded, $DUPLICATE duplicate, $SKIPPED skipped" 2>/dev/null || true
else
${pkgs.libnotify}/bin/notify-send -i dialog-error "Tracksync" "$UPLOADED uploaded, $SKIPPED skipped, $ERRORS failed" 2>/dev/null || true
${pkgs.libnotify}/bin/notify-send -i dialog-error "Tracksync" "$UPLOADED uploaded, $DUPLICATE duplicate, $SKIPPED skipped, $ERRORS failed" 2>/dev/null || true
exit 1
fi
'';
Expand Down
92 changes: 83 additions & 9 deletions tracksync/internal/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"encoding/hex"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/Quadrubo/tracksync/tracksync/internal/device"
"github.com/Quadrubo/tracksync/tracksync/internal/migrations"
)

Expand Down Expand Up @@ -71,23 +73,31 @@ func ReadToken(path string) (string, error) {
return strings.TrimSpace(string(data)), nil
}

func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFormat, filename string, data []byte) (string, error) {
// UploadStatus represents the outcome of an upload attempt.
type UploadStatus int

const (
StatusUploaded UploadStatus = iota // server accepted as new
StatusDuplicate // server already had this file
)

func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFormat, filename string, data []byte) (UploadStatus, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return "", fmt.Errorf("creating form: %w", err)
return 0, fmt.Errorf("creating form: %w", err)
}
if _, err := part.Write(data); err != nil {
return "", fmt.Errorf("writing form: %w", err)
return 0, fmt.Errorf("writing form: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("closing form: %w", err)
return 0, fmt.Errorf("closing form: %w", err)
}

req, err := http.NewRequest("POST", serverURL+"/upload", &buf)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
return 0, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
Expand All @@ -97,7 +107,7 @@ func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFor

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("sending request: %w", err)
return 0, fmt.Errorf("sending request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

Expand All @@ -106,10 +116,74 @@ func Upload(client *http.Client, serverURL, token, deviceID, hostname, sourceFor

switch resp.StatusCode {
case http.StatusCreated:
return "uploaded", nil
return StatusUploaded, nil
case http.StatusOK:
return "duplicate (server)", nil
return StatusDuplicate, nil
default:
return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, status)
return 0, fmt.Errorf("server returned %d: %s", resp.StatusCode, status)
}
}

// Summary holds the results of a sync operation.
type Summary struct {
Uploaded int `json:"uploaded"`
Duplicate int `json:"duplicate"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
Files []string `json:"files,omitempty"`
}

// SyncFiles syncs a list of found files to the server.
func SyncFiles(db *sql.DB, client *http.Client, serverURL, token, deviceID, hostname string, files []device.FoundFile) Summary {
var summary Summary

for _, ff := range files {
name := filepath.Base(ff.Path)

data, err := os.ReadFile(ff.Path)
if err != nil {
slog.Error("failed to read file", "file", name, "error", err)
summary.Errors++
continue
}

hash := SHA256Hex(data)

uploaded, err := AlreadyUploaded(db, hash)
if err != nil {
slog.Error("failed to check upload state", "file", name, "error", err)
summary.Errors++
continue
}
if uploaded {
slog.Debug("skipped", "file", name, "reason", "already uploaded")
summary.Skipped++
continue
}

status, err := Upload(client, serverURL, token, deviceID, hostname, ff.Format, name, data)
if err != nil {
slog.Error("upload failed", "file", name, "error", err)
summary.Errors++
continue
}

if err := RecordUpload(db, hash, name, deviceID); err != nil {
slog.Error("failed to record upload", "file", name, "error", err)
summary.Errors++
continue
}

switch status {
case StatusUploaded:
slog.Info("uploaded", "file", name)
summary.Uploaded++
summary.Files = append(summary.Files, name)
case StatusDuplicate:
slog.Info("duplicate", "file", name, "reason", "already on server")
summary.Duplicate++
}
}

return summary
}
128 changes: 126 additions & 2 deletions tracksync/internal/sync/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/Quadrubo/tracksync/tracksync/internal/device"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
Expand Down Expand Up @@ -109,7 +110,7 @@ func TestUpload_Created(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
status, err := Upload(client, ts.URL, "tok", "dev-1", "myhost", "gpx_1.1", "track.gpx", []byte("<gpx/>"))
require.NoError(t, err)
assert.Equal(t, "uploaded", status)
assert.Equal(t, StatusUploaded, status)
}

func TestUpload_Duplicate(t *testing.T) {
Expand All @@ -122,7 +123,7 @@ func TestUpload_Duplicate(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
status, err := Upload(client, ts.URL, "tok", "dev", "host", "gpx_1.1", "f.gpx", []byte("data"))
require.NoError(t, err)
assert.Equal(t, "duplicate (server)", status)
assert.Equal(t, StatusDuplicate, status)
}

func TestUpload_ServerError(t *testing.T) {
Expand All @@ -137,6 +138,129 @@ func TestUpload_ServerError(t *testing.T) {
assert.Error(t, err)
}

func TestSyncFiles_Uploaded(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintln(w, "uploaded")
}))
defer ts.Close()

db := openTestDB(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), []byte("<gpx/>"), 0644))

files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)

assert.Equal(t, 1, summary.Uploaded)
assert.Equal(t, 0, summary.Duplicate)
assert.Equal(t, 0, summary.Skipped)
assert.Equal(t, 0, summary.Errors)
assert.Equal(t, []string{"track.gpx"}, summary.Files)
}

func TestSyncFiles_Duplicate(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "duplicate")
}))
defer ts.Close()

db := openTestDB(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), []byte("<gpx/>"), 0644))

files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)

assert.Equal(t, 0, summary.Uploaded)
assert.Equal(t, 1, summary.Duplicate)
assert.Equal(t, 0, summary.Skipped)
assert.Equal(t, 0, summary.Errors)
assert.Nil(t, summary.Files)
}

func TestSyncFiles_SkippedClientSide(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not upload, file already in local DB")
}))
defer ts.Close()

db := openTestDB(t)
dir := t.TempDir()
data := []byte("<gpx/>")
require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), data, 0644))

// Pre-record the file in the local DB
require.NoError(t, RecordUpload(db, SHA256Hex(data), "track.gpx", "dev"))

files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)

assert.Equal(t, 0, summary.Uploaded)
assert.Equal(t, 0, summary.Duplicate)
assert.Equal(t, 1, summary.Skipped)
assert.Equal(t, 0, summary.Errors)
}

func TestSyncFiles_UploadError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintln(w, "error")
}))
defer ts.Close()

db := openTestDB(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "track.gpx"), []byte("<gpx/>"), 0644))

files := []device.FoundFile{{Path: filepath.Join(dir, "track.gpx"), Format: "gpx_1.1"}}
summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)

assert.Equal(t, 0, summary.Uploaded)
assert.Equal(t, 0, summary.Duplicate)
assert.Equal(t, 0, summary.Skipped)
assert.Equal(t, 1, summary.Errors)
}

func TestSyncFiles_MixedResults(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch callCount {
case 1:
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintln(w, "uploaded")
case 2:
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "duplicate")
}
}))
defer ts.Close()

db := openTestDB(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "new.gpx"), []byte("new-data"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dup.gpx"), []byte("dup-data"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.gpx"), []byte("skip-data"), 0644))

// Pre-record skip.gpx
require.NoError(t, RecordUpload(db, SHA256Hex([]byte("skip-data")), "skip.gpx", "dev"))

files := []device.FoundFile{
{Path: filepath.Join(dir, "new.gpx"), Format: "gpx_1.1"},
{Path: filepath.Join(dir, "dup.gpx"), Format: "gpx_1.1"},
{Path: filepath.Join(dir, "skip.gpx"), Format: "gpx_1.1"},
}
summary := SyncFiles(db, &http.Client{Timeout: 5 * time.Second}, ts.URL, "tok", "dev", "host", files)

assert.Equal(t, 1, summary.Uploaded)
assert.Equal(t, 1, summary.Duplicate)
assert.Equal(t, 1, summary.Skipped)
assert.Equal(t, 0, summary.Errors)
assert.Equal(t, []string{"new.gpx"}, summary.Files)
}

func TestOpenStateDB_CreatesDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "dir", "state.db")
db, err := OpenStateDB(path)
Expand Down
Loading
Loading