First commit
This commit is contained in:
commit
d52389f5f2
|
@ -0,0 +1,4 @@
|
||||||
|
/bin/
|
||||||
|
/data/
|
||||||
|
/testdata/
|
||||||
|
/.idea/
|
|
@ -0,0 +1,156 @@
|
||||||
|
# API service info
|
||||||
|
|
||||||
|
This is a Markdown conversion of the API specifications at [https://api.xbrowsersync.org/](https://api.xbrowsersync.org/) .
|
||||||
|
|
||||||
|
## Bookmarks
|
||||||
|
|
||||||
|
### Create bookmarks
|
||||||
|
|
||||||
|
`POST /bookmarks`
|
||||||
|
|
||||||
|
Creates a new (empty) bookmark sync and returns the corresponding ID.
|
||||||
|
|
||||||
|
Post body example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version":"1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **version:** Version number of the xBrowserSync client used to create the sync.
|
||||||
|
|
||||||
|
Response example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id":"52758cb942814faa9ab255208025ae59",
|
||||||
|
"lastUpdated":"2016-07-06T12:43:16.866Z",
|
||||||
|
"version":"1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **id:** 32 character alphanumeric sync ID.
|
||||||
|
- **lastUpdated:** Last updated timestamp for created bookmarks.
|
||||||
|
- **version:**Version number of the xBrowserSync client used to create the sync.
|
||||||
|
|
||||||
|
### Get bookmarks
|
||||||
|
|
||||||
|
`GET /bookmarks/{id}`
|
||||||
|
|
||||||
|
Retrieves the bookmark sync corresponding to the provided sync ID.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- **id:** 32 character alphanumeric sync ID.
|
||||||
|
|
||||||
|
Response example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bookmarks":"DWCx6wR9ggPqPRrhU4O4oLN5P09oULX4Xt+ckxswtFNds...",
|
||||||
|
"lastUpdated":"2016-07-06T12:43:16.866Z",
|
||||||
|
"version":"1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **bookmarks:** Encrypted bookmark data salted using secret value.
|
||||||
|
- **lastUpdated:** Last updated timestamp for retrieved bookmarks.
|
||||||
|
- **version:** Version number of the xBrowserSync client used to create the sync.
|
||||||
|
|
||||||
|
### Update bookmarks
|
||||||
|
|
||||||
|
`PUT /bookmarks/{id}`
|
||||||
|
|
||||||
|
Updates the bookmark sync data corresponding to the provided sync ID with the provided encrypted bookmarks data.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- **id:** 32 character alphanumeric sync ID.
|
||||||
|
|
||||||
|
Post body example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bookmarks":"DWCx6wR9ggPqPRrhU4O4oLN5P09oULX4Xt+ckxswtFNds...",
|
||||||
|
"lastUpdated":"2016-07-06T12:43:16.866Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **bookmarks:** Encrypted bookmark data salted using secret value.
|
||||||
|
- **lastUpdated:** Last updated timestamp to check against existing bookmarks.
|
||||||
|
|
||||||
|
Response example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastUpdated":"2016-07-06T12:43:16.866Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **lastUpdated:** Last updated timestamp for updated bookmarks.
|
||||||
|
|
||||||
|
### Get last updated
|
||||||
|
|
||||||
|
`GET /bookmarks/{id}/lastUpdated`
|
||||||
|
|
||||||
|
Retrieves the bookmark sync last updated timestamp corresponding to the provided sync ID.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- **id:** 32 character alphanumeric sync ID.
|
||||||
|
|
||||||
|
Response example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastUpdated":"2016-07-06T12:43:16.866Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **lastUpdated:** Last updated timestamp for corresponding bookmarks.
|
||||||
|
|
||||||
|
### Get sync version
|
||||||
|
|
||||||
|
`GET /bookmarks/{id}/version`
|
||||||
|
|
||||||
|
Retrieves the bookmark sync version number of the xBrowserSync client used to create the bookmarks sync corresponding
|
||||||
|
to the provided sync ID.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- **id:** 32 character alphanumeric sync ID.
|
||||||
|
|
||||||
|
Response example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version":"1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **version:** Version number of the xBrowserSync client used to create the sync.
|
||||||
|
|
||||||
|
## Service information
|
||||||
|
|
||||||
|
### Get service information
|
||||||
|
|
||||||
|
`GET /info`
|
||||||
|
|
||||||
|
Retrieves information describing the xBrowserSync service.
|
||||||
|
|
||||||
|
Response example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"maxSyncSize":204800,
|
||||||
|
"message":"",
|
||||||
|
"status":1,
|
||||||
|
"version":"1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **status:** Current service status code. 1 = Online; 2 = Offline; 3 = Not accepting new syncs.
|
||||||
|
- **message:** Service information message.
|
||||||
|
- **version:** API version service is using.
|
||||||
|
- **maxSyncSize:** Maximum sync size (in bytes) allowed by the service.
|
|
@ -0,0 +1,24 @@
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
|
@ -0,0 +1,234 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mporrato/uBrowserSync/syncstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceInfoResp struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
MaxSyncSize int32 `json:"maxSyncSize"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReq struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateReq struct {
|
||||||
|
Bookmarks string `json:"bookmarks"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateResp struct {
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LastUpdatedResp struct {
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSyncVerResp struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendJSON(w http.ResponseWriter, status int, data interface{}) error {
|
||||||
|
body, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, err = w.Write(body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendJSONOk(w http.ResponseWriter, data interface{}) error {
|
||||||
|
return sendJSON(w, http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendJSONError(w http.ResponseWriter, err error) error {
|
||||||
|
switch e := err.(type) {
|
||||||
|
case syncstore.SyncError:
|
||||||
|
return sendJSON(w, e.StatusCode, e.Payload)
|
||||||
|
default:
|
||||||
|
return sendJSON(w, http.StatusInternalServerError,
|
||||||
|
syncstore.ErrorPayload{
|
||||||
|
Code: "Internal server error",
|
||||||
|
Message: e.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != "GET" || req.URL.Path != "/info" {
|
||||||
|
sendJSONError(w, syncstore.NotImplementedError)
|
||||||
|
} else {
|
||||||
|
sendJSONOk(w, serviceInfoResp{
|
||||||
|
Version: "1.1.13",
|
||||||
|
Message: "Powered by browsersync",
|
||||||
|
MaxSyncSize: 2 * 1024 * 1024,
|
||||||
|
Status: 1})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var store syncstore.Store
|
||||||
|
|
||||||
|
var invalidRequestError = syncstore.NewSyncError(
|
||||||
|
"Invalid request",
|
||||||
|
"Malformed request body",
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
func createSync(w http.ResponseWriter, req *http.Request) {
|
||||||
|
body := new(CreateReq)
|
||||||
|
err := json.NewDecoder(req.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, invalidRequestError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := store.Create(body.Version)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONOk(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSync(syncId string, w http.ResponseWriter, req *http.Request) {
|
||||||
|
resp, err := store.Get(syncId)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONOk(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLastUpdated(syncId string, w http.ResponseWriter, req *http.Request) {
|
||||||
|
resp, err := store.GetLastUpdated(syncId)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONOk(w, LastUpdatedResp{LastUpdated: resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVersion(syncId string, w http.ResponseWriter, req *http.Request) {
|
||||||
|
resp, err := store.GetVersion(syncId)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONOk(w, GetSyncVerResp{Version: resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSync(syncId string, w http.ResponseWriter, req *http.Request) {
|
||||||
|
body := new(UpdateReq)
|
||||||
|
err := json.NewDecoder(req.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, invalidRequestError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := store.Update(syncId, body.Bookmarks, body.LastUpdated)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONOk(w, UpdateResp{LastUpdated: resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
var sidRe = regexp.MustCompile("^[[:xdigit:]]{32}$")
|
||||||
|
|
||||||
|
func bookmarks(w http.ResponseWriter, req *http.Request) {
|
||||||
|
elements := strings.Split(strings.Trim(req.URL.Path, "/"), "/")
|
||||||
|
|
||||||
|
if len(elements) == 1 && req.Method == "POST" {
|
||||||
|
createSync(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncId := elements[1]
|
||||||
|
if !sidRe.MatchString(elements[1]) {
|
||||||
|
sendJSONError(w, syncstore.NotImplementedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(elements) == 2 {
|
||||||
|
if req.Method == "GET" {
|
||||||
|
getSync(syncId, w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Method == "PUT" {
|
||||||
|
updateSync(syncId, w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: Handle HEAD requests
|
||||||
|
sendJSONError(w, syncstore.MethodNotImplementedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(elements) == 3 {
|
||||||
|
if elements[2] == "lastUpdated" {
|
||||||
|
if req.Method == "GET" {
|
||||||
|
getLastUpdated(syncId, w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONError(w, syncstore.MethodNotImplementedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if elements[2] == "version" {
|
||||||
|
if req.Method == "GET" {
|
||||||
|
getVersion(syncId, w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSONError(w, syncstore.MethodNotImplementedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//sendJSON(w, http.StatusOK, map[string][]string{"elements": elements})
|
||||||
|
sendJSONError(w, syncstore.NotImplementedError)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listen string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
var storeflag string
|
||||||
|
var storeDrv syncstore.StoreDriver
|
||||||
|
|
||||||
|
flag.StringVar(&listen, "listen", ":8090", "listen address and port")
|
||||||
|
flag.StringVar(&storeflag, "store", "fs:data", "blob store driver")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
lstore := strings.Split(storeflag, ":")
|
||||||
|
|
||||||
|
switch lstore[0] {
|
||||||
|
case "fs":
|
||||||
|
if len(lstore) != 2 {
|
||||||
|
err = fmt.Errorf("argument required for fs store driver")
|
||||||
|
} else {
|
||||||
|
storeDrv, err = syncstore.NewFSStore(lstore[1])
|
||||||
|
}
|
||||||
|
case "mem":
|
||||||
|
storeDrv, err = syncstore.NewMemStore()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("Invalid store driver: "+lstore[0])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
store = syncstore.NewStore(storeDrv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.HandleFunc("/info", info)
|
||||||
|
http.HandleFunc("/bookmarks", bookmarks)
|
||||||
|
http.HandleFunc("/bookmarks/", bookmarks)
|
||||||
|
|
||||||
|
http.ListenAndServe(listen, nil)
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
# uBrowserSync
|
||||||
|
|
||||||
|
The _uncomplicated browser sync server_ is an implementation of the [xbrowsersync](https://www.xbrowsersync.org/) server API with no external dependencies.
|
||||||
|
|
||||||
|
Being written in Go, the server is a standalone statically linked binary using the local filesystem as the datastore by
|
||||||
|
default and can be built for any operating system and hardware platform supported by the Go compiler, making it ideal
|
||||||
|
for resource constrained self-hosting scenarios.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
xBrowserSync is a great tool for privacy minded people. The problem with the official server code is about the
|
||||||
|
software stack. It's written in NodeJS and requires MongoDB for storing data. I don't use NodeJS or MongoDB for anything
|
||||||
|
else on my VPS so adding a self-hosted xBrowserSync API server would pull in a lot of code and require a lot of
|
||||||
|
additional resources.
|
||||||
|
|
||||||
|
The xBrowserSync API is simple enough to make writing a new server an almost trivial exercise. This was the perfect
|
||||||
|
opportunity for me to improve my Go programming skills. I'm still very far from being an expert Go developer but it was
|
||||||
|
worth my time.
|
||||||
|
|
||||||
|
## How
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
`go install -ldflags="-s -w" -trimpath ./cmd/ubsserver`
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
`~/go/bin/ubsserver`
|
||||||
|
|
||||||
|
TODO: Suggest deployment setups
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
There are a few features missing that I would like to add.
|
||||||
|
|
||||||
|
Hig priority:
|
||||||
|
- **Enforce storage limit**: it's missing in the current implementation but it must be implemented to prevent abuse
|
||||||
|
|
||||||
|
Nice to have:
|
||||||
|
- **Rate limiting**: currently it can be enforced by a reverse proxy in front of the API server but would be nice to
|
||||||
|
have it as part of the server itself
|
||||||
|
- **Front page**: serving a static HTML page on `/`, perhaps with some simple JS to show the service status, should be
|
||||||
|
fairly straightforward and would improve the end user experience. The main blocker here is my lack of design skills
|
||||||
|
to craft a half decent looking front page
|
||||||
|
- **Migration tool**: a tool able to pull your current sync from a server and push it to a different one might come
|
||||||
|
handy; the main hurdle is that it would require decrypting the payload and re-encrypting it because the sync ID is
|
||||||
|
used as the IV in the AES-GCM encryption process
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The code is released under the "Unlicense" public domain license. See [UNLICENSE](UNLICENSE) for more details.
|
|
@ -0,0 +1,58 @@
|
||||||
|
package syncstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Blob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Bookmarks string `json:"bookmarks"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
LastAccessed time.Time `json:"lastAccessed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorPayload struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncError struct {
|
||||||
|
StatusCode int
|
||||||
|
Payload ErrorPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e SyncError) Error() string {
|
||||||
|
return fmt.Sprintf("code: %s, message: %s", e.Payload.Code, e.Payload.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncError(code string, message string, status int) SyncError {
|
||||||
|
return SyncError{
|
||||||
|
StatusCode: status,
|
||||||
|
Payload: ErrorPayload{
|
||||||
|
Code: code,
|
||||||
|
Message: message}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var NotImplementedError = NewSyncError(
|
||||||
|
"NotImplementedException",
|
||||||
|
"The requested route has not been implemented",
|
||||||
|
http.StatusNotFound)
|
||||||
|
|
||||||
|
var MethodNotImplementedError = NewSyncError(
|
||||||
|
"NotImplementedException",
|
||||||
|
"The requested method has not been implemented",
|
||||||
|
http.StatusMethodNotAllowed)
|
||||||
|
|
||||||
|
var SyncNotFoundError = NewSyncError(
|
||||||
|
"SyncNotFoundException",
|
||||||
|
"Sync does not exist",
|
||||||
|
http.StatusUnauthorized)
|
||||||
|
|
||||||
|
var SyncConflictError = NewSyncError(
|
||||||
|
"SyncConflictException",
|
||||||
|
"A sync conflict was detected",
|
||||||
|
http.StatusConflict)
|
|
@ -0,0 +1,75 @@
|
||||||
|
package syncstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FSStore struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFSStore(path string) (*FSStore, error) {
|
||||||
|
p, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = os.MkdirAll(p, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s = new(FSStore)
|
||||||
|
s.path = p
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *FSStore) storePath(id string) string {
|
||||||
|
return filepath.Join(drv.path, id[0:3], fmt.Sprintf("%s.json", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *FSStore) RawSave(s *Blob) error {
|
||||||
|
body, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filename := drv.storePath(s.ID)
|
||||||
|
dirname := filepath.Dir(filename)
|
||||||
|
err = os.Mkdir(dirname, 0700)
|
||||||
|
if err != nil && !os.IsExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(dirname, "tmp-"+s.ID+".*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(body)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(f.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(f.Name(), filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *FSStore) RawLoad(id string) (*Blob, error) {
|
||||||
|
body, err := ioutil.ReadFile(drv.storePath(id))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, SyncNotFoundError
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := new(Blob)
|
||||||
|
err = json.Unmarshal(body, s)
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *FSStore) Exists(id string) bool {
|
||||||
|
st, err := os.Stat(drv.storePath(id))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (!st.IsDir()) && (st.Size() > 0)
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package syncstore
|
||||||
|
|
||||||
|
type MemStore map[string]Blob
|
||||||
|
|
||||||
|
func NewMemStore() (*MemStore, error) {
|
||||||
|
r := make(MemStore)
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *MemStore) RawSave(s *Blob) error {
|
||||||
|
(*drv)[s.ID] = *s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *MemStore) RawLoad(id string) (*Blob, error) {
|
||||||
|
r, e := (*drv)[id]
|
||||||
|
if !e {
|
||||||
|
return nil, SyncNotFoundError
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (drv *MemStore) Exists(id string) bool {
|
||||||
|
_, r := (*drv)[id]
|
||||||
|
return r
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
package syncstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StoreDriver interface {
|
||||||
|
RawSave(s *Blob) error
|
||||||
|
RawLoad(id string) (*Blob, error)
|
||||||
|
Exists(id string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
drv StoreDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(drv StoreDriver) Store {
|
||||||
|
return Store{drv}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetResp struct {
|
||||||
|
Bookmarks string `json:"bookmarks"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Create(version string) (*CreateResp, error) {
|
||||||
|
var sid string
|
||||||
|
failed := true
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
var idb = make([]byte, 16)
|
||||||
|
var ids = make([]byte, 32)
|
||||||
|
_, err := rand.Read(idb)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hex.Encode(ids, idb)
|
||||||
|
sid = string(ids[:])
|
||||||
|
if !store.drv.Exists(sid) {
|
||||||
|
failed = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
return nil, fmt.Errorf("cannot generate unique sync ID")
|
||||||
|
}
|
||||||
|
s := new(Blob)
|
||||||
|
s.ID = sid
|
||||||
|
s.Version = version
|
||||||
|
s.Created = time.Now().UTC()
|
||||||
|
s.LastUpdated = s.Created
|
||||||
|
s.LastAccessed = s.Created
|
||||||
|
return &CreateResp{
|
||||||
|
ID: s.ID,
|
||||||
|
LastUpdated: s.LastUpdated,
|
||||||
|
Version: s.Version}, store.drv.RawSave(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Update(id string, bookmarks string, lastUpdated time.Time) (time.Time, error) {
|
||||||
|
t, err := store.drv.RawLoad(id)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
if !lastUpdated.IsZero() {
|
||||||
|
if !t.LastUpdated.Equal(lastUpdated) {
|
||||||
|
return time.Time{}, SyncConflictError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.ID = id
|
||||||
|
t.Bookmarks = bookmarks
|
||||||
|
t.LastUpdated = time.Now().UTC()
|
||||||
|
t.LastAccessed = t.LastUpdated
|
||||||
|
return t.LastUpdated, store.drv.RawSave(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Get(id string) (*GetResp, error) {
|
||||||
|
s, err := store.drv.RawLoad(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ID = id
|
||||||
|
s.LastAccessed = time.Now().UTC()
|
||||||
|
return &GetResp{
|
||||||
|
Version: s.Version,
|
||||||
|
Bookmarks: s.Bookmarks,
|
||||||
|
LastUpdated: s.LastUpdated}, store.drv.RawSave(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetLastUpdated(id string) (time.Time, error) {
|
||||||
|
r, err := store.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return r.LastUpdated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetVersion(id string) (string, error) {
|
||||||
|
r, err := store.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.Version, nil
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
package syncstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStore(t *testing.T) {
|
||||||
|
drv, _ := NewMemStore()
|
||||||
|
store := NewStore(drv)
|
||||||
|
|
||||||
|
v1 := "0.1.2"
|
||||||
|
time10 := time.Now().UTC()
|
||||||
|
t1, e1 := store.Create(v1)
|
||||||
|
time11 := time.Now().UTC()
|
||||||
|
if e1 != nil {
|
||||||
|
t.Errorf("Create() failed (%v)", e1)
|
||||||
|
}
|
||||||
|
if t1 == nil {
|
||||||
|
t.Error("Create() returned nil result")
|
||||||
|
}
|
||||||
|
idOk, err := regexp.MatchString("^[[:xdigit:]]{32}$", t1.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Matching ID failed (%v)", err)
|
||||||
|
}
|
||||||
|
if idOk != true {
|
||||||
|
t.Errorf("Create() returned an invalid ID: %s", t1.ID)
|
||||||
|
}
|
||||||
|
if t1.Version != v1 {
|
||||||
|
t.Errorf(
|
||||||
|
"Create() returned a mismatched version: \"%s\" (expected \"%s\")",
|
||||||
|
t1.Version, v1)
|
||||||
|
}
|
||||||
|
if t1.LastUpdated.Before(time10) || t1.LastUpdated.After(time11) {
|
||||||
|
t.Errorf(
|
||||||
|
"Create() returned an incorrect lastUpdated timestamp: %v (should be between %v and %v)",
|
||||||
|
t1.LastUpdated, time10, time11)
|
||||||
|
}
|
||||||
|
t2, e2 := store.Get(t1.ID)
|
||||||
|
if e2 != nil {
|
||||||
|
t.Errorf("Get() failed (%v)", e2)
|
||||||
|
}
|
||||||
|
if t2.Bookmarks != "" {
|
||||||
|
t.Errorf("Expected empty bookmarks after Create(), got \"%s\" instead", t2.Bookmarks)
|
||||||
|
}
|
||||||
|
if t2.LastUpdated != t1.LastUpdated {
|
||||||
|
t.Errorf("lastUpdate changed from %v to %v", t1.LastUpdated, t2.LastUpdated)
|
||||||
|
}
|
||||||
|
if t2.Version != t1.Version {
|
||||||
|
t.Errorf("version changed from \"%s\" to \"%s\"", t1.Version, t2.Version)
|
||||||
|
}
|
||||||
|
testBookmark := "This is a test"
|
||||||
|
time30 := time.Now().UTC()
|
||||||
|
t3, e3 := store.Update(t1.ID, testBookmark, time.Time{})
|
||||||
|
time31 := time.Now().UTC()
|
||||||
|
if e3 != nil {
|
||||||
|
t.Errorf("Update() with zero lastUpdated failed (%v)", e3)
|
||||||
|
}
|
||||||
|
if t3.Before(time30) || t3.After(time31) {
|
||||||
|
t.Errorf(
|
||||||
|
"Update() returned an incorrect lastUpdated timestamp: %v (should be between %v and %v)",
|
||||||
|
t3, time30, time31)
|
||||||
|
}
|
||||||
|
t4, e4 := store.Get(t1.ID)
|
||||||
|
if e4 != nil {
|
||||||
|
t.Errorf("Get() failed with error (%v)", e4)
|
||||||
|
}
|
||||||
|
if t4.Bookmarks != testBookmark {
|
||||||
|
t.Errorf("Get() did not return updated bookmarks (expected %v, got %v)",
|
||||||
|
testBookmark, t4.Bookmarks)
|
||||||
|
}
|
||||||
|
testBookmark2 := "This is another test"
|
||||||
|
_, e5 := store.Update(t1.ID, testBookmark2, t3.Add(time.Duration(-10000000)))
|
||||||
|
if e5 != SyncConflictError {
|
||||||
|
t.Errorf("Expected SyncConflictError on Update() with incorrect lastUpdated, got %v instead", e5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchCreate_helper(b *testing.B, drv StoreDriver) {
|
||||||
|
store := NewStore(drv)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
store.Create("0.1.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkStore_Create_Mem(b *testing.B) {
|
||||||
|
drv, _ := NewMemStore()
|
||||||
|
benchCreate_helper(b, drv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkStore_Create_FS(b *testing.B) {
|
||||||
|
drv, _ := NewFSStore(b.TempDir())
|
||||||
|
benchCreate_helper(b, drv)
|
||||||
|
}
|
Loading…
Reference in New Issue