commit d1ca6b1c4939efc03fc4da6b21c4bbd9737761ef Author: jolheiser Date: Sat Jan 1 00:04:50 2022 -0600 Initial Commit Signed-off-by: jolheiser diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..669554e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +.cabinet/ +/cabinet* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..433f7db --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 John Olheiser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..acdb4a3 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# cabinet + +Store your files and links in your cabinet. + +Inspired by [chanbakjsd/filehost](https://github.com/chanbakjsd/filehost). + +## How to upload files? + +HTTP POST files: + +```shell +curl -F'file=@yourfile.png' https://example.com/f +``` + +If the instance is token protected: + +```shell +curl -F'file=@yourfile.png' -F'token=yourtoken' https://example.com/f +``` + +You can access uploaded files at the returned URL. + +## How to shorten URLs? + +HTTP POST your URL: + +```shell +curl -d'url=https://example.com' https://example.com/r +``` + +If the instance is token protected: + +```shell +curl -d'url=https://example.com' -d'token=yourtoken' https://example.com/r +``` + +You can access redirects at the returned URL. + +## How long are files and shortened URLs kept? + +Until the configured limit of the server is reached! Older and larger files get killed when that's reached. + +URLs are treated as files with the URL as its content. + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/command.go b/command.go new file mode 100644 index 0000000..7c4be5d --- /dev/null +++ b/command.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "github.com/AlecAivazis/survey/v2" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "go.jolheiser.com/cabinet/gc" + "go.jolheiser.com/cabinet/router" + "go.jolheiser.com/cabinet/workspace" +) + +type serveOpts struct { + jsonMode bool + debugMode bool + maxFileSize int + maxDiskSize int + workspacePath string + gcInterval time.Duration + requestPerMinute int + sizePerMinute int + burstSize int + memPerRequest int + port int + domain string +} + +var serveCmd = func(opts *serveOpts) func(context.Context, []string) error { + return func(_ context.Context, _ []string) error { + if opts.jsonMode { + log.Logger = zerolog.New(os.Stderr).With().Timestamp().Logger() + } + if opts.debugMode { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + + if opts.domain == "" { + opts.domain = fmt.Sprintf("http://localhost:%d", opts.port) + } + + ws, err := workspace.New(opts.workspacePath) + if err != nil { + log.Fatal().Err(err).Msg("could not open workspace") + } + + go gc.Start(ws, opts.maxDiskSize, opts.gcInterval) + + r := router.New(opts.domain, ws, router.NewLimit(opts.requestPerMinute, opts.sizePerMinute, opts.burstSize, opts.memPerRequest)) + + portStr := fmt.Sprintf(":%d", opts.port) + log.Info().Msgf("Listening at http://localhost%s", portStr) + if err := http.ListenAndServe(portStr, r); err != nil { + log.Err(err).Msg("could not start HTTP server") + } + return nil + } +} + +type tokenOpts struct { + token string + perm workspace.TokenPermission + desc string + workspacePath string + delete bool +} + +var tokenCmd = func(opts *tokenOpts) func(context.Context, []string) error { + return func(_ context.Context, _ []string) error { + ws, err := workspace.New(opts.workspacePath) + if err != nil { + log.Fatal().Err(err).Msg("could not open workspace") + } + + if opts.delete { + return deleteTokens(ws, opts) + } + return addToken(ws, opts) + } +} + +func addToken(ws *workspace.Workspace, opts *tokenOpts) error { + token := opts.token + if token == "" { + r, err := randToken() + if err != nil { + return err + } + q := &survey.Input{ + Message: "Token", + Default: r, + Help: "Input token or use randomly generated", + } + if err := survey.AskOne(q, &token, survey.WithValidator(survey.Required)); err != nil { + return err + } + } + + perm := opts.perm + if perm == 0 { + q := &survey.Select{ + Message: "Permission", + Default: "all", + Help: "Token permission", + Options: []string{ + "all", + "redirect", + "file", + }, + } + var p string + err := survey.AskOne(q, &p, survey.WithValidator(survey.Required)) + if err != nil { + return err + } + perm, err = workspace.ParseTokenPermission(p) + if err != nil { + return err + } + } + + desc := opts.desc + if desc == "" { + q := &survey.Input{ + Message: "Description", + Default: "", + Help: "Description of the token usage/user", + } + if err := survey.AskOne(q, &desc, survey.WithValidator(survey.Required)); err != nil { + return err + } + } + + if err := ws.AddToken(workspace.Token{ + Key: token, + Permission: perm, + Description: desc, + }); err != nil { + return err + } + + log.Info().Msg(token) + return nil +} + +func deleteTokens(ws *workspace.Workspace, opts *tokenOpts) error { + if opts.token != "" { + if err := ws.DeleteTokens(opts.token); err != nil { + return err + } + log.Info().Msgf("Successfully deleted %q", opts.token) + return nil + } + + tokens, err := ws.Tokens() + if err != nil { + return err + } + + if len(tokens) == 0 { + log.Info().Msg("This instance has no tokens") + return nil + } + + var tokenSlice []string + tokenMap := make(map[string]string) + for _, token := range tokens { + k := fmt.Sprintf("%s (%s) -- %s", token.Key, token.Permission, token.Description) + tokenSlice = append(tokenSlice, k) + tokenMap[k] = token.Key + } + + q := &survey.MultiSelect{ + Message: "Token(s) to delete", + Options: tokenSlice, + Help: "Select any tokens you wish to remove", + } + var selections []string + if err := survey.AskOne(q, &selections); err != nil { + return err + } + + var keys []string + for _, selection := range selections { + keys = append(keys, tokenMap[selection]) + } + + if err := ws.DeleteTokens(keys...); err != nil { + return err + } + + log.Info().Msgf("Successfully deleted %d token(s)", len(keys)) + return nil +} diff --git a/gc/gc.go b/gc/gc.go new file mode 100644 index 0000000..3f2c1c3 --- /dev/null +++ b/gc/gc.go @@ -0,0 +1,49 @@ +package gc + +import ( + "os" + "sort" + "time" + + "github.com/rs/zerolog/log" +) + +type GC interface { + List() ([]os.FileInfo, error) + Delete(id string) error +} + +func Start(g GC, maxSize int, interval time.Duration) { + ticker := time.NewTicker(interval) + for { + <-ticker.C + log.Debug().Msg("running GC") + infos, err := g.List() + if err != nil { + log.Err(err).Msg("could not run GC") + continue + } + + var size int + for _, info := range infos { + size += int(info.Size()) + } + + if size < maxSize { + continue + } + + sort.Sort(sorter(infos)) + for size > maxSize { + fi := infos[0] + log.Debug().Msgf("deleting file %s", fi.Name()) + if err := g.Delete(fi.Name()); err != nil { + log.Err(err).Msgf("could not delete file %s", fi.Name()) + break + } + size -= int(fi.Size()) + infos = infos[1:] + } + log.Debug().Msg("finished GC") + } +} diff --git a/gc/sort.go b/gc/sort.go new file mode 100644 index 0000000..70ccd12 --- /dev/null +++ b/gc/sort.go @@ -0,0 +1,24 @@ +package gc + +import ( + "os" + "time" +) + +type sorter []os.FileInfo + +func (f sorter) Len() int { + return len(f) +} + +func (f sorter) Less(i, j int) bool { + // Snagged this from chanbakjsd/filehost + const bytesPerSecond = 1024 + fi := int(time.Since(f[i].ModTime()).Seconds()) + int(f[i].Size()/bytesPerSecond) + fj := int(time.Since(f[j].ModTime()).Seconds()) + int(f[j].Size()/bytesPerSecond) + return fi > fj +} + +func (f sorter) Swap(i, j int) { + f[i], f[j] = f[j], f[i] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..15368dc --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module go.jolheiser.com/cabinet + +go 1.17 + +require ( + github.com/AlecAivazis/survey/v2 v2.3.2 + github.com/bwmarrin/snowflake v0.3.0 + github.com/go-chi/chi/v5 v5.0.7 + github.com/peterbourgon/ff/v3 v3.1.2 + github.com/rs/zerolog v1.26.1 + go.etcd.io/bbolt v1.3.6 + golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 +) + +require ( + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/pelletier/go-toml v1.6.0 // indirect + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect + golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect + golang.org/x/text v0.3.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..32010e2 --- /dev/null +++ b/go.sum @@ -0,0 +1,78 @@ +github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8= +github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/peterbourgon/ff/v3 v3.1.2 h1:0GNhbRhO9yHA4CC27ymskOsuRpmX0YQxwxM9UPiP6JM= +github.com/peterbourgon/ff/v3 v3.1.2/go.mod h1:XNJLY8EIl6MjMVjBS4F0+G0LYoAqs0DTa4rmHHukKDE= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= +github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/stretchr/testify v1.2.1 h1:52QO5WkIUcHGIR7EnGagH88x1bUzqGXTC5/1bDTUQ7U= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3e380c9 --- /dev/null +++ b/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "flag" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/peterbourgon/ff/v3" + "github.com/peterbourgon/ff/v3/ffcli" + "github.com/peterbourgon/ff/v3/fftoml" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "go.jolheiser.com/cabinet/workspace" +) + +func main() { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + serveOpts := serveOpts{ + maxFileSize: 1024 * 1024 * 10, // 10 MiB + maxDiskSize: 1024 * 1024 * 1024 * 5, // 5 GiB + sizePerMinute: 5 * 1024 * 1024, // 5 MiB/min + burstSize: 100 * 1024 * 1024, // 100 MiB + memPerRequest: 1024 * 1024, // 1 MiB + } + serveFS := flag.NewFlagSet("serve", flag.ExitOnError) + serveFS.BoolVar(&serveOpts.jsonMode, "json", false, "Log as JSON") + serveFS.BoolVar(&serveOpts.debugMode, "debug", false, "Debug logging") + serveFS.Func("max-file-size", "Max size of a file", fileSizeParse(&serveOpts.maxFileSize)) + serveFS.Func("max-disk-size", "Max size of all disk space usage", fileSizeParse(&serveOpts.maxDiskSize)) + serveFS.StringVar(&serveOpts.workspacePath, "workspace", ".cabinet", "Workspace for DB, files, tokens, etc.") + serveFS.DurationVar(&serveOpts.gcInterval, "gc-interval", time.Hour, "GC interval for cleaning up files") + serveFS.IntVar(&serveOpts.requestPerMinute, "request-per-minute", 12, "Request limit per-second") + serveFS.Func("size-per-second", "File size limit per-second", fileSizeParse(&serveOpts.sizePerMinute)) + serveFS.Func("burst-size", "Burst size for files", fileSizeParse(&serveOpts.burstSize)) + serveFS.Func("mem-per-request", "Memory per request before storing on disk temporarily", fileSizeParse(&serveOpts.memPerRequest)) + serveFS.IntVar(&serveOpts.port, "port", 8080, "Port to serve on") + serveFS.StringVar(&serveOpts.domain, "domain", "", "Domain the app is running on") + + var tokenOpts tokenOpts + tokenFS := flag.NewFlagSet("token", flag.ExitOnError) + tokenFS.StringVar(&tokenOpts.workspacePath, "workspace", ".cabinet", "Workspace for DB, files, tokens, etc.") + tokenFS.Func("token", "Set token to bypass prompt (or empty to generate)", func(s string) error { + if s == "" { + var err error + tokenOpts.token, err = randToken() + return err + } + tokenOpts.token = s + return nil + }) + tokenFS.Func("permission", "Set the token permission to bypass prompt (or empty for all)", tokenPermParse(&tokenOpts.perm)) + tokenFS.StringVar(&tokenOpts.desc, "description", "", "Description for identifying the token usage/user") + tokenFS.BoolVar(&tokenOpts.delete, "delete", false, "Delete a token by --token or prompt") + + cmd := ffcli.Command{ + Name: "cabinet", + ShortUsage: "Cabinet hosts your files and redirects", + ShortHelp: "Base command", + Subcommands: []*ffcli.Command{ + { + Name: "serve", + ShortUsage: "Serve the Cabinet server", + ShortHelp: "Listen for file and redirect requests", + FlagSet: serveFS, + Options: []ff.Option{ + ff.WithEnvVarPrefix("CABINET"), + ff.WithConfigFileFlag("config"), + ff.WithAllowMissingConfigFile(true), + ff.WithConfigFileParser(fftoml.New().Parse), + }, + Exec: serveCmd(&serveOpts), + }, + { + Name: "token", + ShortHelp: "Generate or delete tokens", + FlagSet: tokenFS, + Exec: tokenCmd(&tokenOpts), + }, + }, + Exec: func(_ context.Context, _ []string) error { + return errors.New("cabinet [serve|token]") + }, + } + + if err := cmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil { + log.Fatal().Err(err).Msg("could not run command") + } +} + +var fileSizePattern = regexp.MustCompile(`(?i)(\d+)(b|mi?b?|gi?b?)?`) + +func fileSizeParse(out *int) func(string) error { + return func(in string) error { + match := fileSizePattern.FindStringSubmatch(in) + if match == nil { + return fmt.Errorf("pattern %q did not match a file size pattern", in) + } + b, err := strconv.Atoi(match[1]) + if err != nil { + return fmt.Errorf("could not parse file size: %w", err) + } + switch strings.ToLower(match[2]) { + case "", "b": + *out = b + case "m", "mb": + *out = b * 1000 * 1000 + case "mi", "mib": + *out = b * 1024 * 1024 + case "g", "gb": + *out = b * 1000 * 1000 * 1000 + case "gi", "gib": + *out = b * 1024 * 1024 * 1024 + default: + return fmt.Errorf("pattern %q did not match a file size pattern", in) + } + return nil + } +} + +func tokenPermParse(out *workspace.TokenPermission) func(string) error { + return func(in string) error { + if in == "" { + *out = workspace.TokenRedirect | workspace.TokenFile + } + var err error + *out, err = workspace.ParseTokenPermission(in) + return err + } +} + +func randToken() (string, error) { + b := make([]byte, 10) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/router/logger.go b/router/logger.go new file mode 100644 index 0000000..7ef135b --- /dev/null +++ b/router/logger.go @@ -0,0 +1 @@ +package router diff --git a/router/rate.go b/router/rate.go new file mode 100644 index 0000000..54757a2 --- /dev/null +++ b/router/rate.go @@ -0,0 +1,102 @@ +package router + +import ( + "net" + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +type Limit struct { + rpm int + spm int + burst int + mpr int + + request map[string]*rate.Limiter + size map[string]*rate.Limiter + mx sync.Mutex +} + +func NewLimit(requestPerMinute, sizePerMinute, burstSize, memPerRequest int) *Limit { + return &Limit{ + rpm: requestPerMinute, + spm: sizePerMinute, + burst: burstSize, + mpr: memPerRequest, + request: make(map[string]*rate.Limiter), + size: make(map[string]*rate.Limiter), + mx: sync.Mutex{}, + } +} + +func (l *Limit) file(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if l.hasHitRequestLimit(r.RemoteAddr) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + + if err := r.ParseMultipartForm(int64(l.mpr)); err != nil { + http.Error(w, "Invalid multipart form", http.StatusBadRequest) + return + } + + files := r.MultipartForm.File["file"] + if len(files) != 1 { + http.Error(w, "Expected exactly one file", http.StatusBadRequest) + return + } + + if l.hasHitSizeLimit(r.RemoteAddr, files[0].Size) { + http.Error(w, "File too big", http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (l *Limit) redirect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if l.hasHitRequestLimit(r.RemoteAddr) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) +} + +// hasHitRequestLimit returns if the requested remote address has reached the request limit. +func (l *Limit) hasHitRequestLimit(remoteAddr string) bool { + ip, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return true + } + + l.mx.Lock() + defer l.mx.Unlock() + + if l.request[ip] == nil { + l.request[ip] = rate.NewLimiter(rate.Limit(l.rpm/60), l.rpm) + l.size[ip] = rate.NewLimiter(rate.Limit(l.spm), l.burst) + } + + return !l.request[ip].Allow() +} + +// hasHitSizeLimit returns if the requested remote address has reached the size limit. +func (l *Limit) hasHitSizeLimit(remoteAddr string, size int64) bool { + ip, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return true + } + + l.mx.Lock() + defer l.mx.Unlock() + + return !l.size[ip].AllowN(time.Now(), int(size)) +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..b4daeb7 --- /dev/null +++ b/router/router.go @@ -0,0 +1,175 @@ +package router + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog/log" + "go.jolheiser.com/cabinet/static" + + "github.com/go-chi/chi/v5" + "go.jolheiser.com/cabinet/workspace" +) + +type Cabinet interface { + Meta(string) (workspace.Meta, error) + Read(string) (io.ReadCloser, error) + Add(io.Reader, workspace.Meta) (string, error) + Token(string) (workspace.Token, error) + IsProtected() (bool, error) +} + +func New(domain string, c Cabinet, limit *Limit) *chi.Mux { + m := chi.NewMux() + m.Use(middleware.StripSlashes) + m.Use(middleware.Logger) + m.Use(middleware.Recoverer) + + middleware.DefaultLogger = middleware.RequestLogger(&middleware.DefaultLogFormatter{ + Logger: &log.Logger, + NoColor: true, + }) + + s := Store{ + domain: domain, + c: c, + } + + m.Get("/", index(domain)) + m.Mount("/css/", http.StripPrefix("/css/", http.FileServer(http.FS(static.CSS)))) + + m.Route("/r", func(r chi.Router) { + r.Get("/{id}", s.GetRedirect) + r.With(limit.redirect, tokenMiddleware(c, workspace.TokenRedirect)).Post("/", s.AddRedirect) + }) + + m.Route("/f", func(r chi.Router) { + r.Get("/{id}", s.GetFile) + r.With(limit.file, tokenMiddleware(c, workspace.TokenFile)).Post("/", s.AddFile) + }) + + return m +} + +func index(domain string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if err := static.Index.Execute(w, domain); err != nil { + log.Err(err).Msg("could not execute index template") + } + } +} + +type Store struct { + domain string + c Cabinet +} + +func (s *Store) AddRedirect(w http.ResponseWriter, r *http.Request) { + u := r.FormValue("url") + if u == "" { + http.Error(w, "Expected a url", http.StatusBadRequest) + return + } + + _, err := url.Parse(u) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + id, err := s.c.Add(strings.NewReader(u), workspace.Meta{ + Type: workspace.TypeRedirect, + Name: u, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if _, err := w.Write([]byte(fmt.Sprintf("%s/r/%s", s.domain, id))); err != nil { + log.Err(err).Msg("could not write response") + } +} + +func (s *Store) GetRedirect(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + meta, err := s.c.Meta(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + if meta.Type != workspace.TypeRedirect { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + rc, err := s.c.Read(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + defer rc.Close() + + redirect, err := io.ReadAll(rc) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, string(redirect), http.StatusFound) +} + +func (s *Store) AddFile(w http.ResponseWriter, r *http.Request) { + fi, fh, err := r.FormFile("file") + if err != nil { + http.Error(w, "Could not open form file", http.StatusBadRequest) + return + } + defer fi.Close() + + id, err := s.c.Add(fi, workspace.Meta{ + Type: workspace.TypeFile, + Name: fh.Filename, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if _, err := w.Write([]byte(fmt.Sprintf("%s/f/%s", s.domain, id))); err != nil { + log.Err(err).Msg("could not write response") + } +} + +func (s *Store) GetFile(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + meta, err := s.c.Meta(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + if meta.Type != workspace.TypeFile { + http.Error(w, "could not find file", http.StatusNotFound) + return + } + + rc, err := s.c.Read(id) + if err != nil { + http.Error(w, "could not read file", http.StatusInternalServerError) + return + } + defer rc.Close() + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.Name)) + if _, err := io.Copy(w, rc); err != nil { + log.Err(err).Msg("could not write response") + } +} diff --git a/router/token.go b/router/token.go new file mode 100644 index 0000000..108eaf9 --- /dev/null +++ b/router/token.go @@ -0,0 +1,42 @@ +package router + +import ( + "net/http" + + "go.jolheiser.com/cabinet/workspace" +) + +func tokenMiddleware(c Cabinet, perm workspace.TokenPermission) func(handler http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + prot, err := c.IsProtected() + if err != nil { + http.Error(w, "could not check token protection", http.StatusInternalServerError) + return + } + if !prot { + next.ServeHTTP(w, r) + return + } + + t := r.FormValue("token") + if t == "" { + http.Error(w, "this host is token protected", http.StatusUnauthorized) + return + } + + token, err := c.Token(t) + if err != nil { + http.Error(w, "could not get token", http.StatusInternalServerError) + return + } + + if !token.Has(perm) { + http.Error(w, "this token cannot access this resource", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/static/index.tmpl b/static/index.tmpl new file mode 100644 index 0000000..48f862b --- /dev/null +++ b/static/index.tmpl @@ -0,0 +1,53 @@ + + + + + Cabinet + + + + + +

Simple File Host

+

A simple website that just hosts your files and redirects.

+ +

How to upload files?

+

HTTP POST files:

+
curl -F'file=@yourfile.png' {{.}}/f
+

If this instance is token protected:

+
curl -F'file=@yourfile.png' -F'token=yourtoken' {{.}}/f
+

You can access uploaded files at the returned URL.

+ +

How to shorten URLs?

+

HTTP POST your URL:

+
curl -d'url=https://example.com' {{.}}/r
+

If this instance is token protected:

+
curl -d'url=https://example.com' -d'token=yourtoken' {{.}}/r
+

You can access redirects at the returned URL.

+ +

How long are files and shortened URLs kept?

+

Until the configured limit of the server is reached! Older and larger files get killed when that's reached.

+

URLs are treated as files with the URL as its content.

+ +

What cannot be uploaded?

+

Here's a non-exhaustive list.

+ + +

Help out

+

+ If you run a server and like this site, clone it!
+ https://git.jojodev.com/jolheiser/cabinet +

+ +

Credits

+

Inspired by https://github.com/chanbakjsd/filehost. CSS modifed from https://github.com/oxalorg/sakura.

+ + \ No newline at end of file diff --git a/static/sakura.css b/static/sakura.css new file mode 100644 index 0000000..b1f3690 --- /dev/null +++ b/static/sakura.css @@ -0,0 +1,249 @@ +/* Sakura.css v1.3.1 + * ================ + * Minimal css theme. + * Project: https://github.com/oxalorg/sakura/ + */ + +/* Default Sakura Theme */ +:root { + --color-blossom: #1d7484; + --color-fade: #982c61; + --color-bg: #f9f9f9; + --color-bg-alt: #f1f1f1; + --color-text: #4a4a4a; +} + +/* Sakura Dark Theme */ +@media (prefers-color-scheme: dark) { + :root { + --color-blossom: #ffffff; + --color-fade: #c9c9c9; + --color-bg: #222222; + --color-bg-alt: #4a4a4a; + --color-text: #c9c9c9; + } +} + +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; +} + +body { + font-size: 1.8rem; + line-height: 1.618; + max-width: 50em; + margin: auto; + color: var(--color-text); + background-color: var(--color-bg); + padding: 13px; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1, h2, h3, h4, h5, h6 { + line-height: 1.1; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + font-weight: 700; + margin-top: 3rem; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + -ms-word-break: break-all; + word-break: break-word; +} + +h1 { + font-size: 2.35em; +} + +h2 { + font-size: 2.00em; +} + +h3 { + font-size: 1.75em; +} + +h4 { + font-size: 1.5em; +} + +h5 { + font-size: 1.25em; +} + +h6 { + font-size: 1em; +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +small, sub, sup { + font-size: 75%; +} + +hr { + border-color: var(--color-blossom); +} + +a { + text-decoration: none; + color: var(--color-blossom); +} + +a:hover { + color: var(--color-fade); + border-bottom: 2px solid var(--color-text); +} + +a:visited { + color: var(--color-blossom); +} + +ul { + padding-left: 1.4em; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +li { + margin-bottom: 0.4em; +} + +blockquote { + margin-left: 0px; + margin-right: 0px; + padding-left: 1em; + padding-top: 0.8em; + padding-bottom: 0.8em; + padding-right: 0.8em; + border-left: 5px solid var(--color-blossom); + margin-bottom: 2.5rem; + background-color: var(--color-bg-alt); +} + +blockquote p { + margin-bottom: 0; +} + +img, video { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +/* Pre and Code */ +pre { + background-color: var(--color-bg-alt); + display: block; + padding: 1em; + overflow-x: auto; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +code { + font-size: 0.9em; + padding: 0 0.5em; + background-color: var(--color-bg-alt); + white-space: pre-wrap; +} + +pre > code { + padding: 0; + background-color: transparent; + white-space: pre; +} + +/* Tables */ +table { + text-align: justify; + width: 100%; + border-collapse: collapse; +} + +td, th { + padding: 0.5em; + border-bottom: 1px solid var(--color-bg-alt); +} + +/* Buttons, forms and input */ +input, textarea { + border: 1px solid var(--color-text); +} + +input:focus, textarea:focus { + border: 1px solid var(--color-blossom); +} + +textarea { + width: 100%; +} + +.button, button, input[type="submit"], input[type="reset"], input[type="button"] { + display: inline-block; + padding: 5px 10px; + text-align: center; + text-decoration: none; + white-space: nowrap; + background-color: var(--color-blossom); + color: var(--color-bg); + border-radius: 1px; + border: 1px solid var(--color-blossom); + cursor: pointer; + box-sizing: border-box; +} + +.button[disabled], button[disabled], input[type="submit"][disabled], input[type="reset"][disabled], input[type="button"][disabled] { + cursor: default; + opacity: .5; +} + +.button:focus:enabled, .button:hover:enabled, button:focus:enabled, button:hover:enabled, input[type="submit"]:focus:enabled, input[type="submit"]:hover:enabled, input[type="reset"]:focus:enabled, input[type="reset"]:hover:enabled, input[type="button"]:focus:enabled, input[type="button"]:hover:enabled { + background-color: var(--color-fade); + border-color: var(--color-fade); + color: var(--color-bg); + outline: 0; +} + +textarea, select, input { + color: var(--color-text); + padding: 6px 10px; + /* The 6px vertically centers text on FF, ignored by Webkit */ + margin-bottom: 10px; + background-color: var(--color-bg-alt); + border: 1px solid var(--color-bg-alt); + border-radius: 4px; + box-shadow: none; + box-sizing: border-box; +} + +textarea:focus, select:focus, input:focus { + border: 1px solid var(--color-blossom); + outline: 0; +} + +input[type="checkbox"]:focus { + outline: 1px dotted var(--color-blossom); +} + +label, legend, fieldset { + display: block; + margin-bottom: .5rem; + font-weight: 600; +} \ No newline at end of file diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..237495c --- /dev/null +++ b/static/static.go @@ -0,0 +1,15 @@ +package static + +import ( + "embed" + "html/template" +) + +var ( + //go:embed index.tmpl + indexTmpl string + Index = template.Must(template.New("").Parse(indexTmpl)) + + //go:embed sakura.css + CSS embed.FS +) diff --git a/workspace/meta.go b/workspace/meta.go new file mode 100644 index 0000000..82d03c3 --- /dev/null +++ b/workspace/meta.go @@ -0,0 +1,47 @@ +package workspace + +import ( + "encoding/json" +) + +type MetaType int + +const ( + TypeFile = iota + TypeRedirect +) + +type Meta struct { + id string + Type MetaType `json:"type"` + Name string `json:"name"` +} + +func (m Meta) ID() string { + return m.id +} + +func (m Meta) MarshalJSON() ([]byte, error) { + type meta Meta + return json.Marshal(struct { + ID string `json:"id"` + meta + }{ + ID: m.ID(), + meta: meta(m), + }) +} + +func (m *Meta) UnmarshalJSON(data []byte) error { + type meta Meta + s := struct { + ID string `json:"id"` + meta + }{} + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *m = Meta(s.meta) + m.id = s.ID + return nil +} diff --git a/workspace/mock/file.go b/workspace/mock/file.go new file mode 100644 index 0000000..519dc47 --- /dev/null +++ b/workspace/mock/file.go @@ -0,0 +1,41 @@ +package mock + +import ( + "os" + "time" + + "go.jolheiser.com/cabinet/workspace" +) + +type File struct { + name string + Content string + size int64 + modTime time.Time + + Meta workspace.Meta +} + +func (f *File) Name() string { + return f.name +} + +func (f *File) Size() int64 { + return f.size +} + +func (f *File) ModTime() time.Time { + return f.modTime +} + +func (f *File) Mode() os.FileMode { + return os.ModePerm +} + +func (f *File) IsDir() bool { + return false +} + +func (f *File) Sys() any { + return nil +} diff --git a/workspace/mock/mock.go b/workspace/mock/mock.go new file mode 100644 index 0000000..b8c2b97 --- /dev/null +++ b/workspace/mock/mock.go @@ -0,0 +1,85 @@ +package mock + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "go.jolheiser.com/cabinet/workspace" +) + +type Mock struct { + files map[string]*File + tokens map[string]workspace.Token +} + +func New() *Mock { + return &Mock{ + files: make(map[string]*File), + } +} + +func (m Mock) List() ([]os.FileInfo, error) { + files := make([]os.FileInfo, 0) + for _, file := range m.files { + files = append(files, file) + } + return files, nil +} + +func (m Mock) Meta(id string) (workspace.Meta, error) { + if file, ok := m.files[id]; ok { + return file.Meta, nil + } + return workspace.Meta{}, fmt.Errorf("no file meta found for %s", id) +} + +func (m Mock) Read(id string) (io.ReadCloser, error) { + if file, ok := m.files[id]; ok { + return io.NopCloser(strings.NewReader(file.Content)), nil + } + return nil, fmt.Errorf("no file content found for %s", id) +} + +func (m Mock) Add(r io.Reader, meta workspace.Meta) (string, error) { + content, err := io.ReadAll(r) + if err != nil { + return "", err + } + id := strconv.Itoa(len(m.files) + 1) + m.files[id] = &File{ + name: meta.Name, + Content: string(content), + size: int64(len(content)), + modTime: time.Now(), + Meta: meta, + } + return id, nil +} + +func (m Mock) Delete(id string) error { + if _, ok := m.files[id]; ok { + delete(m.files, id) + return nil + } + return fmt.Errorf("no file found for %s", id) +} + +func (m Mock) IsProtected() (bool, error) { + return len(m.tokens) > 0, nil +} + +func (m Mock) AddToken(token workspace.Token) error { + m.tokens[token.Key] = token + return nil +} + +func (m Mock) DeleteTokens(keys ...string) error { + for _, key := range keys { + delete(m.tokens, key) + } + return nil +} diff --git a/workspace/token.go b/workspace/token.go new file mode 100644 index 0000000..ed713d0 --- /dev/null +++ b/workspace/token.go @@ -0,0 +1,98 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "strings" + + "go.etcd.io/bbolt" +) + +type TokenPermission int + +const ( + TokenFile = 1 << iota + TokenRedirect +) + +type Token struct { + Key string + Permission TokenPermission + Description string +} + +func (t Token) Has(perm TokenPermission) bool { + return t.Permission&perm != 0 +} + +func ParseTokenPermission(s string) (TokenPermission, error) { + switch strings.ToLower(s) { + case "a", "all": + return TokenRedirect | TokenFile, nil + case "r", "redirect": + return TokenRedirect, nil + case "f", "file": + return TokenFile, nil + default: + return 0, fmt.Errorf("%q did not match a token permission", s) + } +} + +func (t TokenPermission) String() string { + switch t { + case TokenRedirect | TokenFile: + return "all" + case TokenRedirect: + return "redirect" + case TokenFile: + return "file" + default: + return "unknown" + } +} + +func (w *Workspace) Token(key string) (token Token, err error) { + return token, w.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(tokenBucket).Get([]byte(key)) + return json.Unmarshal(data, &token) + }) +} + +func (w *Workspace) Tokens() ([]Token, error) { + var tokens []Token + return tokens, w.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(tokenBucket).ForEach(func(_, v []byte) error { + var token Token + if err := json.Unmarshal(v, &token); err != nil { + return err + } + tokens = append(tokens, token) + return nil + }) + }) +} + +func (w *Workspace) AddToken(token Token) error { + return w.db.Update(func(tx *bbolt.Tx) error { + data, err := json.Marshal(token) + if err != nil { + return err + } + return tx.Bucket(tokenBucket).Put([]byte(token.Key), data) + }) +} + +func (w *Workspace) DeleteTokens(keys ...string) error { + return w.db.Update(func(tx *bbolt.Tx) error { + var failed []string + for _, key := range keys { + if err := tx.Bucket(tokenBucket).Delete([]byte(key)); err != nil { + failed = append(failed, key) + } + } + if len(failed) != 0 { + return fmt.Errorf("failed to delete %d tokens: %s", len(failed), failed) + } + return nil + }) +} diff --git a/workspace/workspace.go b/workspace/workspace.go new file mode 100644 index 0000000..4d7fb5a --- /dev/null +++ b/workspace/workspace.go @@ -0,0 +1,134 @@ +package workspace + +import ( + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/bwmarrin/snowflake" + "go.etcd.io/bbolt" +) + +var ( + metaBucket = []byte("meta") + tokenBucket = []byte("token") +) + +type Workspace struct { + path string + db *bbolt.DB + + sf *snowflake.Node +} + +func (w *Workspace) Close() error { + return w.db.Close() +} + +func (w *Workspace) List() ([]os.FileInfo, error) { + files := make([]os.FileInfo, 0) + if err := filepath.Walk(w.path, func(walkPath string, walkInfo fs.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if walkInfo.IsDir() { + return filepath.SkipDir + } + if walkInfo.Name() == "cabinet.db" { + return nil + } + + files = append(files, walkInfo) + + return nil + }); err != nil { + return nil, err + } + + return files, nil +} + +func (w *Workspace) Meta(id string) (meta Meta, err error) { + return meta, w.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(metaBucket).Get([]byte(id)) + return json.Unmarshal(data, &meta) + }) +} + +func (w *Workspace) Read(id string) (io.ReadCloser, error) { + return os.Open(filepath.Join(w.path, id)) +} + +func (w *Workspace) Add(r io.Reader, meta Meta) (string, error) { + id := w.sf.Generate().String() + meta.id = id + + fi, err := os.Create(filepath.Join(w.path, id)) + if err != nil { + return "", err + } + defer fi.Close() + + if _, err := io.Copy(fi, r); err != nil { + return "", err + } + + return id, w.db.Update(func(tx *bbolt.Tx) error { + data, err := json.Marshal(meta) + if err != nil { + return err + } + return tx.Bucket(metaBucket).Put([]byte(id), data) + }) +} + +func (w *Workspace) Delete(id string) error { + if err := os.Remove(filepath.Join(w.path, id)); err != nil { + return err + } + return w.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(metaBucket).Delete([]byte(id)) + }) +} + +func (w *Workspace) IsProtected() (protected bool, err error) { + return protected, w.db.View(func(tx *bbolt.Tx) error { + protected = tx.Bucket(tokenBucket).Stats().KeyN > 0 + return nil + }) +} + +func New(path string) (*Workspace, error) { + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return nil, err + } + + db, err := bbolt.Open(filepath.Join(path, "cabinet.db"), os.ModePerm, bbolt.DefaultOptions) + if err != nil { + return nil, err + } + + if err := db.Update(func(tx *bbolt.Tx) error { + if _, err := tx.CreateBucketIfNotExists(metaBucket); err != nil { + return err + } + if _, err := tx.CreateBucketIfNotExists(tokenBucket); err != nil { + return err + } + return nil + }); err != nil { + return nil, err + } + + sf, err := snowflake.NewNode(1) + if err != nil { + return nil, err + } + return &Workspace{ + path: path, + db: db, + sf: sf, + }, nil +}