commit
d1ca6b1c49
|
@ -0,0 +1,3 @@
|
|||
.idea/
|
||||
.cabinet/
|
||||
/cabinet*
|
|
@ -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.
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
package router
|
|
@ -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))
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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&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>
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue