Initial commit
This commit is contained in:
commit
12f82e106e
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/send2gokapi.iml" filepath="$PROJECT_DIR$/.idea/send2gokapi.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
9
.idea/send2gokapi.iml
Normal file
9
.idea/send2gokapi.iml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
137
chunker.go
Normal file
137
chunker.go
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type chunker struct {
|
||||||
|
gc *gokapiClient
|
||||||
|
parallelChunks int
|
||||||
|
chunkSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChunker(gc *gokapiClient, parallelChunks, chunkSize int) *chunker {
|
||||||
|
return &chunker{
|
||||||
|
gc: gc,
|
||||||
|
parallelChunks: parallelChunks,
|
||||||
|
chunkSize: chunkSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunker) UploadFile(ctx context.Context, filename string, progress func(ChunkReport)) (UploadResponse, error) {
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResponse{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fstat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return UploadResponse{}, err
|
||||||
|
}
|
||||||
|
fname := fstat.Name()
|
||||||
|
|
||||||
|
fi := uploadInfo{
|
||||||
|
chunkID: gonanoid.Must(12),
|
||||||
|
filename: fname,
|
||||||
|
totalSize: fstat.Size(),
|
||||||
|
contentType: mime.TypeByExtension(filepath.Ext(fname)),
|
||||||
|
allowedDownloads: 5,
|
||||||
|
expiryDays: 7,
|
||||||
|
password: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.upload(ctx, fi, f, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunker) upload(ctx context.Context, fi uploadInfo, r io.ReaderAt, progress func(ChunkReport)) (UploadResponse, error) {
|
||||||
|
bufPool := sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return make([]byte, c.chunkSize)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := int(fi.totalSize/int64(c.chunkSize) + 1)
|
||||||
|
|
||||||
|
chunkUploaded := make(chan uploadedChunk)
|
||||||
|
doneChunkReport := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(doneChunkReport)
|
||||||
|
|
||||||
|
uploadedChunks := 0
|
||||||
|
uploadedBytes := 0
|
||||||
|
|
||||||
|
progress(ChunkReport{
|
||||||
|
UploadedChunks: 0,
|
||||||
|
UploadedBytes: 0,
|
||||||
|
TotalChunks: chunks,
|
||||||
|
TotalSize: fi.totalSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
for r := range chunkUploaded {
|
||||||
|
uploadedChunks += 1
|
||||||
|
uploadedBytes += r.ChunkSize
|
||||||
|
|
||||||
|
progress(ChunkReport{
|
||||||
|
UploadedChunks: uploadedChunks,
|
||||||
|
UploadedBytes: int64(uploadedBytes),
|
||||||
|
TotalChunks: chunks,
|
||||||
|
TotalSize: fi.totalSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
errGroup, egctx := errgroup.WithContext(ctx)
|
||||||
|
errGroup.SetLimit(c.parallelChunks)
|
||||||
|
|
||||||
|
for i := 0; i < chunks; i++ {
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
offset := int64(i * c.chunkSize)
|
||||||
|
|
||||||
|
buf := bufPool.Get().([]byte)
|
||||||
|
defer bufPool.Put(buf)
|
||||||
|
|
||||||
|
thisBuf := buf
|
||||||
|
if offset+int64(c.chunkSize) > fi.totalSize {
|
||||||
|
thisBuf = buf[:fi.totalSize-offset]
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.ReadAt(thisBuf, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if n != len(thisBuf) {
|
||||||
|
return fmt.Errorf("chunk %d: expected %d bytes but only read %d", i, len(thisBuf), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.gc.uploadChunk(egctx, fi, offset, bytes.NewReader(thisBuf)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkUploaded <- uploadedChunk{ChunkSize: len(thisBuf)}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := errGroup.Wait(); err != nil {
|
||||||
|
return UploadResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
close(chunkUploaded)
|
||||||
|
<-doneChunkReport
|
||||||
|
|
||||||
|
return c.gc.finalizeChunk(ctx, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadedChunk struct {
|
||||||
|
ChunkSize int
|
||||||
|
}
|
116
client.go
Normal file
116
client.go
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gokapiClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
host *url.URL
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGokapiClient(host *url.URL, apiKey string) *gokapiClient {
|
||||||
|
return &gokapiClient{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
host: host,
|
||||||
|
apiKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gc *gokapiClient) uploadChunk(ctx context.Context, fi uploadInfo, offset int64, data io.Reader) error {
|
||||||
|
var body bytes.Buffer
|
||||||
|
|
||||||
|
boundary, err := gc.prepUploadChunkBody(&body, fi, offset, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
actionURL := gc.host.ResolveReference(&url.URL{Path: "/api/chunk/add"})
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", actionURL.String(), &body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Apikey", gc.apiKey)
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
|
||||||
|
resp, err := gc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("upload chunk failed with status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gc *gokapiClient) finalizeChunk(ctx context.Context, fi uploadInfo) (UploadResponse, error) {
|
||||||
|
actionURL := gc.host.ResolveReference(&url.URL{Path: "/api/chunk/complete"})
|
||||||
|
|
||||||
|
formData := url.Values{}
|
||||||
|
formData.Set("uuid", fi.chunkID)
|
||||||
|
formData.Set("filename", fi.filename)
|
||||||
|
formData.Set("filesize", strconv.FormatInt(fi.totalSize, 10))
|
||||||
|
formData.Set("contenttype", fi.contentType)
|
||||||
|
formData.Set("allowedDownloads", strconv.Itoa(fi.allowedDownloads))
|
||||||
|
formData.Set("expiryDays", strconv.Itoa(fi.expiryDays))
|
||||||
|
formData.Set("password", fi.password)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", actionURL.String(), strings.NewReader(formData.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return UploadResponse{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Apikey", gc.apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := gc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResponse{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return UploadResponse{}, fmt.Errorf("upload chunk finalization with status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r UploadResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
|
return UploadResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gc *gokapiClient) prepUploadChunkBody(w io.Writer, fi uploadInfo, offset int64, data io.Reader) (string, error) {
|
||||||
|
mw := multipart.NewWriter(w)
|
||||||
|
defer mw.Close()
|
||||||
|
|
||||||
|
mw.WriteField("uuid", fi.chunkID)
|
||||||
|
mw.WriteField("filesize", strconv.FormatInt(fi.totalSize, 10))
|
||||||
|
mw.WriteField("offset", strconv.FormatInt(offset, 10))
|
||||||
|
|
||||||
|
fileWriter, err := mw.CreateFormFile("file", fi.filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(fileWriter, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mw.Boundary(), nil
|
||||||
|
}
|
8
config.go
Normal file
8
config.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Hostname string
|
||||||
|
APIKey string
|
||||||
|
ParallelChunks int
|
||||||
|
ChunkSize int
|
||||||
|
}
|
13
go.mod
Normal file
13
go.mod
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
module lmika.dev/cmd/send2gokapi
|
||||||
|
|
||||||
|
go 1.23.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
||||||
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/schollz/progressbar/v3 v3.17.1 // indirect
|
||||||
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/term v0.27.0 // indirect
|
||||||
|
)
|
14
go.sum
Normal file
14
go.sum
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||||
|
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||||
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||||
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/schollz/progressbar/v3 v3.17.1 h1:bI1MTaoQO+v5kzklBjYNRQLoVpe0zbyRZNK6DFkVC5U=
|
||||||
|
github.com/schollz/progressbar/v3 v3.17.1/go.mod h1:RzqpnsPQNjUyIgdglUjRLgD7sVnxN1wpmBMV+UiEbL4=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
50
main.go
Normal file
50
main.go
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"github.com/schollz/progressbar/v3"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config := Config{
|
||||||
|
Hostname: os.Getenv("GOKAPI_HOSTNAME"),
|
||||||
|
APIKey: os.Getenv("GOKAPI_API_KEY"),
|
||||||
|
ParallelChunks: 4,
|
||||||
|
ChunkSize: 1024 * 100,
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
hostUrl, err := url.Parse(config.Hostname)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
client := newGokapiClient(hostUrl, config.APIKey)
|
||||||
|
cnkr := newChunker(client, config.ParallelChunks, config.ChunkSize)
|
||||||
|
|
||||||
|
for _, file := range flag.Args() {
|
||||||
|
_, err := cnkr.UploadFile(ctx, file, progressBarReport(file))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressBarReport(filename string) func(cr ChunkReport) {
|
||||||
|
var pr *progressbar.ProgressBar
|
||||||
|
|
||||||
|
return func(cr ChunkReport) {
|
||||||
|
if cr.UploadedChunks == 0 && pr == nil {
|
||||||
|
pr = progressbar.DefaultBytes(cr.TotalSize, filepath.Base(filename))
|
||||||
|
}
|
||||||
|
pr.Set(int(cr.UploadedBytes))
|
||||||
|
}
|
||||||
|
}
|
46
models.go
Normal file
46
models.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
type ChunkReport struct {
|
||||||
|
UploadedChunks int
|
||||||
|
UploadedBytes int64
|
||||||
|
TotalChunks int
|
||||||
|
TotalSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadInfo struct {
|
||||||
|
chunkID string
|
||||||
|
filename string
|
||||||
|
totalSize int64
|
||||||
|
contentType string
|
||||||
|
allowedDownloads int
|
||||||
|
expiryDays int
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadResponse struct {
|
||||||
|
Result string `json:"Result"`
|
||||||
|
FileInfo FileInfo `json:"FileInfo"`
|
||||||
|
IncludeFilename bool `json:"IncludeFilename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Size string `json:"Size"`
|
||||||
|
HotlinkId string `json:"HotlinkId"`
|
||||||
|
ContentType string `json:"ContentType"`
|
||||||
|
ExpireAtString string `json:"ExpireAtString"`
|
||||||
|
UrlDownload string `json:"UrlDownload"`
|
||||||
|
UrlHotlink string `json:"UrlHotlink"`
|
||||||
|
ExpireAt int64 `json:"ExpireAt"`
|
||||||
|
SizeBytes int64 `json:"SizeBytes"`
|
||||||
|
DownloadsRemaining int `json:"DownloadsRemaining"`
|
||||||
|
DownloadsCount int `json:"DownloadsCount"`
|
||||||
|
UnlimitedDownloads bool `json:"UnlimitedDownloads"`
|
||||||
|
UnlimitedTime bool `json:"UnlimitedTime"`
|
||||||
|
RequiresClientSideDecryption bool `json:"RequiresClientSideDecryption"`
|
||||||
|
IsEncrypted bool `json:"IsEncrypted"`
|
||||||
|
IsEndToEndEncrypted bool `json:"IsEndToEndEncrypted"`
|
||||||
|
IsPasswordProtected bool `json:"IsPasswordProtected"`
|
||||||
|
IsSavedOnLocalStorage bool `json:"IsSavedOnLocalStorage"`
|
||||||
|
}
|
Loading…
Reference in a new issue