Browse Source

Initial Commit

Signed-off-by: jolheiser <john.olheiser@gmail.com>
main
jolheiser 3 weeks ago
commit
d1ca6b1c49
Signed by: jolheiser GPG Key ID: B853ADA5DA7BBF7A
  1. 3
      .gitignore
  2. 19
      LICENSE
  3. 47
      README.md
  4. 199
      command.go
  5. 49
      gc/gc.go
  6. 24
      gc/sort.go
  7. 24
      go.mod
  8. 78
      go.sum
  9. 147
      main.go
  10. 1
      router/logger.go
  11. 102
      router/rate.go
  12. 175
      router/router.go
  13. 42
      router/token.go
  14. 53
      static/index.tmpl
  15. 249
      static/sakura.css
  16. 15
      static/static.go
  17. 47
      workspace/meta.go
  18. 41
      workspace/mock/file.go
  19. 85
      workspace/mock/mock.go
  20. 98
      workspace/token.go
  21. 134
      workspace/workspace.go

3
.gitignore

@ -0,0 +1,3 @@
.idea/
.cabinet/
/cabinet*

19
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.

47
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)

199
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
}

49
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")
}
}

24
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]
}

24
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
)

78
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=

147
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
}

1
router/logger.go

@ -0,0 +1 @@
package router

102
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))
}

175
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")
}
}

42
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)
})
}
}

53
static/index.tmpl

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Cabinet</title>
<link rel="stylesheet" href="{{.}}/css/sakura.css"/>
</head>
<body>
<h1>Simple File Host</h1>
<p>A simple website that just hosts your files and redirects.</p>
<h2>How to upload files?</h2>
<p>HTTP POST files:</p>
<pre>curl -F'file=@yourfile.png' {{.}}/f</pre>
<p>If this instance is token protected:</p>
<pre>curl -F'file=@yourfile.png' -F'token=yourtoken' {{.}}/f</pre>
<p>You can access uploaded files at the returned URL.</p>
<h2>How to shorten URLs?</h2>
<p>HTTP POST your URL:</p>
<pre>curl -d'url=https://example.com' {{.}}/r</pre>
<p>If this instance is token protected:</p>
<pre>curl -d'url=https://example.com' -d'token=yourtoken' {{.}}/r</pre>
<p>You can access redirects at the returned URL.</p>
<h2>How long are files and shortened URLs kept?</h2>
<p>Until the configured limit of the server is reached! Older and larger files get killed when that's reached.</p>
<p>URLs are treated as files with the URL as its content.</p>
<h2>What cannot be uploaded?</h2>
<p>Here's a non-exhaustive list.</p>
<ul>
<li>Piracy</li>
<li>Pornography</li>
<li>Extremist material of any kind</li>
<li>Malware / botnet C&amp;C</li>
<li>Cryptocurrency-related things</li>
<li>Backups</li>
<li>Anything illegal</li>
</ul>
<h2>Help out</h2>
<p>
If you run a server and like this site, clone it!<br/>
<a href="https://git.jojodev.com/jolheiser/cabinet">https://git.jojodev.com/jolheiser/cabinet</a>
</p>
<h2>Credits</h2>
<p>Inspired by <a href="https://github.com/chanbakjsd/filehost">https://github.com/chanbakjsd/filehost</a>. CSS modifed from <a href="https://github.com/oxalorg/sakura">https://github.com/oxalorg/sakura</a>.</p>
</body>
</html>

249
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;
}

15
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
)

47
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
}

41
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
}

85
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
}

98
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
})
}

134
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
})