Initial commit

This commit is contained in:
jdl
2023-10-13 11:43:27 +02:00
commit 71eb6b0c7e
121 changed files with 11493 additions and 0 deletions

136
fstore/browser.go Normal file
View File

@@ -0,0 +1,136 @@
package fstore
import (
"embed"
"io"
"git.crumpington.com/public/jldb/fstore/pages"
"net/http"
"os"
"path/filepath"
)
//go:embed static/*
var staticFS embed.FS
type browser struct {
store *Store
}
func (s *Store) ServeBrowser(listenAddr string) error {
b := &browser{s}
http.HandleFunc("/", b.handle)
http.Handle("/static/", http.FileServer(http.FS(staticFS)))
return http.ListenAndServe(listenAddr, nil)
}
func (b *browser) handle(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
b.handleGet(w, r)
case http.MethodPost:
b.handlePOST(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (b *browser) handleGet(w http.ResponseWriter, r *http.Request) {
path := cleanPath(r.URL.Path)
if err := validatePath(path); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
info, err := b.store.Stat(path)
if err != nil {
if os.IsNotExist(err) {
pages.Page{Path: path}.Render(w)
return
}
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if !info.IsDir() {
b.store.Serve(w, r, path)
return
}
dirs, files, err := b.store.List(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pages.Page{
Path: path,
Dirs: dirs,
Files: files,
}.Render(w)
}
// Handle actions:
// - upload (multipart),
// - delete
func (b *browser) handlePOST(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1024 * 1024); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Form.Get("Action") {
case "Upload":
b.handlePOSTUpload(w, r)
case "Delete":
b.handlePOSTDelete(w, r)
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}
func (b *browser) handlePOSTUpload(w http.ResponseWriter, r *http.Request) {
file, handler, err := r.FormFile("File")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
relativePath := handler.Filename
if p := r.Form.Get("Path"); p != "" {
relativePath = p
}
fullPath := filepath.Join(r.URL.Path, relativePath)
tmpPath := b.store.GetTempFilePath()
defer os.RemoveAll(tmpPath)
tmpFile, err := os.Create(tmpPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer tmpFile.Close()
if _, err := io.Copy(tmpFile, file); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := b.store.Store(tmpPath, fullPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, filepath.Dir(fullPath), http.StatusSeeOther)
}
func (b *browser) handlePOSTDelete(w http.ResponseWriter, r *http.Request) {
path := r.Form.Get("Path")
if err := b.store.Remove(path); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, filepath.Dir(path), http.StatusSeeOther)
}

64
fstore/command.go Normal file
View File

@@ -0,0 +1,64 @@
package fstore
import (
"bytes"
"encoding/binary"
"io"
"git.crumpington.com/public/jldb/lib/errs"
)
type command struct {
Path string
Store bool
TempID uint64
FileSize int64 // In bytes.
File io.Reader
}
func (c command) Reader(buf *bytes.Buffer) (int64, io.Reader) {
buf.Reset()
vars := []any{
uint32(len(c.Path)),
c.Store,
c.TempID,
c.FileSize,
}
for _, v := range vars {
binary.Write(buf, binary.LittleEndian, v)
}
buf.Write([]byte(c.Path))
if c.Store {
return int64(buf.Len()) + c.FileSize, io.MultiReader(buf, c.File)
} else {
return int64(buf.Len()), buf
}
}
func (c *command) ReadFrom(r io.Reader) error {
pathLen := uint32(0)
ptrs := []any{
&pathLen,
&c.Store,
&c.TempID,
&c.FileSize,
}
for _, ptr := range ptrs {
if err := binary.Read(r, binary.LittleEndian, ptr); err != nil {
return errs.IO.WithErr(err)
}
}
pathBuf := make([]byte, pathLen)
if _, err := r.Read(pathBuf); err != nil {
return errs.IO.WithErr(err)
}
c.Path = string(pathBuf)
c.File = r
return nil
}

95
fstore/pages/page.go Normal file
View File

@@ -0,0 +1,95 @@
package pages
import (
"html/template"
"io"
"path/filepath"
)
type Page struct {
Path string
Dirs []string
Files []string
// Created in Render.
BreadCrumbs []Directory
}
func (ctx Page) FullPath(dir string) string {
return filepath.Join(ctx.Path, dir)
}
func (ctx Page) Render(w io.Writer) {
crumbs := []Directory{}
current := Directory(ctx.Path)
for current != "/" {
crumbs = append([]Directory{current}, crumbs...)
current = Directory(filepath.Dir(string(current)))
}
ctx.BreadCrumbs = crumbs
authRegisterTmpl.Execute(w, ctx)
}
var authRegisterTmpl = template.Must(template.New("").Parse(`
<html>
<head>
<link rel="stylesheet" href="/static/css/pure-min.css">
<style>
body {
padding: 2em;
}
input[type=file] {
padding: 0.4em .6em;
display: inline-block;
border: 1px solid #ccc;
box-shadow: inset 0 1px 3px #ddd;
border-radius: 4px;
vertical-align: middle;
box-sizing: border-box;
width: 350px;
max-width: 100%;
}
</style>
</head>
<body>
<h1>
<a href="/">root</a> / {{range .BreadCrumbs}}<a href="{{.}}">{{.Name}}</a> / {{end -}}
</h1>
<form class="pure-form" method="post" enctype="multipart/form-data">
<fieldset>
<legend>Upload a file</legend>
<input type="hidden" name="Action" value="Upload">
<input id="File" name="File" type="file">
<input type="text" id="Path" name="Path" placeholder="Relative path (optional)" />
<button type="submit" class="pure-button pure-button-primary">Upload</button>
</fieldset>
</form>
<a href="../">../</a><br>
{{range .Dirs}}
<a href="{{$.FullPath .}}">{{.}}/</a><br>
{{end}}
{{range .Files}}
<form style="display:inline-block; margin:0;" method="post" enctype="multipart/form-data">
<input type="hidden" name="Action" value="Delete">
<input type="hidden" id="Path" name="Path" value="{{$.FullPath .}}">
[<a href="#" onclick="this.closest('form').submit();return false;">X</a>]
</form>
<a href="{{$.FullPath .}}">{{.}}</a>
<br>
{{end}}
</body>
</html>
`))
type Directory string
func (d Directory) Name() string {
return filepath.Base(string(d))
}

63
fstore/paths.go Normal file
View File

@@ -0,0 +1,63 @@
package fstore
import (
"git.crumpington.com/public/jldb/lib/errs"
"path/filepath"
"strconv"
)
func filesRootPath(rootDir string) string {
return filepath.Clean(filepath.Join(rootDir, "files"))
}
func repDirPath(rootDir string) string {
return filepath.Clean(filepath.Join(rootDir, "rep"))
}
func tempDirPath(rootDir string) string {
return filepath.Clean(filepath.Join(rootDir, "tmp"))
}
func tempFilePath(inDir string, id uint64) string {
return filepath.Join(inDir, strconv.FormatUint(id, 10))
}
func validatePath(p string) error {
if len(p) == 0 {
return errs.InvalidPath.WithMsg("empty path")
}
if p[0] != '/' {
return errs.InvalidPath.WithMsg("path must be absolute")
}
for _, c := range p {
switch c {
case '/', '-', '_', '.':
continue
default:
}
if c >= 'a' && c <= 'z' {
continue
}
if c >= '0' && c <= '9' {
continue
}
return errs.InvalidPath.WithMsg("invalid character in path: %s", string([]rune{c}))
}
return nil
}
func cleanPath(p string) string {
if len(p) == 0 {
return "/"
}
if p[0] != '/' {
p = "/" + p
}
return filepath.Clean(p)
}

55
fstore/paths_test.go Normal file
View File

@@ -0,0 +1,55 @@
package fstore
import "testing"
func TestValidatePath_valid(t *testing.T) {
cases := []string{
"/",
"/a/z/0.9/a-b_c.d/",
"/a/../b",
"/x/abcdefghijklmnopqrstuvwxyz/0123456789/_.-",
}
for _, s := range cases {
if err := validatePath(s); err != nil {
t.Fatal(s, err)
}
}
}
func TestValidatePath_invalid(t *testing.T) {
cases := []string{
"",
"/A",
"/a/b/~xyz/",
"/a\\b",
"a/b/c",
}
for _, s := range cases {
if err := validatePath(s); err == nil {
t.Fatal(s)
}
}
}
func TestCleanPath(t *testing.T) {
type Case struct {
In, Out string
}
cases := []Case{
{"", "/"},
{"../", "/"},
{"a/b", "/a/b"},
{"/a/b/../../../", "/"},
{"a/b/../../../", "/"},
}
for _, c := range cases {
out := cleanPath(c.In)
if out != c.Out {
t.Fatal(c.In, out, c.Out)
}
}
}

50
fstore/stateutil_test.go Normal file
View File

@@ -0,0 +1,50 @@
package fstore
import (
"path/filepath"
"strings"
)
type StoreState struct {
Path string
IsDir bool
Dirs map[string]StoreState
Files map[string]StoreState
FileData string
}
func NewStoreState(in map[string]string) StoreState {
root := StoreState{
Path: "/",
IsDir: true,
Dirs: map[string]StoreState{},
Files: map[string]StoreState{},
}
for path, fileData := range in {
slugs := strings.Split(path[1:], "/") // Remove leading slash.
parent := root
// Add directories.
for _, part := range slugs[:len(slugs)-1] {
if _, ok := parent.Dirs[part]; !ok {
parent.Dirs[part] = StoreState{
Path: filepath.Join(parent.Path, part),
IsDir: true,
Dirs: map[string]StoreState{},
Files: map[string]StoreState{},
}
}
parent = parent.Dirs[part]
}
parent.Files[slugs[len(slugs)-1]] = StoreState{
Path: path,
IsDir: false,
FileData: fileData,
}
}
return root
}

11
fstore/static/css/pure-min.css vendored Normal file

File diff suppressed because one or more lines are too long

82
fstore/store-commands.go Normal file
View File

@@ -0,0 +1,82 @@
package fstore
import (
"git.crumpington.com/public/jldb/lib/errs"
"git.crumpington.com/public/jldb/lib/idgen"
"log"
"os"
"path/filepath"
)
func (s *Store) applyStoreFromReader(cmd command) error {
tmpPath := tempFilePath(s.tmpDir, idgen.Next())
f, err := os.Create(tmpPath)
if err != nil {
return errs.IO.WithErr(err)
}
defer f.Close()
n, err := f.ReadFrom(cmd.File)
if err != nil {
return errs.IO.WithErr(err)
}
if n != cmd.FileSize {
return errs.IO.WithMsg("expected to %d bytes, but got %d", cmd.FileSize, n)
}
if err := f.Sync(); err != nil {
return errs.IO.WithErr(err)
}
fullPath := filepath.Join(s.filesRoot, cmd.Path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0700); err != nil {
return errs.IO.WithErr(err)
}
if err := os.Rename(tmpPath, fullPath); err != nil {
return errs.IO.WithErr(err)
}
return nil
}
func (s *Store) applyStoreFromTempID(cmd command) error {
tmpPath := tempFilePath(s.tmpDir, cmd.TempID)
fullPath := filepath.Join(s.filesRoot, cmd.Path)
info, err := os.Stat(tmpPath)
if err != nil || info.Size() != cmd.FileSize {
log.Printf("[STORE] Primary falling back on reader copy: %v", err)
return s.applyStoreFromReader(cmd)
}
if err := os.MkdirAll(filepath.Dir(fullPath), 0700); err != nil {
return errs.IO.WithErr(err)
}
if err := os.Rename(tmpPath, fullPath); err != nil {
return errs.IO.WithErr(err)
}
return nil
}
func (s *Store) applyRemove(cmd command) error {
finalPath := filepath.Join(s.filesRoot, cmd.Path)
if err := os.Remove(finalPath); err != nil {
if !os.IsNotExist(err) {
return errs.IO.WithErr(err)
}
}
parent := filepath.Dir(finalPath)
for parent != s.filesRoot {
if err := os.Remove(parent); err != nil {
return nil
}
parent = filepath.Dir(parent)
}
return nil
}

View File

@@ -0,0 +1,136 @@
package fstore
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
func TestStoreHarness(t *testing.T) {
StoreTestHarness{}.Run(t)
}
type StoreTestHarness struct{}
func (h StoreTestHarness) Run(t *testing.T) {
val := reflect.ValueOf(h)
typ := val.Type()
for i := 0; i < typ.NumMethod(); i++ {
method := typ.Method(i)
if !strings.HasPrefix(method.Name, "Test") {
continue
}
t.Run(method.Name, func(t *testing.T) {
t.Parallel()
rootDir := t.TempDir()
primary, err := Open(Config{
RootDir: rootDir,
Primary: true,
WALSegMinCount: 1,
WALSegMaxAgeSec: 1,
WALSegGCAgeSec: 2,
})
if err != nil {
t.Fatal(err)
}
defer primary.Close()
mux := http.NewServeMux()
mux.HandleFunc("/rep/", primary.Handle)
testServer := httptest.NewServer(mux)
defer testServer.Close()
rootDir2 := t.TempDir()
secondary, err := Open(Config{
RootDir: rootDir2,
Primary: false,
PrimaryEndpoint: testServer.URL + "/rep/",
})
if err != nil {
t.Fatal(err)
}
defer secondary.Close()
val.MethodByName(method.Name).Call([]reflect.Value{
reflect.ValueOf(t),
reflect.ValueOf(primary),
reflect.ValueOf(secondary),
})
})
}
}
func (StoreTestHarness) TestBasic(t *testing.T, primary, secondary *Store) {
stateChan := make(chan map[string]string, 1)
go func() {
stateChan <- primary.WriteRandomFor(4 * time.Second)
}()
state := <-stateChan
secondary.WaitForParity(primary)
primary.AssertState(t, state)
secondary.AssertState(t, state)
}
func (StoreTestHarness) TestWriteThenFollow(t *testing.T, primary, secondary *Store) {
secondary.Close()
stateChan := make(chan map[string]string, 1)
go func() {
stateChan <- primary.WriteRandomFor(4 * time.Second)
}()
state := <-stateChan
var err error
secondary, err = Open(secondary.conf)
if err != nil {
t.Fatal(err)
}
secondary.WaitForParity(primary)
primary.AssertState(t, state)
secondary.AssertState(t, state)
}
func (StoreTestHarness) TestCloseAndOpenFollowerConcurrently(t *testing.T, primary, secondary *Store) {
secondary.Close()
stateChan := make(chan map[string]string, 1)
go func() {
stateChan <- primary.WriteRandomFor(8 * time.Second)
}()
var err error
for i := 0; i < 4; i++ {
time.Sleep(time.Second)
secondary, err = Open(secondary.conf)
if err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
secondary.Close()
}
secondary, err = Open(secondary.conf)
if err != nil {
t.Fatal(err)
}
state := <-stateChan
secondary.WaitForParity(primary)
primary.AssertState(t, state)
secondary.AssertState(t, state)
}

171
fstore/store-rep.go Normal file
View File

@@ -0,0 +1,171 @@
package fstore
import (
"encoding/binary"
"errors"
"io"
"io/fs"
"git.crumpington.com/public/jldb/lib/errs"
"git.crumpington.com/public/jldb/lib/wal"
"net"
"os"
"path/filepath"
"time"
)
func (s *Store) repSendState(conn net.Conn) error {
err := filepath.Walk(s.filesRoot, func(path string, info fs.FileInfo, err error) error {
if err != nil {
// Skip deleted files.
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
relPath, err := filepath.Rel(s.filesRoot, path)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(s.conf.NetTimeout))
if err := binary.Write(conn, binary.LittleEndian, int32(len(relPath))); err != nil {
return err
}
if _, err := conn.Write([]byte(relPath)); err != nil {
return err
}
if err := binary.Write(conn, binary.LittleEndian, int64(info.Size())); err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(s.conf.NetTimeout))
if _, err := io.CopyN(conn, f, info.Size()); err != nil {
return err
}
return nil
})
if err != nil {
return errs.IO.WithErr(err)
}
conn.SetWriteDeadline(time.Now().Add(s.conf.NetTimeout))
if err := binary.Write(conn, binary.LittleEndian, int32(0)); err != nil {
return errs.IO.WithErr(err)
}
return nil
}
func (s *Store) repRecvState(conn net.Conn) error {
var (
errorDone = errors.New("Done")
pathLen = int32(0)
fileSize = int64(0)
pathBuf = make([]byte, 1024)
)
for {
err := func() error {
conn.SetReadDeadline(time.Now().Add(s.conf.NetTimeout))
if err := binary.Read(conn, binary.LittleEndian, &pathLen); err != nil {
return err
}
if pathLen == 0 {
return errorDone
}
if cap(pathBuf) < int(pathLen) {
pathBuf = make([]byte, pathLen)
}
pathBuf = pathBuf[:pathLen]
if _, err := io.ReadFull(conn, pathBuf); err != nil {
return err
}
fullPath := filepath.Join(s.filesRoot, string(pathBuf))
if err := os.MkdirAll(filepath.Dir(fullPath), 0700); err != nil {
return err
}
if err := binary.Read(conn, binary.LittleEndian, &fileSize); err != nil {
return err
}
f, err := os.Create(fullPath)
if err != nil {
return err
}
defer f.Close()
conn.SetReadDeadline(time.Now().Add(s.conf.NetTimeout))
if _, err = io.CopyN(f, conn, fileSize); err != nil {
return err
}
return f.Sync()
}()
if err != nil {
if err == errorDone {
return nil
}
return errs.IO.WithErr(err)
}
}
}
func (s *Store) repInitStorage() (err error) {
if err := os.MkdirAll(s.filesRoot, 0700); err != nil {
return errs.IO.WithErr(err)
}
if err := os.MkdirAll(s.tmpDir, 0700); err != nil {
return errs.IO.WithErr(err)
}
return nil
}
func (s *Store) repReplay(rec wal.Record) (err error) {
cmd := command{}
if err := cmd.ReadFrom(rec.Reader); err != nil {
return err
}
if cmd.Store {
return s.applyStoreFromReader(cmd)
}
return s.applyRemove(cmd)
}
func (s *Store) repLoadFromStorage() (err error) {
// Nothing to do.
return nil
}
func (s *Store) repApply(rec wal.Record) (err error) {
cmd := command{}
if err := cmd.ReadFrom(rec.Reader); err != nil {
return err
}
if cmd.Store {
if s.conf.Primary {
return s.applyStoreFromTempID(cmd)
}
return s.applyStoreFromReader(cmd)
}
return s.applyRemove(cmd)
}

View File

@@ -0,0 +1,119 @@
package fstore
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type StoreTestCase struct {
Name string
Update func(t *testing.T, s *Store) error
ExpectedError error
State map[string]string
}
func TestRunnerTestCases(t *testing.T) {
t.Helper()
rootDir := t.TempDir()
store, err := Open(Config{
RootDir: rootDir,
Primary: true,
})
if err != nil {
t.Fatal(err)
}
defer store.Close()
mux := http.NewServeMux()
mux.HandleFunc("/rep/", store.Handle)
testServer := httptest.NewServer(mux)
defer testServer.Close()
rootDir2 := t.TempDir()
secondary, err := Open(Config{
RootDir: rootDir2,
Primary: false,
PrimaryEndpoint: testServer.URL + "/rep/",
})
if err != nil {
t.Fatal(err)
}
defer secondary.Close()
for _, testCase := range storeTestCases {
testCase := testCase
t.Run(testCase.Name, func(t *testing.T) {
testRunnerRunTestCase(t, store, secondary, testCase)
})
}
}
func testRunnerRunTestCase(t *testing.T, store, secondary *Store, testCase StoreTestCase) {
err := testCase.Update(t, store)
if !errors.Is(err, testCase.ExpectedError) {
t.Fatal(testCase.Name, err, testCase.ExpectedError)
}
store.AssertState(t, testCase.State)
pInfo := store.rep.Info()
for {
sInfo := secondary.rep.Info()
if sInfo.AppSeqNum == pInfo.AppSeqNum {
break
}
time.Sleep(time.Millisecond)
}
secondary.AssertState(t, testCase.State)
}
var storeTestCases = []StoreTestCase{
{
Name: "store a file",
Update: func(t *testing.T, s *Store) error {
return s.StoreString("hello world", "/a/b/c")
},
ExpectedError: nil,
State: map[string]string{
"/a/b/c": "hello world",
},
}, {
Name: "store more files",
Update: func(t *testing.T, s *Store) error {
if err := s.StoreString("good bye", "/a/b/x"); err != nil {
return err
}
return s.StoreString("xxx", "/x")
},
ExpectedError: nil,
State: map[string]string{
"/a/b/c": "hello world",
"/a/b/x": "good bye",
"/x": "xxx",
},
}, {
Name: "remove a file",
Update: func(t *testing.T, s *Store) error {
return s.Remove("/x")
},
ExpectedError: nil,
State: map[string]string{
"/a/b/c": "hello world",
"/a/b/x": "good bye",
},
}, {
Name: "remove another file",
Update: func(t *testing.T, s *Store) error {
return s.Remove("/a/b/c")
},
ExpectedError: nil,
State: map[string]string{
"/a/b/x": "good bye",
},
},
}

279
fstore/store.go Normal file
View File

@@ -0,0 +1,279 @@
package fstore
import (
"bytes"
"io"
"git.crumpington.com/public/jldb/lib/errs"
"git.crumpington.com/public/jldb/lib/idgen"
"git.crumpington.com/public/jldb/lib/rep"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
type Config struct {
RootDir string
Primary bool
ReplicationPSK string
NetTimeout time.Duration // Default is 1 minute.
// WAL settings.
WALSegMinCount int64 // Minimum Change sets in a segment. Default is 1024.
WALSegMaxAgeSec int64 // Maximum age of a segment. Default is 1 hour.
WALSegGCAgeSec int64 // Segment age for garbage collection. Default is 7 days.
// For use by secondary.
PrimaryEndpoint string
}
func (c Config) repConfig() rep.Config {
return rep.Config{
RootDir: repDirPath(c.RootDir),
Primary: c.Primary,
ReplicationPSK: c.ReplicationPSK,
NetTimeout: c.NetTimeout,
WALSegMinCount: c.WALSegMinCount,
WALSegMaxAgeSec: c.WALSegMaxAgeSec,
WALSegGCAgeSec: c.WALSegGCAgeSec,
PrimaryEndpoint: c.PrimaryEndpoint,
SynchronousAppend: true,
}
}
type Store struct {
lock sync.Mutex
buf *bytes.Buffer
rep *rep.Replicator
conf Config
filesRoot string // Absolute, no trailing slash.
tmpDir string // Absolute, no trailing slash.
}
func Open(conf Config) (*Store, error) {
if conf.NetTimeout <= 0 {
conf.NetTimeout = time.Minute
}
s := &Store{
buf: &bytes.Buffer{},
conf: conf,
filesRoot: filesRootPath(conf.RootDir),
tmpDir: tempDirPath(conf.RootDir),
}
var err error
repConf := s.conf.repConfig()
s.rep, err = rep.Open(
rep.App{
SendState: s.repSendState,
RecvState: s.repRecvState,
InitStorage: s.repInitStorage,
Replay: s.repReplay,
LoadFromStorage: s.repLoadFromStorage,
Apply: s.repApply,
},
repConf)
if err != nil {
return nil, err
}
return s, nil
}
func (s *Store) GetTempFilePath() string {
return tempFilePath(s.tmpDir, idgen.Next())
}
func (s *Store) StoreString(str string, finalPath string) error {
return s.StoreBytes([]byte(str), finalPath)
}
func (s *Store) StoreBytes(b []byte, finalPath string) error {
tmpPath := s.GetTempFilePath()
if err := os.WriteFile(tmpPath, b, 0600); err != nil {
return err
}
defer os.RemoveAll(tmpPath)
return s.Store(tmpPath, finalPath)
}
func (s *Store) Store(tmpPath, finalPath string) error {
if !s.conf.Primary {
return errs.NotAuthorized.WithMsg("not primary")
}
userPath, _, err := s.cleanAndValidatePath(finalPath)
if err != nil {
return err
}
idStr := filepath.Base(tmpPath)
tmpID, _ := strconv.ParseUint(idStr, 10, 64)
s.lock.Lock()
defer s.lock.Unlock()
f, err := os.Open(tmpPath)
if err != nil {
return err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
cmd := command{
Path: userPath,
Store: true,
TempID: tmpID,
FileSize: fi.Size(),
File: f,
}
size, reader := cmd.Reader(s.buf)
_, _, err = s.rep.Append(size, reader)
return err
}
func (s *Store) Remove(filePath string) error {
if !s.conf.Primary {
return errs.NotAuthorized.WithMsg("not primary")
}
userPath, _, err := s.cleanAndValidatePath(filePath)
if err != nil {
return err
}
s.lock.Lock()
defer s.lock.Unlock()
cmd := command{
Path: userPath,
Store: false,
TempID: 0,
FileSize: 0,
}
size, reader := cmd.Reader(s.buf)
_, _, err = s.rep.Append(size, reader)
return err
}
func (s *Store) List(p string) (dirs, files []string, err error) {
_, fullPath, err := s.cleanAndValidatePath(p)
if err != nil {
return nil, nil, err
}
fi, err := os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, nil
}
return nil, nil, err
}
if !fi.IsDir() {
return nil, []string{fi.Name()}, nil
}
entries, err := os.ReadDir(fullPath)
if err != nil {
return nil, nil, err
}
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, e.Name())
} else {
files = append(files, e.Name())
}
}
return dirs, files, nil
}
func (s *Store) Stat(p string) (os.FileInfo, error) {
_, fullPath, err := s.cleanAndValidatePath(p)
if err != nil {
return nil, err
}
fi, err := os.Stat(fullPath)
if err != nil {
return nil, err
}
return fi, nil
}
func (s *Store) WriteTo(w io.Writer, filePath string) error {
_, fullPath, err := s.cleanAndValidatePath(filePath)
if err != nil {
return err
}
f, err := os.Open(fullPath)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(w, f); err != nil {
return err
}
return nil
}
func (s *Store) Serve(w http.ResponseWriter, r *http.Request, p string) {
_, fullPath, err := s.cleanAndValidatePath(p)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, fullPath)
}
func (s *Store) ServeFallback(w http.ResponseWriter, r *http.Request, paths ...string) {
for _, p := range paths {
_, fullPath, err := s.cleanAndValidatePath(p)
if err == nil {
fi, err := os.Stat(fullPath)
if err == nil && !fi.IsDir() {
http.ServeFile(w, r, fullPath)
return
}
}
}
http.Error(w, "not found", http.StatusNotFound)
}
func (s *Store) Handle(w http.ResponseWriter, r *http.Request) {
s.rep.Handle(w, r)
}
func (s *Store) Close() error {
return s.rep.Close()
}
func (s *Store) cleanAndValidatePath(in string) (userPath, fullPath string, err error) {
userPath = cleanPath(in)
if err := validatePath(userPath); err != nil {
return "", "", err
}
return userPath, filepath.Join(s.filesRoot, userPath), nil
}

91
fstore/store_test.go Normal file
View File

@@ -0,0 +1,91 @@
package fstore
import (
"bytes"
"math/rand"
"path/filepath"
"strconv"
"testing"
"time"
)
func (s *Store) ReadString(t *testing.T, filePath string) string {
buf := &bytes.Buffer{}
if err := s.WriteTo(buf, filePath); err != nil {
t.Fatal(err)
}
return buf.String()
}
func (s *Store) AssertState(t *testing.T, in map[string]string) {
state := NewStoreState(in)
s.AssertStateDir(t, state)
}
func (s *Store) AssertStateDir(t *testing.T, dir StoreState) {
dirs, files, err := s.List(dir.Path)
if err != nil {
t.Fatal(err)
}
// check file lengths.
if len(files) != len(dir.Files) {
t.Fatal(files, dir.Files)
}
// check dir lengths.
if len(dirs) != len(dir.Dirs) {
t.Fatal(dirs, dir.Dirs)
}
for _, file := range dir.Files {
expectedContent := file.FileData
actualContent := s.ReadString(t, file.Path)
if expectedContent != actualContent {
t.Fatal(expectedContent, actualContent)
}
}
for _, dir := range dir.Dirs {
s.AssertStateDir(t, dir)
}
}
func (s *Store) WriteRandomFor(dt time.Duration) map[string]string {
state := map[string]string{}
tStart := time.Now()
for time.Since(tStart) < dt {
slug1 := strconv.FormatInt(rand.Int63n(10), 10)
slug2 := strconv.FormatInt(rand.Int63n(10), 10)
path := filepath.Join("/", slug1, slug2)
if rand.Float32() < 0.05 {
if err := s.Remove(path); err != nil {
panic(err)
}
delete(state, path)
} else {
data := randString()
state[path] = data
if err := s.StoreString(data, path); err != nil {
panic(err)
}
}
time.Sleep(time.Millisecond)
}
return state
}
func (s *Store) WaitForParity(rhs *Store) {
for {
i1 := s.rep.Info()
i2 := rhs.rep.Info()
if i1.AppSeqNum == i2.AppSeqNum {
return
}
time.Sleep(time.Millisecond)
}
}

View File

@@ -0,0 +1,6 @@
<html>
<head></head>
<body>
<pre>{{.}}</pre>
</body>
</html>

16
fstore/test_util.go Normal file
View File

@@ -0,0 +1,16 @@
package fstore
import (
crand "crypto/rand"
"encoding/base32"
"math/rand"
)
func randString() string {
size := 8 + rand.Intn(92)
buf := make([]byte, size)
if _, err := crand.Read(buf); err != nil {
panic(err)
}
return base32.StdEncoding.EncodeToString(buf)
}