├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd └── filehost │ ├── import.go │ └── main.go ├── filehost-clean └── main.go ├── go.mod ├── go.sum └── internal ├── api ├── account.go ├── api.go ├── auth.go ├── domains.go ├── file.go ├── ratelimit.go ├── uploads.go └── util.go ├── config └── config.go ├── database ├── account.go ├── database.go ├── domain.go ├── domain_access.go └── upload.go └── filestore ├── diskstore └── diskstore.go ├── filestore.go ├── multistore └── multistore.go └── s3store ├── s3store.go └── seeker.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: Build 25 | run: go build -v ./cmd/filehost 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | /files 4 | /filehost.json 5 | /db 6 | /test 7 | /standalone 8 | /filehost2 9 | /vendor/golang.org 10 | /filehost-clean 11 | /filehost-linux 12 | /deploy.sh 13 | /.env 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # filehost 2 | 3 | ## set up sharex: 4 | 5 | 1. Destinations > Destination settings 6 | 2. add an uploader 7 | 3. fill in your stuff like this ![](https://i.nuuls.com/KPim.png) 8 | 4. click Update 9 | 5. Destinations > image uploader > custom image uploader 10 | 6. Destinations > file uploader > custom file uploader 11 | -------------------------------------------------------------------------------- /cmd/filehost/import.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "mime" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/nuuls/filehost/internal/config" 11 | "github.com/nuuls/filehost/internal/database" 12 | "github.com/sirupsen/logrus" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | func ImportFilesFromFS(cfg *config.Config, log logrus.FieldLogger, db *database.Database) error { 17 | entries, err := os.ReadDir(cfg.FallbackFilePath) 18 | if err != nil { 19 | return err 20 | } 21 | for i, entry := range entries { 22 | log := log.WithField("i", i). 23 | WithField("total", len(entries)). 24 | WithField("filename", entry.Name()). 25 | WithField("progress", i*100/len(entries)) 26 | if i%1000 == 0 { 27 | log.Info("Processing file") 28 | } 29 | if entry.IsDir() { 30 | continue 31 | } 32 | _, err := db.GetUploadByFilename(entry.Name()) 33 | if err == nil { 34 | // already present in db 35 | continue 36 | } 37 | if !errors.Is(err, gorm.ErrRecordNotFound) { 38 | log.WithError(err).Error("Unexpected error") 39 | continue 40 | } 41 | fileInfo, err := entry.Info() 42 | if err != nil { 43 | log.WithError(err).Error("Failed to read file info") 44 | continue 45 | } 46 | spl := strings.Split(entry.Name(), ".") 47 | ext := ".txt" 48 | if len(spl) > 1 { 49 | ext = "." + spl[1] 50 | } 51 | mimeType := mime.TypeByExtension(ext) 52 | mimeType = strings.Split(mimeType, ";")[0] 53 | if mimeType == "" { 54 | mimeType = "text/plain" 55 | } 56 | // import file 57 | upload := database.Upload{ 58 | OwnerID: nil, 59 | UploaderIP: "127.0.0.1", 60 | TTLSeconds: nil, 61 | SizeBytes: uint(fileInfo.Size()), 62 | Filename: entry.Name(), 63 | MimeType: mimeType, 64 | DomainID: cfg.DefaultDomainID, 65 | LastViewedAt: time.Now(), 66 | } 67 | 68 | _, err = db.CreateUpload(upload) 69 | if err != nil { 70 | log.WithError(err).Error("failed to import file") 71 | continue 72 | } 73 | if i%1000 == 0 { 74 | log.Info("Imported file") 75 | } 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /cmd/filehost/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/nuuls/filehost/internal/api" 7 | "github.com/nuuls/filehost/internal/config" 8 | "github.com/nuuls/filehost/internal/database" 9 | "github.com/nuuls/filehost/internal/filestore" 10 | "github.com/nuuls/filehost/internal/filestore/diskstore" 11 | "github.com/nuuls/filehost/internal/filestore/multistore" 12 | "github.com/nuuls/filehost/internal/filestore/s3store" 13 | "github.com/sirupsen/logrus" 14 | "gorm.io/gorm/logger" 15 | ) 16 | 17 | func main() { 18 | cfg := config.MustLoad() 19 | 20 | log := logrus.New() 21 | log.Level = cfg.LogLevel 22 | 23 | dbLogLevel := logger.Info 24 | if cfg.LogLevel != logrus.DebugLevel { 25 | dbLogLevel = logger.Error 26 | } 27 | 28 | db, err := database.New(&database.Config{ 29 | DSN: cfg.PostgresDSN, 30 | Log: log, 31 | LogLevel: dbLogLevel, 32 | }) 33 | if err != nil { 34 | log.WithError(err).Fatal("Failed to connect to database") 35 | } 36 | 37 | if len(os.Args) > 1 { 38 | switch os.Args[1] { 39 | case "import": 40 | err := ImportFilesFromFS(cfg, log, db) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | return 45 | } 46 | } 47 | 48 | a := api.New(api.Config{ 49 | DB: db, 50 | Filestore: multistore.New([]filestore.Filestore{ 51 | s3store.New(cfg), 52 | diskstore.New(cfg.FallbackFilePath), 53 | }), 54 | Log: log, 55 | Config: cfg, 56 | }) 57 | err = a.Run() 58 | if err != nil { 59 | log.WithError(err).Fatal("Failed to run API") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /filehost-clean/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "time" 10 | ) 11 | 12 | func main() { 13 | log.SetOutput(os.Stdout) 14 | var ( 15 | dbPath = flag.String("db", "database.json", "path to database.json file") 16 | // filePath = flag.String("files", ".", "path to files") 17 | maxClicks = flag.Int("max-clicks", 10, "dont delete files with more than max-clicks clicks") 18 | maxAgeDays = flag.Int("max-age-days", 365, "dont delete files younger than max-age-days") 19 | dryRun = flag.Bool("dry-run", false, "dry run") 20 | ) 21 | 22 | flag.Parse() 23 | 24 | db := loadDatabase(*dbPath) 25 | 26 | filesChecked := 0 27 | filesDeleted := 0 28 | 29 | for _, item := range db { 30 | log.Println("Checking: ", item.Name) 31 | filesChecked++ 32 | if item.Clicks > *maxClicks || 33 | time.Since(item.Time) < time.Hour*24*time.Duration(*maxAgeDays) { 34 | log.Printf("Keeping: %#v", item) 35 | continue 36 | } 37 | log.Printf("Deleting: %#v", item) 38 | if *dryRun { 39 | filesDeleted++ 40 | continue 41 | } 42 | err := os.Remove(item.Path) 43 | if err != nil { 44 | log.Println(err) 45 | } else { 46 | filesDeleted++ 47 | } 48 | } 49 | log.Printf("Checked files: %d, deleted files: %d", filesChecked, filesDeleted) 50 | } 51 | 52 | type File struct { 53 | Name string 54 | Path string 55 | MimeType string 56 | Uploader Uploader 57 | Time time.Time 58 | Expire int 59 | Clicks int 60 | } 61 | 62 | type Uploader struct { 63 | IP string `json:"ip"` 64 | UserAgent string `json:"user-agent"` 65 | } 66 | 67 | func loadDatabase(path string) map[string]File { 68 | f, err := ioutil.ReadFile(path) 69 | if err != nil { 70 | log.Fatal("Failed to read DB file:", err) 71 | } 72 | data := map[string]File{} 73 | err = json.Unmarshal(f, &data) 74 | if err != nil { 75 | log.Fatal("failed to decode db:", err) 76 | } 77 | return data 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nuuls/filehost 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.17.5 7 | github.com/aws/aws-sdk-go-v2/config v1.18.15 8 | github.com/aws/aws-sdk-go-v2/credentials v1.13.15 9 | github.com/aws/aws-sdk-go-v2/service/s3 v1.30.5 10 | github.com/go-chi/chi v1.5.4 11 | github.com/joho/godotenv v1.4.0 12 | github.com/kelseyhightower/envconfig v1.4.0 13 | github.com/pkg/errors v0.9.1 14 | github.com/sirupsen/logrus v1.9.0 15 | golang.org/x/crypto v0.6.0 16 | gorm.io/driver/postgres v1.4.6 17 | gorm.io/gorm v1.24.3 18 | ) 19 | 20 | require ( 21 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect 22 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29 // indirect 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23 // indirect 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30 // indirect 26 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.21 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.24 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.23 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.5 // indirect 34 | github.com/aws/smithy-go v1.13.5 // indirect 35 | github.com/jackc/pgpassfile v1.0.0 // indirect 36 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 37 | github.com/jackc/pgx/v5 v5.2.0 // indirect 38 | github.com/jinzhu/inflection v1.0.0 // indirect 39 | github.com/jinzhu/now v1.1.5 // indirect 40 | github.com/stretchr/testify v1.8.1 // indirect 41 | golang.org/x/sys v0.5.0 // indirect 42 | golang.org/x/text v0.7.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.17.5 h1:TzCUW1Nq4H8Xscph5M/skINUitxM5UBAyvm2s7XBzL4= 2 | github.com/aws/aws-sdk-go-v2 v1.17.5/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= 3 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= 4 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= 5 | github.com/aws/aws-sdk-go-v2/config v1.18.15 h1:509yMO0pJUGUugBP2H9FOFyV+7Mz7sRR+snfDN5W4NY= 6 | github.com/aws/aws-sdk-go-v2/config v1.18.15/go.mod h1:vS0tddZqpE8cD9CyW0/kITHF5Bq2QasW9Y1DFHD//O0= 7 | github.com/aws/aws-sdk-go-v2/credentials v1.13.15 h1:0rZQIi6deJFjOEgHI9HI2eZcLPPEGQPictX66oRFLL8= 8 | github.com/aws/aws-sdk-go-v2/credentials v1.13.15/go.mod h1:vRMLMD3/rXU+o6j2MW5YefrGMBmdTvkLLGqFwMLBHQc= 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23 h1:Kbiv9PGnQfG/imNI4L/heyUXvzKmcWSBeDvkrQz5pFc= 10 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23/go.mod h1:mOtmAg65GT1HIL/HT/PynwPbS+UG0BgCZ6vhkPqnxWo= 11 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29 h1:9/aKwwus0TQxppPXFmf010DFrE+ssSbzroLVYINA+xE= 12 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29/go.mod h1:Dip3sIGv485+xerzVv24emnjX5Sg88utCL8fwGmCeWg= 13 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23 h1:b/Vn141DBuLVgXbhRWIrl9g+ww7G+ScV5SzniWR13jQ= 14 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23/go.mod h1:mr6c4cHC+S/MMkrjtSlG4QA36kOznDep+0fga5L/fGQ= 15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30 h1:IVx9L7YFhpPq0tTnGo8u8TpluFu7nAn9X3sUDMb11c0= 16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30/go.mod h1:vsbq62AOBwQ1LJ/GWKFxX8beUEYeRp/Agitrxee2/qM= 17 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.21 h1:QdxdY43AiwsqG/VAqHA7bIVSm3rKr8/p9i05ydA0/RM= 18 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.21/go.mod h1:QtIEat7ksHH8nFItljyvMI0dGj8lipK2XZ4PhNihTEU= 19 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= 21 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.24 h1:Qmm8klpAdkuN3/rPrIMa/hZQ1z93WMBPjOzdAsbSnlo= 22 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.24/go.mod h1:QelGeWBVRh9PbbXsfXKTFlU9FjT6W2yP+dW5jMQzOkg= 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 h1:QoOybhwRfciWUBbZ0gp9S7XaDnCuSTeK/fySB99V1ls= 24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23/go.mod h1:9uPh+Hrz2Vn6oMnQYiUi/zbh3ovbnQk19YKINkQny44= 25 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.23 h1:qc+RW0WWZ2KApMnsu/EVCPqLTyIH55uc7YQq7mq4XqE= 26 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.23/go.mod h1:FJhZWVWBCcgAF8jbep7pxQ1QUsjzTwa9tvEXGw2TDRo= 27 | github.com/aws/aws-sdk-go-v2/service/s3 v1.30.5 h1:kFfb+NMap4R7nDvBYyABa/nw7KFMtAfygD1Hyoxh4uE= 28 | github.com/aws/aws-sdk-go-v2/service/s3 v1.30.5/go.mod h1:Dze3kNt4T+Dgb8YCfuIFSBLmE6hadKNxqfdF0Xmqz1I= 29 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 h1:qJdM48OOLl1FBSzI7ZrA1ZfLwOyCYqkXV5lko1hYDBw= 30 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.4/go.mod h1:jtLIhd+V+lft6ktxpItycqHqiVXrPIRjWIsFIlzMriw= 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 h1:YRkWXQveFb0tFC0TLktmmhGsOcCgLwvq88MC2al47AA= 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4/go.mod h1:zVwRrfdSmbRZWkUkWjOItY7SOalnFnq/Yg2LVPqDjwc= 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.5 h1:L1600eLr0YvTT7gNh3Ni24yGI7NSHkq9Gp62vijPRCs= 34 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.5/go.mod h1:1mKZHLLpDMHTNSYPJ7qrcnCQdHCWsNQaT0xRvq2u80s= 35 | github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= 36 | github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 37 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 38 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 40 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= 42 | github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= 43 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 44 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 45 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 46 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 47 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 48 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 49 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 50 | github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8= 51 | github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk= 52 | github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels= 53 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 54 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 55 | github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 56 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 57 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 58 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 59 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 60 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 61 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 62 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 63 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 64 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 65 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 66 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 67 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 68 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 69 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 70 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 71 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 75 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 76 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 77 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 78 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 79 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 80 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 81 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 82 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 83 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 84 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 85 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 86 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 87 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 88 | go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 89 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 90 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 91 | golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 92 | golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= 93 | golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= 94 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 95 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 96 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 97 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 98 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 99 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 100 | golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 101 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 102 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 103 | golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 104 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 105 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 113 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 115 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 116 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 117 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 118 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 119 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 120 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 121 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 122 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 123 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 124 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 125 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 126 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 127 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 128 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 129 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 130 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 131 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 132 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 133 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 134 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 135 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 136 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 137 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 138 | gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc= 139 | gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4= 140 | gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= 141 | gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg= 142 | gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= 143 | -------------------------------------------------------------------------------- /internal/api/account.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/nuuls/filehost/internal/database" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | type signupRequest struct { 12 | Username string 13 | Password string 14 | } 15 | 16 | type Account struct { 17 | ID uint `json:"id"` 18 | Username string `json:"username"` 19 | APIKey string `json:"apiKey"` 20 | DefaultDomainID uint `json:"defaultDomainId"` 21 | DefaultExpiryHours int `json:"defaultExpiryHours"` 22 | } 23 | 24 | func (a *API) ToAccount(acc *database.Account) Account { 25 | return Account{ 26 | ID: acc.ID, 27 | Username: acc.Username, 28 | APIKey: acc.APIKey, 29 | DefaultDomainID: Or(acc.DefaultDomainID, a.cfg.Config.DefaultDomainID), 30 | DefaultExpiryHours: int(Or(acc.DefaultExpiry, time.Hour*24*365*100).Hours()), 31 | } 32 | } 33 | 34 | func (a *API) signup(w http.ResponseWriter, r *http.Request) { 35 | reqData, err := readJSON[signupRequest](r.Body) 36 | if err != nil { 37 | a.writeError(w, 400, ErrInvalidJSON, err.Error()) 38 | return 39 | } 40 | username, err := sanitizeUsername(reqData.Username) 41 | if err != nil { 42 | a.writeError(w, 400, err.Error()) 43 | return 44 | } 45 | if len(reqData.Password) < 8 { 46 | a.writeError(w, 400, "Password must be at least 8 characters long") 47 | return 48 | } 49 | password, err := bcrypt.GenerateFromPassword([]byte(reqData.Password), bcrypt.DefaultCost) 50 | acc, err := a.db.CreateAccount(database.Account{ 51 | Username: username, 52 | Password: string(password), 53 | APIKey: generateAPIKey(), 54 | }) 55 | if err != nil { 56 | a.writeError(w, 500, "Failed to create account", err.Error()) 57 | return 58 | } 59 | a.writeJSON(w, 201, a.ToAccount(acc)) 60 | } 61 | 62 | func (a *API) login(w http.ResponseWriter, r *http.Request) { 63 | reqData, err := readJSON[signupRequest](r.Body) 64 | if err != nil { 65 | a.writeError(w, 400, ErrInvalidJSON, err.Error()) 66 | return 67 | } 68 | username, err := sanitizeUsername(reqData.Username) 69 | if err != nil { 70 | a.writeError(w, 400, err.Error()) 71 | return 72 | } 73 | acc, err := a.db.GetAccountByUsername(username) 74 | if err != nil { 75 | a.writeError(w, 404, "User not found") 76 | return 77 | } 78 | err = bcrypt.CompareHashAndPassword([]byte(acc.Password), []byte(reqData.Password)) 79 | if err != nil { 80 | a.writeError(w, 400, "Invalid password") 81 | return 82 | } 83 | a.writeJSON(w, 201, a.ToAccount(acc)) 84 | } 85 | 86 | func (a *API) getAccount(w http.ResponseWriter, r *http.Request) { 87 | acc := mustGetAccount(r) 88 | a.writeJSON(w, 200, a.ToAccount(acc)) 89 | } 90 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/go-chi/chi/middleware" 9 | "github.com/nuuls/filehost/internal/config" 10 | "github.com/nuuls/filehost/internal/database" 11 | "github.com/nuuls/filehost/internal/filestore" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func New(cfg Config) *API { 16 | return &API{ 17 | cfg: cfg, 18 | db: cfg.DB, 19 | files: cfg.Filestore, 20 | log: cfg.Log, 21 | } 22 | } 23 | 24 | type API struct { 25 | cfg Config 26 | db *database.Database 27 | files filestore.Filestore 28 | log logrus.FieldLogger 29 | } 30 | 31 | type Config struct { 32 | DB *database.Database 33 | Filestore filestore.Filestore 34 | Log logrus.FieldLogger 35 | Config *config.Config 36 | } 37 | 38 | func (a *API) Run() error { 39 | a.log.WithField("addr", a.cfg.Config.Addr).Info("Starting api") 40 | return http.ListenAndServe(a.cfg.Config.Addr, a.newRouter()) 41 | } 42 | 43 | func (a *API) newRouter() chi.Router { 44 | r := chi.NewRouter() 45 | 46 | r.Use(realIPMiddleware) 47 | r.Use(corsMiddleware) 48 | r.Use(middleware.Logger) 49 | 50 | r.Route("/v1", func(r chi.Router) { 51 | r.Post("/signup", a.signup) 52 | r.Post("/login", a.login) 53 | r.With(a.authMiddleware).Get("/account", a.getAccount) 54 | 55 | r.With(a.authMiddleware).Get("/uploads", a.getUploads) 56 | r.With(a.optionalAuthMiddleware).Post("/uploads", a.upload) 57 | r.With(a.optionalAuthMiddleware).Delete("/uploads/{filename}", a.deleteUpload) 58 | 59 | r.Route("/domains", func(r chi.Router) { 60 | r.Use(a.authMiddleware) 61 | 62 | r.Post("/", a.createDomain) 63 | r.Get("/", a.getDomains) 64 | r.Get("/{id}", a.getDomain) 65 | }) 66 | }) 67 | 68 | r.With(a.optionalAuthMiddleware).Post("/upload", a.upload) 69 | 70 | r.With(RatelimitMiddleware(10, time.Minute*5)).Get("/{filename}", a.serveFile) 71 | 72 | return r 73 | } 74 | -------------------------------------------------------------------------------- /internal/api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type ContextKey int 9 | 10 | const ( 11 | ContextKeyAccount ContextKey = iota + 1 12 | ) 13 | 14 | func (a *API) authMiddleware(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | key := r.URL.Query().Get("api_key") 17 | if key == "" { 18 | a.writeError(w, 401, "Missing ?api_key query param") 19 | return 20 | } 21 | acc, err := a.db.GetAccountByAPIKey(key) 22 | if err != nil { 23 | a.writeError(w, 401, "Invalid API Key", err.Error()) 24 | return 25 | } 26 | next.ServeHTTP(w, r.WithContext( 27 | context.WithValue(r.Context(), ContextKeyAccount, acc)), 28 | ) 29 | }) 30 | } 31 | 32 | func (a *API) optionalAuthMiddleware(next http.Handler) http.Handler { 33 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | key := r.URL.Query().Get("api_key") 35 | if key == "" { 36 | next.ServeHTTP(w, r) 37 | return 38 | } 39 | acc, err := a.db.GetAccountByAPIKey(key) 40 | if err != nil { 41 | a.writeError(w, 401, "Invalid API Key", err.Error()) 42 | return 43 | } 44 | next.ServeHTTP(w, r.WithContext( 45 | context.WithValue(r.Context(), ContextKeyAccount, acc)), 46 | ) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /internal/api/domains.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/nuuls/filehost/internal/database" 9 | ) 10 | 11 | type createDomainRequest struct { 12 | Domain string 13 | AccessRequired bool 14 | AllowedMimeTypes []string 15 | } 16 | 17 | type Domain struct { 18 | ID uint `json:"id"` 19 | Owner *Account `json:"owner"` 20 | Domain string `json:"domain"` 21 | AccessRequired bool `json:"accessRequired"` 22 | AllowedMimeTypes []string `json:"allowedMimeTypes"` 23 | Status database.DomainStatus `json:"status"` 24 | } 25 | 26 | func ToDomain(d *database.Domain) *Domain { 27 | return &Domain{ 28 | ID: d.ID, 29 | // TODO: owner 30 | Domain: d.Domain, 31 | AccessRequired: d.AccessRequired, 32 | AllowedMimeTypes: d.AllowedMimeTypes, 33 | Status: d.Status, 34 | } 35 | } 36 | 37 | func (a *API) createDomain(w http.ResponseWriter, r *http.Request) { 38 | acc := mustGetAccount(r) 39 | if acc.ID != 1 { 40 | a.writeError(w, 403, "This endpoint is for admins only") 41 | return 42 | } 43 | data, err := readJSON[createDomainRequest](r.Body) 44 | if err != nil { 45 | a.writeError(w, 400, "Failed to decode json", err.Error()) 46 | return 47 | } 48 | domain := database.Domain{ 49 | OwnerID: acc.ID, 50 | Domain: data.Domain, 51 | AccessRequired: data.AccessRequired, 52 | AllowedMimeTypes: data.AllowedMimeTypes, 53 | Status: database.DomainStatusPending, 54 | } 55 | d, err := a.db.CreateDomain(domain) 56 | if err != nil { 57 | a.writeError(w, 500, "Failed to create domain", err.Error()) 58 | return 59 | } 60 | a.writeJSON(w, 201, d) // TODO: map to proper type 61 | } 62 | 63 | func (a *API) getDomains(w http.ResponseWriter, r *http.Request) { 64 | domains, err := a.db.GetDomains(25, 0) 65 | if err != nil { 66 | a.writeError(w, 500, "Failed to get domains", err.Error()) 67 | return 68 | } 69 | a.writeJSON(w, 200, PaginatedResponse{ 70 | Total: -1, // TODO: fix 71 | Data: domains, // TODO: map to proper type 72 | }) 73 | } 74 | 75 | func (a *API) getDomain(w http.ResponseWriter, r *http.Request) { 76 | idStr := chi.URLParam(r, "id") 77 | id, err := strconv.Atoi(idStr) 78 | if err != nil { 79 | a.writeError(w, 400, "Invalid ID", err.Error()) 80 | return 81 | } 82 | domain, err := a.db.GetDomainByID(uint(id)) 83 | if err != nil { 84 | a.writeError(w, 500, "Failed to get domains", err.Error()) 85 | return 86 | } 87 | a.writeJSON(w, 200, 88 | ToDomain(domain), 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /internal/api/file.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "mime" 5 | "net/http" 6 | "path/filepath" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-chi/chi" 12 | ) 13 | 14 | func (a *API) serveFile(w http.ResponseWriter, r *http.Request) { 15 | l := a.log 16 | name := chi.URLParam(r, "filename") 17 | name = filepath.Base(name) 18 | l = l.WithField("file", name) 19 | file, err := a.files.Get(name) 20 | if err != nil { 21 | http.Error(w, "File not found", 404) 22 | return 23 | } 24 | defer file.Close() 25 | 26 | spl := strings.Split(name, ".") 27 | extension := "" 28 | if len(spl) > 1 { 29 | extension = spl[len(spl)-1] 30 | } 31 | mimeType := mime.TypeByExtension("." + extension) 32 | // TODO: Get mime type from DB 33 | if mimeType == MimeTypeOctetStream { 34 | switch extension { 35 | case "mp3": 36 | mimeType = "audio/mpeg" 37 | case "wav": 38 | mimeType = "audio/wav" 39 | default: 40 | mimeType = "text/plain" 41 | } 42 | } 43 | if strings.HasPrefix(mimeType, "text") { 44 | mimeType += "; charset=utf-8" 45 | } 46 | if dl, _ := strconv.ParseBool(r.URL.Query().Get("download")); dl { 47 | mimeType = MimeTypeOctetStream 48 | } 49 | w.Header().Set("X-Content-Type-Options", "nosniff") 50 | w.Header().Set("Content-Type", mimeType) 51 | 52 | http.ServeContent(w, r, "", time.Time{}, file) 53 | 54 | err = a.db.IncUploadViews(name) 55 | if err != nil { 56 | l.WithError(err).Error("Failed to increment file views") 57 | return 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/api/ratelimit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // RatelimitMiddleware limits new requests when encountering too many 404 errors to prevent enumeration attacks 10 | func RatelimitMiddleware(maxFails int, resetInterval time.Duration) func(next http.Handler) http.Handler { 11 | var mu sync.Mutex 12 | requests := map[string]int{} 13 | 14 | return func(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | ip := r.RemoteAddr 17 | 18 | mu.Lock() 19 | limited := requests[ip] >= maxFails 20 | mu.Unlock() 21 | 22 | if limited { 23 | http.Error(w, "Ratelimited", 429) 24 | return 25 | } 26 | 27 | wrapped := &wrapWriter{ResponseWriter: w} 28 | next.ServeHTTP(wrapped, r) 29 | if wrapped.statusCode != 404 { 30 | return 31 | } 32 | 33 | mu.Lock() 34 | requests[ip]++ 35 | mu.Unlock() 36 | 37 | time.AfterFunc(resetInterval, func() { 38 | mu.Lock() 39 | requests[ip]-- 40 | if requests[ip] == 0 { 41 | delete(requests, ip) 42 | } 43 | mu.Unlock() 44 | }) 45 | }) 46 | } 47 | } 48 | 49 | type wrapWriter struct { 50 | http.ResponseWriter 51 | statusCode int 52 | } 53 | 54 | func (w *wrapWriter) WriteHeader(code int) { 55 | w.statusCode = code 56 | w.ResponseWriter.WriteHeader(code) 57 | } 58 | -------------------------------------------------------------------------------- /internal/api/uploads.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "mime" 7 | "mime/multipart" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-chi/chi" 13 | "github.com/nuuls/filehost/internal/database" 14 | ) 15 | 16 | type Upload struct { 17 | ID uint `json:"id"` 18 | Owner *Account `json:"owner"` 19 | Filename string `json:"filename"` 20 | MimeType string `json:"mimeType"` 21 | SizeBytes uint `json:"sizeBytes"` 22 | Domain Domain `json:"domain"` 23 | TTLSeconds *uint `json:"ttlSeconds"` 24 | LastViewedAt time.Time `json:"lastViewedAt"` 25 | Views uint `json:"views"` 26 | } 27 | 28 | func ToUpload(d *database.Upload) *Upload { 29 | u := &Upload{} 30 | u.ID = d.ID 31 | // u.Owner.From(d.Owner) 32 | u.Filename = d.Filename 33 | u.MimeType = d.MimeType 34 | u.SizeBytes = d.SizeBytes 35 | u.TTLSeconds = d.TTLSeconds 36 | u.LastViewedAt = d.LastViewedAt 37 | u.Views = d.Views 38 | u.Domain = *ToDomain(&d.Domain) 39 | return u 40 | } 41 | 42 | func (a *API) getUploads(w http.ResponseWriter, r *http.Request) { 43 | acc := mustGetFromContext[*database.Account](r, ContextKeyAccount) 44 | uploads, err := a.db.GetUploadsByAccount(acc.ID, 25, 0) 45 | if err != nil { 46 | a.writeError(w, 500, "Failed to get uploads", err.Error()) 47 | return 48 | } 49 | a.writeJSON(w, 200, PaginatedResponse{ 50 | Total: -1, // TODO: fix 51 | Data: Map(uploads, ToUpload), 52 | }) 53 | } 54 | 55 | func (a *API) upload(w http.ResponseWriter, r *http.Request) { 56 | acc := getAccount(r) 57 | var domain *database.Domain 58 | var err error 59 | if acc != nil && acc.DefaultDomainID != nil { 60 | domain, err = a.db.GetDomainByID(*acc.DefaultDomainID) 61 | } else { 62 | domain, err = a.db.GetDomainByID(a.cfg.Config.DefaultDomainID) 63 | } 64 | if err != nil { 65 | a.writeError(w, 500, "Failed to load Domain config") 66 | return 67 | } 68 | l := a.log 69 | defer r.Body.Close() 70 | 71 | mpHeader, err := getFirstFile(r) 72 | if err != nil { 73 | a.writeError(w, 400, "Failed to read file", err.Error()) 74 | return 75 | } 76 | 77 | file, err := mpHeader.Open() 78 | if err != nil { 79 | l.Error(err) 80 | } 81 | 82 | name := RandomString(5) 83 | 84 | l = l.WithField("file", name) 85 | l.Info("uploading...") 86 | mimeType := mpHeader.Header.Get("Content-Type") 87 | 88 | if !whiteListed(domain.AllowedMimeTypes, mimeType) { 89 | spl := strings.Split(mpHeader.Filename, ".") 90 | if len(spl) > 1 { 91 | ext := spl[len(spl)-1] 92 | mimeType = mime.TypeByExtension("." + ext) 93 | l.WithField("mime-type", mimeType).Debug("type from ext") 94 | } 95 | } 96 | 97 | if mimeType == MimeTypeOctetStream || mimeType == "" { 98 | mimeType = "text/plain" 99 | } 100 | 101 | l = l.WithField("mime-type", mimeType) 102 | 103 | if !whiteListed(domain.AllowedMimeTypes, mimeType) { 104 | l.Warning("mime type not allowed") 105 | a.writeError(w, 415, "Unsupported File Type") 106 | return 107 | } 108 | 109 | extension := ExtensionFromMime(mimeType) 110 | if extension != "" { 111 | extension = "." + extension 112 | } 113 | 114 | fullName := name + extension 115 | 116 | err = a.files.Create(fullName, file) 117 | if err != nil { 118 | l.WithError(err).Error("Failed to create file") 119 | a.writeError(w, 500, "Failed to upload file") 120 | return 121 | } 122 | // TODO: fix localhost 123 | fileURL := fmt.Sprintf("https://%s/%s", domain.Domain, fullName) 124 | 125 | w.Write([]byte(fileURL)) 126 | l.Info("uploaded to ", fileURL) 127 | 128 | upload := database.Upload{ 129 | OwnerID: nil, 130 | UploaderIP: r.RemoteAddr, 131 | TTLSeconds: nil, 132 | SizeBytes: uint(mpHeader.Size), 133 | Filename: fullName, 134 | MimeType: mimeType, 135 | DomainID: a.cfg.Config.DefaultDomainID, 136 | LastViewedAt: time.Now(), 137 | } 138 | 139 | if acc != nil { 140 | upload.OwnerID = &acc.ID 141 | if acc.DefaultDomainID != nil { 142 | upload.DomainID = *acc.DefaultDomainID 143 | } 144 | } 145 | 146 | _, err = a.db.CreateUpload(upload) 147 | if err != nil { 148 | l.WithError(err).Error("Failed to create upload entry") 149 | } 150 | } 151 | 152 | func getFirstFile(r *http.Request) (*multipart.FileHeader, error) { 153 | err := r.ParseMultipartForm(1024 * 1024 * 64) 154 | if err != nil { 155 | return nil, err 156 | } 157 | if r.MultipartForm == nil { 158 | return nil, errors.New("No file attached") 159 | } 160 | files := r.MultipartForm.File 161 | for _, headers := range files { 162 | for _, h := range headers { 163 | return h, nil 164 | } 165 | } 166 | return nil, errors.New("No file attached") 167 | } 168 | 169 | func (a *API) deleteUpload(w http.ResponseWriter, r *http.Request) { 170 | acc := getAccount(r) 171 | 172 | filename := chi.URLParam(r, "filename") 173 | 174 | upload, err := a.db.GetUploadByFilename(filename) 175 | if err != nil { 176 | a.writeError(w, 404, "Upload not found", err.Error()) 177 | return 178 | } 179 | 180 | // User is logged in and uploaded the file 181 | hasAccess := acc != nil && 182 | upload.OwnerID != nil && 183 | *upload.OwnerID == acc.ID 184 | 185 | // User has the same IP as uploader and file is newer than 24 hours 186 | if upload.UploaderIP == r.RemoteAddr && time.Since(upload.CreatedAt) < time.Hour*24 { 187 | hasAccess = true 188 | } 189 | 190 | if !hasAccess { 191 | a.writeError(w, 403, "You do not have access to delete this file") 192 | return 193 | } 194 | 195 | err = a.files.Delete(filename) 196 | if err != nil { 197 | a.writeError(w, 500, "Failed to remove file", err.Error()) 198 | return 199 | } 200 | err = a.db.DeleteUpload(upload.ID) 201 | if err != nil { 202 | a.writeError(w, 500, "Failed to remove file database entry", err.Error()) 203 | return 204 | } 205 | a.writeJSON(w, 200, ToUpload(upload)) 206 | } 207 | -------------------------------------------------------------------------------- /internal/api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "io/ioutil" 10 | rand2 "math/rand" 11 | "net/http" 12 | "regexp" 13 | "strings" 14 | "time" 15 | 16 | "github.com/nuuls/filehost/internal/database" 17 | "github.com/sirupsen/logrus" 18 | ) 19 | 20 | func init() { 21 | rand2.Seed(time.Now().UnixNano()) 22 | } 23 | 24 | const ( 25 | ErrInvalidJSON = "Invalid JSON" 26 | ) 27 | 28 | const MimeTypeOctetStream = "application/octet-stream" 29 | 30 | func (a *API) writeError(w http.ResponseWriter, code int, message string, data ...interface{}) { 31 | out := map[string]interface{}{ 32 | "statusCode": code, 33 | "status": http.StatusText(code), 34 | "message": message, 35 | } 36 | if len(data) == 1 { 37 | out["data"] = data[0] 38 | } else if len(data) > 1 { 39 | out["data"] = data 40 | } 41 | a.log.WithFields(logrus.Fields{ 42 | "statusCode": code, 43 | "message": message, 44 | "data": data, 45 | }).Warning("Responding with error") 46 | a.writeJSON(w, code, out) 47 | } 48 | 49 | func (a *API) writeJSON(w http.ResponseWriter, code int, data interface{}) { 50 | // TODO: check if data is coming from database package and refuse to 51 | // send it to the client 52 | bs, err := json.Marshal(data) 53 | if err != nil { 54 | a.log.WithError(err).WithField("data", data).Error("Failed to encode response as json") 55 | } 56 | w.Header().Add("Content-Type", "application/json") 57 | w.WriteHeader(code) 58 | 59 | w.Write(bs) 60 | } 61 | 62 | func readJSON[T interface{}](rd io.Reader) (*T, error) { 63 | bs, err := ioutil.ReadAll(rd) 64 | if err != nil { 65 | return nil, err 66 | } 67 | out := new(T) 68 | err = json.Unmarshal(bs, out) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return out, nil 73 | } 74 | 75 | func generateAPIKey() string { 76 | bs := make([]byte, 16) 77 | _, err := rand.Read(bs) 78 | if err != nil { 79 | panic(err) 80 | } 81 | return hex.EncodeToString(bs) 82 | } 83 | 84 | func getFromContext[T interface{}](r *http.Request, key interface{}) *T { 85 | val := r.Context().Value(key) 86 | if val == nil { 87 | return nil 88 | } 89 | out := val.(T) 90 | return &out 91 | } 92 | 93 | func mustGetFromContext[T interface{}](r *http.Request, key interface{}) T { 94 | val := getFromContext[T](r, key) 95 | if val == nil { 96 | panic("Failed to get context value") 97 | } 98 | return *val 99 | } 100 | 101 | func mustGetAccount(r *http.Request) *database.Account { 102 | return mustGetFromContext[*database.Account](r, ContextKeyAccount) 103 | } 104 | 105 | func getAccount(r *http.Request) *database.Account { 106 | acc := getFromContext[*database.Account](r, ContextKeyAccount) 107 | if acc == nil { 108 | return nil 109 | } 110 | return *acc 111 | } 112 | 113 | func whiteListed(allowed []string, input string) bool { 114 | spl := strings.Split(input, "/") 115 | if len(spl) < 2 { 116 | return false 117 | } 118 | s1, s2 := spl[0], spl[1] 119 | for _, a := range allowed { 120 | if input == a { 121 | return true 122 | } 123 | spl := strings.Split(a, "/") 124 | if len(spl) < 2 { 125 | panic("Invalid mime type in allow list") 126 | } 127 | passed := 0 128 | if spl[0] == "*" || spl[0] == s1 { 129 | passed++ 130 | } 131 | if spl[1] == "*" || spl[1] == s2 { 132 | passed++ 133 | } 134 | if passed > 1 { 135 | return true 136 | } 137 | } 138 | return false 139 | } 140 | 141 | func ExtensionFromMime(mimeType string) string { 142 | spl := strings.Split(mimeType, "/") 143 | if len(spl) < 2 { 144 | return "" 145 | } 146 | s1, s2 := spl[0], spl[1] 147 | switch s1 { 148 | case "audio": 149 | switch s2 { 150 | case "wav", "x-wav": 151 | return "wav" 152 | default: 153 | return "mp3" 154 | } 155 | case "image": 156 | switch s2 { 157 | case "bmp", "x-windows-bmp": 158 | return "bmp" 159 | case "gif": 160 | return "gif" 161 | case "x-icon": 162 | return "ico" 163 | case "jpeg", "pjpeg": 164 | return "jpeg" 165 | case "tiff", "x-tiff": 166 | return "tif" 167 | default: 168 | return "png" 169 | } 170 | case "text": 171 | switch s2 { 172 | case "html": 173 | return "html" 174 | case "css": 175 | return "css" 176 | case "javascript": 177 | return "js" 178 | case "richtext": 179 | return "rtf" 180 | default: 181 | return "txt" 182 | } 183 | case "application": 184 | switch s2 { 185 | case "json": 186 | return "json" 187 | case "x-gzip": 188 | return "gz" 189 | case "javascript", "x-javascript", "ecmascript": 190 | return "js" 191 | case "pdf": 192 | return "pdf" 193 | case "xml": 194 | return "xml" 195 | case "x-compressed", "x-zip-compressed", "zip": 196 | return "zip" 197 | } 198 | case "video": 199 | switch s2 { 200 | case "avi": 201 | return "avi" 202 | case "quicktime": 203 | return "mov" 204 | default: 205 | return "mp4" 206 | } 207 | default: 208 | return "txt" 209 | } 210 | return "txt" 211 | } 212 | 213 | type PaginatedResponse struct { 214 | Total int `json:"total"` 215 | Data interface{} `json:"data"` 216 | } 217 | 218 | func corsMiddleware(next http.Handler) http.Handler { 219 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 220 | w.Header().Add("Access-Control-Allow-Origin", "*") 221 | w.Header().Add("Access-Control-Allow-Headers", "*") 222 | w.Header().Add("Access-Control-Allow-Methods", "*") 223 | if r.Method == http.MethodOptions { 224 | w.WriteHeader(204) 225 | return 226 | } 227 | next.ServeHTTP(w, r) 228 | }) 229 | } 230 | 231 | func Map[T interface{}, O interface{}](arr []T, fn func(T) O) []O { 232 | out := make([]O, len(arr)) 233 | for i, item := range arr { 234 | out[i] = fn(item) 235 | } 236 | return out 237 | } 238 | 239 | func Or[T interface{}](val *T, fallback T) T { 240 | if val != nil { 241 | return *val 242 | } 243 | return fallback 244 | } 245 | 246 | var usernameRegex = regexp.MustCompile(`^\w{3,20}$`) 247 | 248 | func sanitizeUsername(username string) (string, error) { 249 | username = strings.TrimSpace(strings.ToLower(username)) 250 | if !usernameRegex.MatchString(username) { 251 | return "", errors.New("Username is not allowed") 252 | } 253 | return username, nil 254 | } 255 | 256 | func RandomString(length int) string { 257 | const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" 258 | out := make([]byte, length) 259 | for i := 0; i < length; i++ { 260 | out[i] = letters[rand2.Intn(len(letters))] 261 | } 262 | return string(out) 263 | } 264 | 265 | func realIPMiddleware(next http.Handler) http.Handler { 266 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 267 | ip := r.Header.Get("CF-Connecting-IP") 268 | if ip != "" { 269 | r.RemoteAddr = ip 270 | } 271 | next.ServeHTTP(w, r) 272 | }) 273 | } 274 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/joho/godotenv" 7 | "github.com/kelseyhightower/envconfig" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type Config struct { 12 | Addr string `default:":7417"` 13 | LogLevel logrus.Level `default:"debug"` 14 | PostgresDSN string `default:"host=localhost user=postgres password=postgrespw dbname=postgres port=49153 sslmode=disable"` 15 | StorageBucketEndpoint string `envconfig:"STORAGE_BUCKET_ENDPOINT"` 16 | StorageBucketName string `envconfig:"STORAGE_BUCKET_NAME"` 17 | StorageBucketAccessKeyID string `envconfig:"STORAGE_BUCKET_ACCESS_KEY_ID"` 18 | StorageBucketSecretKey string `envconfig:"STORAGE_BUCKET_SECRET_KEY"` 19 | FallbackFilePath string `default:"./files"` 20 | DefaultDomainID uint `default:"1"` 21 | } 22 | 23 | func MustLoad() *Config { 24 | err := godotenv.Load() 25 | if err != nil { 26 | log.Println("Error loading .env file", err) 27 | } 28 | cfg := &Config{} 29 | err = envconfig.Process("", cfg) 30 | if err != nil { 31 | log.Fatal(err.Error()) 32 | } 33 | return cfg 34 | } 35 | -------------------------------------------------------------------------------- /internal/database/account.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type Account struct { 10 | gorm.Model 11 | Username string `gorm:"unique"` 12 | Password string 13 | APIKey string 14 | Status string 15 | DefaultExpiry *time.Duration 16 | DefaultDomainID *uint 17 | DefaultDomain *Domain 18 | } 19 | 20 | func (db *Database) CreateAccount(acc Account) (*Account, error) { 21 | res := db.db.Create(&acc) 22 | if res.Error != nil { 23 | return nil, res.Error 24 | } 25 | return &acc, nil 26 | } 27 | 28 | func (db *Database) GetAccountByAPIKey(key string) (*Account, error) { 29 | acc := &Account{} 30 | res := db.db.First(acc, "api_key = ?", key) 31 | if res.Error != nil { 32 | return nil, res.Error 33 | } 34 | return acc, nil 35 | } 36 | 37 | func (db *Database) GetAccountByUsername(username string) (*Account, error) { 38 | acc := &Account{} 39 | res := db.db.First(acc, "username = ?", username) 40 | if res.Error != nil { 41 | return nil, res.Error 42 | } 43 | return acc, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "gorm.io/driver/postgres" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/logger" 8 | ) 9 | 10 | type Database struct { 11 | db *gorm.DB 12 | } 13 | 14 | type Config struct { 15 | DSN string 16 | Log logrus.FieldLogger 17 | LogLevel logger.LogLevel 18 | } 19 | 20 | func New(cfg *Config) (*Database, error) { 21 | conn, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{ 22 | DisableForeignKeyConstraintWhenMigrating: true, 23 | Logger: logger.New(cfg.Log, logger.Config{ 24 | LogLevel: cfg.LogLevel, 25 | IgnoreRecordNotFoundError: true, 26 | }), 27 | }) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | db := &Database{ 33 | db: conn, 34 | } 35 | 36 | err = db.db.AutoMigrate( 37 | &Account{}, 38 | &Domain{}, 39 | &DomainAccess{}, 40 | &Upload{}, 41 | ) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return db, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/database/domain.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import "gorm.io/gorm" 4 | 5 | type DomainStatus string 6 | 7 | const ( 8 | DomainStatusPending DomainStatus = "pending" 9 | ) 10 | 11 | type Domain struct { 12 | gorm.Model 13 | OwnerID uint 14 | Owner *Account 15 | Domain string 16 | AccessRequired bool 17 | AllowedMimeTypes []string `gorm:"serializer:json"` 18 | 19 | Status DomainStatus 20 | } 21 | 22 | func (db *Database) CreateDomain(domain Domain) (*Domain, error) { 23 | res := db.db.Create(&domain) 24 | if res.Error != nil { 25 | return nil, res.Error 26 | } 27 | return &domain, nil 28 | } 29 | 30 | func (db *Database) GetDomains(limit, offset int) ([]*Domain, error) { 31 | out := []*Domain{} 32 | res := db.db.Limit(limit).Offset(offset).Find(&out) 33 | if res.Error != nil { 34 | return nil, res.Error 35 | } 36 | return out, nil 37 | } 38 | 39 | func (db *Database) GetDomainByID(id uint) (*Domain, error) { 40 | out := &Domain{} 41 | res := db.db.First(&out, "id = ?", id) 42 | if res.Error != nil { 43 | return nil, res.Error 44 | } 45 | return out, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/database/domain_access.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import "gorm.io/gorm" 4 | 5 | type DomainAccess struct { 6 | gorm.Model 7 | DomainID uint 8 | Domain Domain 9 | AccountID uint 10 | Account Account 11 | } 12 | -------------------------------------------------------------------------------- /internal/database/upload.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type Upload struct { 10 | gorm.Model 11 | OwnerID *uint `gorm:"index"` 12 | Owner *Account 13 | UploaderIP string 14 | Filename string `gorm:"uniqueIndex"` 15 | MimeType string 16 | SizeBytes uint 17 | DomainID uint 18 | Domain Domain 19 | TTLSeconds *uint 20 | LastViewedAt time.Time `gorm:"default:now()"` 21 | Views uint `gorm:"default:0; not null"` 22 | } 23 | 24 | func (db *Database) CreateUpload(upload Upload) (*Upload, error) { 25 | res := db.db.Create(&upload) 26 | if res.Error != nil { 27 | return nil, res.Error 28 | } 29 | return &upload, nil 30 | } 31 | 32 | func (db *Database) GetUploadsByAccount(accountID uint, limit, offset int) ([]*Upload, error) { 33 | out := []*Upload{} 34 | res := db.db. 35 | Joins("Domain"). 36 | Order("id DESC").Limit(limit).Offset(offset). 37 | Find(&out, "uploads.owner_id = ?", accountID) 38 | if res.Error != nil { 39 | return nil, res.Error 40 | } 41 | return out, nil 42 | } 43 | 44 | func (db *Database) IncUploadViews(filename string) error { 45 | res := db.db.Model(Upload{}). 46 | Where("filename = ?", filename). 47 | Updates(map[string]interface{}{ 48 | "views": gorm.Expr("views + 1"), 49 | "last_viewed_at": gorm.Expr("now()"), 50 | }) 51 | return res.Error 52 | } 53 | 54 | func (db *Database) GetUploadByFilename(filename string) (*Upload, error) { 55 | upload := &Upload{} 56 | res := db.db.First(upload, "filename = ?", filename) 57 | if res.Error != nil { 58 | return nil, res.Error 59 | } 60 | return upload, nil 61 | } 62 | 63 | func (db *Database) DeleteUpload(id uint) error { 64 | res := db.db.Delete(&Upload{}, "id = ?", id) 65 | return res.Error 66 | } 67 | -------------------------------------------------------------------------------- /internal/filestore/diskstore/diskstore.go: -------------------------------------------------------------------------------- 1 | package diskstore 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/nuuls/filehost/internal/filestore" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type DiskStore struct { 13 | basePath string 14 | } 15 | 16 | var _ filestore.Filestore = &DiskStore{} 17 | 18 | func New(basePath string) *DiskStore { 19 | return &DiskStore{ 20 | basePath: basePath, 21 | } 22 | } 23 | 24 | func (d *DiskStore) Get(name string) (io.ReadSeekCloser, error) { 25 | file, err := os.Open(filepath.Join(d.basePath, name)) 26 | if err != nil { 27 | return nil, errors.Wrap(err, "File not found") 28 | } 29 | return file, nil 30 | } 31 | 32 | func (d *DiskStore) Create(name string, data io.Reader) error { 33 | dstPath := filepath.Join(d.basePath, name) 34 | // TODO: check if file exists 35 | dst, err := os.Create(dstPath) 36 | if err != nil { 37 | return errors.Wrap(err, "Failed to create file on disk") 38 | } 39 | _, err = io.Copy(dst, data) 40 | if err != nil { 41 | return errors.Wrap(err, "Failed to write file to disk") 42 | } 43 | return nil 44 | } 45 | 46 | func (d *DiskStore) Delete(name string) error { 47 | err := os.Remove(filepath.Join(d.basePath, name)) 48 | if err != nil { 49 | errors.Wrap(err, "Failed to delete file") 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/filestore/filestore.go: -------------------------------------------------------------------------------- 1 | package filestore 2 | 3 | import "io" 4 | 5 | type Filestore interface { 6 | Get(name string) (io.ReadSeekCloser, error) 7 | Create(name string, data io.Reader) error 8 | Delete(name string) error 9 | } 10 | -------------------------------------------------------------------------------- /internal/filestore/multistore/multistore.go: -------------------------------------------------------------------------------- 1 | package multistore 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/nuuls/filehost/internal/filestore" 7 | ) 8 | 9 | var _ filestore.Filestore = &Multistore{} 10 | 11 | type Multistore struct { 12 | stores []filestore.Filestore 13 | } 14 | 15 | // New creates a new Multistore. 16 | // Preferred store should be first in the list. 17 | func New(stores []filestore.Filestore) *Multistore { 18 | return &Multistore{ 19 | stores: stores, 20 | } 21 | } 22 | 23 | // Get file from first available store 24 | func (m *Multistore) Get(name string) (io.ReadSeekCloser, error) { 25 | var err error 26 | for _, s := range m.stores { 27 | var file io.ReadSeekCloser 28 | file, err = s.Get(name) 29 | if err == nil { 30 | return file, nil 31 | } 32 | } 33 | return nil, err 34 | } 35 | 36 | // Create file on first available store 37 | func (m *Multistore) Create(name string, data io.Reader) error { 38 | var err error 39 | for _, s := range m.stores { 40 | err = s.Create(name, data) 41 | if err == nil { 42 | return nil 43 | } 44 | } 45 | return err 46 | } 47 | 48 | // Delete file from all stores, returns no error if deleted from any 49 | func (m *Multistore) Delete(name string) error { 50 | var err error 51 | for _, s := range m.stores { 52 | err2 := s.Delete(name) 53 | if err != nil { 54 | err = err2 55 | } 56 | } 57 | return err 58 | } 59 | -------------------------------------------------------------------------------- /internal/filestore/s3store/s3store.go: -------------------------------------------------------------------------------- 1 | package s3store 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | awsconfig "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/credentials" 12 | "github.com/aws/aws-sdk-go-v2/service/s3" 13 | "github.com/nuuls/filehost/internal/config" 14 | "github.com/nuuls/filehost/internal/filestore" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | type S3Store struct { 19 | client *s3.Client 20 | bucketName string 21 | } 22 | 23 | var _ filestore.Filestore = &S3Store{} 24 | 25 | func New(cfg *config.Config) *S3Store { 26 | r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { 27 | return aws.Endpoint{ 28 | URL: cfg.StorageBucketEndpoint, 29 | }, nil 30 | }) 31 | 32 | s3cfg, err := awsconfig.LoadDefaultConfig(context.TODO(), 33 | awsconfig.WithEndpointResolverWithOptions(r2Resolver), 34 | awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.StorageBucketAccessKeyID, cfg.StorageBucketSecretKey, "")), 35 | ) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | s3Client := s3.NewFromConfig(s3cfg) 41 | 42 | if err != nil { 43 | panic(err) 44 | } 45 | return &S3Store{ 46 | client: s3Client, 47 | bucketName: cfg.StorageBucketName, 48 | } 49 | } 50 | 51 | func (s *S3Store) Get(name string) (io.ReadSeekCloser, error) { 52 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) 53 | defer cancel() 54 | obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{ 55 | Bucket: &s.bucketName, 56 | Key: &name, 57 | }) 58 | if err != nil { 59 | return nil, errors.Wrap(err, "File not found") 60 | } 61 | defer obj.Body.Close() 62 | 63 | readSeeker := newReadSeeker(func(rangeStr string) (*s3.GetObjectOutput, error) { 64 | obj, err := s.client.GetObject(context.TODO(), &s3.GetObjectInput{ 65 | Bucket: &s.bucketName, 66 | Key: &name, 67 | Range: &rangeStr, 68 | }) 69 | return obj, err 70 | }) 71 | 72 | return readSeeker, nil 73 | } 74 | 75 | func (s *S3Store) Create(name string, data io.Reader) error { 76 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) 77 | defer cancel() 78 | _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ 79 | Bucket: &s.bucketName, 80 | Key: &name, 81 | Body: data, 82 | }) 83 | if err != nil { 84 | return errors.Wrap(err, "Failed to create file") 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (s *S3Store) Delete(name string) error { 91 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) 92 | defer cancel() 93 | _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ 94 | Bucket: &s.bucketName, 95 | Key: &name, 96 | }) 97 | if err != nil { 98 | errors.Wrap(err, "Failed to delete file") 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /internal/filestore/s3store/seeker.go: -------------------------------------------------------------------------------- 1 | package s3store 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/aws/aws-sdk-go-v2/service/s3" 9 | ) 10 | 11 | type s3ReadSeeker struct { 12 | getRange func(string) (*s3.GetObjectOutput, error) 13 | 14 | buffer *bytes.Buffer 15 | 16 | offset int64 17 | } 18 | 19 | const bufferSize = 1024 * 1024 * 8 20 | 21 | func newReadSeeker(getRange func(string) (*s3.GetObjectOutput, error)) *s3ReadSeeker { 22 | buf := make([]byte, 0, bufferSize) 23 | return &s3ReadSeeker{ 24 | getRange: getRange, 25 | buffer: bytes.NewBuffer(buf), 26 | } 27 | } 28 | 29 | func (r *s3ReadSeeker) Read(buf []byte) (int, error) { 30 | if r.buffer.Len() < len(buf) { 31 | rangeStr := fmt.Sprintf("bytes=%d-%d", r.offset, r.offset+bufferSize) 32 | res, err := r.getRange(rangeStr) 33 | if err != nil { 34 | return -1, err 35 | } 36 | defer res.Body.Close() 37 | 38 | r.buffer.Truncate(0) 39 | _, err = io.Copy(r.buffer, res.Body) 40 | if err != nil { 41 | return -1, err 42 | } 43 | } 44 | 45 | n, err := r.buffer.Read(buf) 46 | r.offset += int64(n) 47 | return n, err 48 | } 49 | 50 | func (r *s3ReadSeeker) Seek(offset int64, whence int) (int64, error) { 51 | switch whence { 52 | case io.SeekCurrent: 53 | r.offset += offset 54 | return r.offset, nil 55 | case io.SeekStart: 56 | r.offset = 0 + offset 57 | return r.offset, nil 58 | case io.SeekEnd: 59 | res, err := r.getRange("bytes=0-") 60 | if err != nil { 61 | return -1, err 62 | } 63 | defer res.Body.Close() 64 | r.offset = res.ContentLength + offset 65 | return r.offset, nil 66 | } 67 | 68 | return -1, fmt.Errorf("Invalid seek whence") 69 | } 70 | 71 | func (r *s3ReadSeeker) Close() error { 72 | return nil 73 | } 74 | --------------------------------------------------------------------------------