Compare commits
14 Commits
Author | SHA1 | Date |
---|---|---|
mappu | bfd669bc96 | |
mappu | ea1309eb75 | |
mappu | b146db9d0a | |
mappu | 0a22d1ca8a | |
mappu | bc911327cf | |
mappu | e04900f672 | |
mappu | d10026ae82 | |
mappu | 36b4b124f7 | |
mappu | 8d40031edc | |
mappu | 15c02efe96 | |
mappu | 01ff8f69aa | |
mappu | 3e5fb091c9 | |
mappu | caf521c318 | |
mappu | 77a4061cdd |
|
@ -1,4 +1,5 @@
|
|||
cmd/contented/contented
|
||||
cmd/contented-multi/contented-multi
|
||||
build/
|
||||
_dist/
|
||||
contented.db
|
||||
|
|
15
README.md
15
README.md
|
@ -10,20 +10,22 @@ The name is a pun on "content" and the -d suffix for server daemons.
|
|||
|
||||
## Features
|
||||
|
||||
- Use local disk or S3-backed storage
|
||||
- Drag and drop upload
|
||||
- Multiple files upload
|
||||
- Pastebin upload
|
||||
- Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js))
|
||||
- Ctrl-V upload
|
||||
- Galleries
|
||||
- Galleries and nested galleries
|
||||
- SDK-oriented design for embedding, including CORS support
|
||||
- Mobile friendly HTML interface
|
||||
- Preserves uploaded filename and content-type metadata
|
||||
- Hash verification (SHA512/256)
|
||||
- Detect duplicate upload content and reuse storage
|
||||
- Options to limit the upload filesize and the upload bandwidth
|
||||
- Options to limit the upload filesize, upload bandwidth, and maximum source filesize for thumbnailing
|
||||
- Short URLs (using [Hashids](http://hashids.org) algorithm)
|
||||
- Image thumbnailing
|
||||
- Optional multi-tenant binary (`contented-multi`)
|
||||
|
||||
## Usage (Server)
|
||||
|
||||
|
@ -84,6 +86,15 @@ You can optionally supply additional ordered parameters to `contented.init`:
|
|||
|
||||
## Changelog
|
||||
|
||||
2023-05-20: 1.5.1
|
||||
- Improve support for albums with no images, and for albums with missing interior images
|
||||
|
||||
2023-05-19: 1.5.0
|
||||
- Feature: Support S3-backed storage
|
||||
- Feature: New `contented-multi` binary to host multiple server configurations from a single process
|
||||
- Enhancement: Better client-side caching for thumbnails
|
||||
- Option to cap source filesize for thumbnailing (default 20MiB)
|
||||
|
||||
2023-05-17: 1.4.0
|
||||
- BREAKING: Remove support for some old web browsers (require jQuery 3, ES6 template literals, Promises, Canvas.toBlob)
|
||||
- Feature: Initial album support with custom titles
|
||||
|
|
46
Server.go
46
Server.go
|
@ -3,6 +3,7 @@ package contented
|
|||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
@ -11,6 +12,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/mxk/go-flowrate/flowrate"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
@ -20,9 +23,15 @@ var staticAssets embed.FS
|
|||
|
||||
var SERVER_HEADER string = `contented/0.0.0-dev`
|
||||
|
||||
const DEFAULT_MAX_CONCURRENT_THUMBS = 16
|
||||
const (
|
||||
DEFAULT_MAX_CONCURRENT_THUMBS = 16
|
||||
DEFAULT_MAX_THUMBSIZE = 20 * 1024 * 1024 // 20 MiB
|
||||
|
||||
const ALBUM_MIMETYPE = `contented/album`
|
||||
ALBUM_MIMETYPE = `contented/album`
|
||||
|
||||
STORAGE_LOCAL int = 0
|
||||
STORAGE_S3 int = 1
|
||||
)
|
||||
|
||||
type ServerPublicProperties struct {
|
||||
AppTitle string
|
||||
|
@ -31,7 +40,16 @@ type ServerPublicProperties struct {
|
|||
}
|
||||
|
||||
type ServerOptions struct {
|
||||
DataDirectory string
|
||||
StorageType int // STORAGE_xx
|
||||
DataDirectory string
|
||||
DataS3Options struct {
|
||||
Hostname string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
Prefix string
|
||||
}
|
||||
|
||||
DBPath string
|
||||
DiskFilesWorldReadable bool
|
||||
BandwidthLimit int64
|
||||
|
@ -39,6 +57,7 @@ type ServerOptions struct {
|
|||
EnableHomepage bool
|
||||
EnableUpload bool
|
||||
MaxConcurrentThumbs int
|
||||
MaxThumbSizeBytes int64
|
||||
ServerPublicProperties
|
||||
}
|
||||
|
||||
|
@ -52,6 +71,7 @@ func (this *ServerOptions) FileMode() os.FileMode {
|
|||
|
||||
type Server struct {
|
||||
opts ServerOptions
|
||||
s3client *minio.Client
|
||||
db *bolt.DB
|
||||
startTime time.Time
|
||||
thumbnailSem chan struct{}
|
||||
|
@ -71,8 +91,26 @@ func NewServer(opts *ServerOptions) (*Server, error) {
|
|||
log.Printf("Allowing %d concurrent thumbnails", s.opts.MaxConcurrentThumbs)
|
||||
}
|
||||
|
||||
if s.opts.MaxThumbSizeBytes <= 0 {
|
||||
s.opts.MaxThumbSizeBytes = DEFAULT_MAX_THUMBSIZE
|
||||
log.Printf("Allowing thumbnails for files up to %d byte(s)", s.opts.MaxThumbSizeBytes)
|
||||
}
|
||||
|
||||
s.staticDir, _ = fs.Sub(staticAssets, `static`) // can't fail
|
||||
|
||||
// Maybe open s3 connection
|
||||
if s.opts.StorageType == STORAGE_S3 {
|
||||
cl, err := minio.New(opts.DataS3Options.Hostname, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(opts.DataS3Options.AccessKey, opts.DataS3Options.SecretKey, ""),
|
||||
Secure: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Connecting to S3 host: %w", err)
|
||||
}
|
||||
|
||||
s.s3client = cl
|
||||
}
|
||||
|
||||
// "fill" the thumbnailer semaphore
|
||||
s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs)
|
||||
for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 {
|
||||
|
@ -151,7 +189,7 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):])
|
||||
|
||||
} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) {
|
||||
this.handlePreview(w, r.URL.Path[len(previewUrlPrefix):])
|
||||
this.handlePreview(r.Context(), w, r.URL.Path[len(previewUrlPrefix):])
|
||||
|
||||
} else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) {
|
||||
parts := rxThumbUrl.FindStringSubmatch(r.URL.Path)
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"code.ivysaur.me/contented"
|
||||
)
|
||||
|
||||
type ContentedMultiCfg struct {
|
||||
Servers []struct {
|
||||
ListenAddr string
|
||||
Options contented.ServerOptions
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
configFile := flag.String("config", "contented-multi.cfg", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
fh, err := os.Open(*configFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var cfg ContentedMultiCfg
|
||||
err = json.NewDecoder(fh).Decode(&cfg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fh.Close()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(cfg.Servers))
|
||||
|
||||
for i, _ := range cfg.Servers {
|
||||
go (func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
s, err := contented.NewServer(&cfg.Servers[i].Options)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create server %d/%d: %s", i+1, len(cfg.Servers), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = http.ListenAndServe(cfg.Servers[i].ListenAddr, s)
|
||||
log.Printf("Server %d/%d shutting down: %s", i+1, len(cfg.Servers), err.Error())
|
||||
|
||||
})(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
|
@ -12,22 +12,28 @@ import (
|
|||
func main() {
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
listenAddr := flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
|
||||
dataDir := flag.String("data", cwd, "Directory for stored content")
|
||||
dbPath := flag.String("db", "contented.db", "Path for metadata database")
|
||||
appTitle := flag.String("title", "contented", "Title used in web interface")
|
||||
maxUploadMb := flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
|
||||
maxUploadSpeed := flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
|
||||
trustXForwardedFor := flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
|
||||
enableHomepage := flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
|
||||
enableUpload := flag.Bool("enableUpload", true, "Enable uploads (disable for read-only mode)")
|
||||
diskFilesWorldReadable := flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
|
||||
maxConcurrentThumbs := flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
|
||||
var (
|
||||
listenAddr = flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
|
||||
dataDir = flag.String("data", cwd, "Directory for stored content")
|
||||
dbPath = flag.String("db", "contented.db", "Path for metadata database")
|
||||
appTitle = flag.String("title", "contented", "Title used in web interface")
|
||||
maxUploadMb = flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
|
||||
maxUploadSpeed = flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
|
||||
trustXForwardedFor = flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
|
||||
enableHomepage = flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
|
||||
enableUpload = flag.Bool("enableUpload", true, "Enable uploads (disable for read-only mode)")
|
||||
diskFilesWorldReadable = flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
|
||||
maxConcurrentThumbs = flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
|
||||
s3Host = flag.String("s3hostname", "", "S3 Server hostname")
|
||||
s3AccessKey = flag.String("s3access", "", "S3 Access key")
|
||||
s3SecretKey = flag.String("s3secret", "", "S3 Secret key")
|
||||
s3Bucket = flag.String("s3bucket", "", "S3 Bucket")
|
||||
s3Prefix = flag.String("s3prefix", "", "S3 object prefix")
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
svr, err := contented.NewServer(&contented.ServerOptions{
|
||||
DataDirectory: *dataDir,
|
||||
opts := contented.ServerOptions{
|
||||
DBPath: *dbPath,
|
||||
BandwidthLimit: int64(*maxUploadSpeed),
|
||||
TrustXForwardedFor: *trustXForwardedFor,
|
||||
|
@ -39,7 +45,26 @@ func main() {
|
|||
AppTitle: *appTitle,
|
||||
MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(*s3AccessKey) > 0 {
|
||||
opts.StorageType = contented.STORAGE_S3
|
||||
opts.DataS3Options.Hostname = *s3Host
|
||||
opts.DataS3Options.AccessKey = *s3AccessKey
|
||||
opts.DataS3Options.SecretKey = *s3SecretKey
|
||||
opts.DataS3Options.Bucket = *s3Bucket
|
||||
opts.DataS3Options.Prefix = *s3Prefix
|
||||
|
||||
} else if len(*dataDir) > 0 {
|
||||
opts.StorageType = contented.STORAGE_LOCAL
|
||||
opts.DataDirectory = *dataDir
|
||||
|
||||
} else {
|
||||
log.Println("Please specify either the -data or -s3__ options.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
svr, err := contented.NewServer(&opts)
|
||||
if err != nil {
|
||||
log.Println(err.Error())
|
||||
os.Exit(1)
|
||||
|
|
16
download.go
16
download.go
|
@ -4,7 +4,6 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) {
|
||||
|
@ -28,7 +27,7 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
|
|||
}
|
||||
|
||||
// Load file
|
||||
f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
|
||||
f, err := this.ReadFile(r.Context(), m.FileHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -50,7 +49,18 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
|
|||
w.Header().Set(`Content-Type`, m.MimeType)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "", m.UploadTime, f)
|
||||
/*
|
||||
if _, ok := f.(io.ReadSeeker); ! ok {
|
||||
// Stream directly, no support for bytes/etag
|
||||
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", m.FileSize))
|
||||
_, err := io.Copy(w, f)
|
||||
return err
|
||||
|
||||
}
|
||||
*/
|
||||
|
||||
// Allow range requests, if-modified-since, and so on
|
||||
http.ServeContent(w, r, "", m.UploadTime, f)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
|
18
go.mod
18
go.mod
|
@ -9,9 +9,25 @@ require (
|
|||
|
||||
require (
|
||||
code.ivysaur.me/imagequant/v2 v2.12.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.52 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.6.0 // indirect
|
||||
golang.org/x/image v0.0.0-20200618115811-c13761719519 // indirect
|
||||
golang.org/x/sys v0.4.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
go 1.19
|
||||
|
|
48
go.sum
48
go.sum
|
@ -4,18 +4,66 @@ code.ivysaur.me/thumbnail v1.0.2 h1:vQaRPbBZOUGpr4b5rrUOHiZv08XSRJ83uu64WXFx7mo=
|
|||
code.ivysaur.me/thumbnail v1.0.2/go.mod h1:sXeHBfmPfiSe5ZBKsbGSES13C9OSZq0WmT4yZ/XBeeE=
|
||||
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
|
||||
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.52 h1:8XhG36F6oKQUDDSuz6dY3rioMzovKjW40W6ANuN0Dps=
|
||||
github.com/minio/minio-go/v7 v7.0.52/go.mod h1:IbbodHyjUAguneyucUaahv+VMNs/EOTV9du7A7/Z3HU=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/speps/go-hashids v1.0.0 h1:jdFC07PrExRM4Og5Ev4411Tox75aFpkC77NlmutadNI=
|
||||
github.com/speps/go-hashids v1.0.0/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
|
||||
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b h1:5rOiLYVqtE+JehJPVJTXQJaP8aT3cpJC1Iy22+5WLFU=
|
||||
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
39
preview.go
39
preview.go
|
@ -1,18 +1,18 @@
|
|||
package contented
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (this *Server) handlePreview(w http.ResponseWriter, fileIDList string) {
|
||||
func (this *Server) handlePreview(ctx context.Context, w http.ResponseWriter, fileIDList string) {
|
||||
|
||||
fileIDs := strings.Split(fileIDList, `-`)
|
||||
|
||||
|
@ -77,6 +77,7 @@ html, body {
|
|||
.thumbnail {
|
||||
line-height: 0;
|
||||
width: 340px;
|
||||
height: 340px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
@ -108,8 +109,27 @@ html, body {
|
|||
m, err := this.Metadata(fileID)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, "Not found", 404)
|
||||
return
|
||||
|
||||
// If this is just one image out of many, show a 404 box and continue to show the other entries
|
||||
// But if this is only a single image requested, abandon the whole pageload
|
||||
|
||||
if len(fileIDs) == 1 {
|
||||
http.Error(w, "Not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl += `
|
||||
<div class="entry">
|
||||
<div class="thumbnail">
|
||||
<img loading="lazy" src="/nothumb_340.png"></a>
|
||||
</div>
|
||||
<div class="properties">
|
||||
Requested ID ` + html.EscapeString(fileID) + ` not found in storage (404)
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println(err.Error())
|
||||
|
@ -119,7 +139,7 @@ html, body {
|
|||
|
||||
if m.MimeType == ALBUM_MIMETYPE {
|
||||
// Special handling for albums
|
||||
f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
|
||||
f, err := this.ReadFile(ctx, m.FileHash)
|
||||
if err != nil {
|
||||
log.Printf("Opening file '%s' for preview of album '%s': %s", m.FileHash, fileID, err.Error())
|
||||
http.Error(w, "Internal error", 500)
|
||||
|
@ -135,16 +155,15 @@ html, body {
|
|||
return
|
||||
}
|
||||
|
||||
if len(childIDs) == 0 {
|
||||
log.Printf("Failed to parse album '%s': no entries in album", fileID)
|
||||
http.Error(w, "Internal error", 500)
|
||||
return
|
||||
albumThumb := `/nothumb_340.png`
|
||||
if len(childIDs) > 0 {
|
||||
albumThumb = `/thumb/m/` + childIDs[0]
|
||||
}
|
||||
|
||||
tmpl += `
|
||||
<div class="entry">
|
||||
<div class="thumbnail">
|
||||
<a href="` + html.EscapeString(`/p/`+strings.Join(childIDs, `-`)) + `"><img loading="lazy" src="` + html.EscapeString(`/thumb/m/`+childIDs[0]) + `"></a>
|
||||
<a href="` + html.EscapeString(`/p/`+strings.Join(childIDs, `-`)) + `"><img loading="lazy" src="` + html.EscapeString(albumThumb) + `"></a>
|
||||
<div class="thumbnail-overlay">` + fmt.Sprintf("%d", len(childIDs)) + ` image(s)</div>
|
||||
</div>
|
||||
<div class="properties">
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
package contented
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func (this *Server) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
|
||||
if this.opts.StorageType == STORAGE_LOCAL {
|
||||
fh, err := os.Open(filepath.Join(this.opts.DataDirectory, fileHash))
|
||||
return fh, err
|
||||
|
||||
} else if this.opts.StorageType == STORAGE_S3 {
|
||||
obj, err := this.s3client.GetObject(ctx, this.opts.DataS3Options.Bucket, this.opts.DataS3Options.Prefix+fileHash, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
|
||||
} else {
|
||||
panic("bad StorageType")
|
||||
}
|
||||
}
|
||||
|
||||
func (this *Server) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
|
||||
if this.opts.StorageType == STORAGE_LOCAL {
|
||||
|
||||
// Save file to disk
|
||||
dest, err := os.OpenFile(filepath.Join(this.opts.DataDirectory, fileHash), os.O_CREATE|os.O_WRONLY, this.opts.FileMode())
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil // hash matches existing upload
|
||||
}
|
||||
|
||||
return err // Real error
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
_, err = io.CopyN(dest, src, int64(srcLen))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
} else if this.opts.StorageType == STORAGE_S3 {
|
||||
_, err := this.s3client.PutObject(ctx, this.opts.DataS3Options.Bucket, this.opts.DataS3Options.Prefix+fileHash, src, srcLen, minio.PutObjectOptions{})
|
||||
return err
|
||||
|
||||
} else {
|
||||
panic("bad StorageType")
|
||||
}
|
||||
}
|
37
thumb.go
37
thumb.go
|
@ -4,8 +4,10 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"code.ivysaur.me/thumbnail"
|
||||
|
@ -94,12 +96,45 @@ func (this *Server) handleThumbInternal(ctx context.Context, w http.ResponseWrit
|
|||
return err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(this.opts.DataDirectory, m.FileHash)
|
||||
if m.FileSize > this.opts.MaxThumbSizeBytes {
|
||||
return errors.New("Don't want to thumbnail very large files, sorry")
|
||||
}
|
||||
|
||||
var filePath string
|
||||
|
||||
if this.opts.StorageType == STORAGE_LOCAL {
|
||||
filePath = filepath.Join(this.opts.DataDirectory, m.FileHash)
|
||||
|
||||
} else if this.opts.StorageType == STORAGE_S3 {
|
||||
// Need to temporarily download it for thumbnailing (slow and costs money)
|
||||
|
||||
destFh, err := os.CreateTemp("", "contented-thumbcache-*")
|
||||
defer os.Remove(destFh.Name())
|
||||
|
||||
srcFh, err := this.ReadFile(ctx, m.FileHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.CopyN(destFh, srcFh, m.FileSize)
|
||||
srcFh.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destFh.Seek(0, io.SeekStart)
|
||||
filePath = destFh.Name()
|
||||
|
||||
} else {
|
||||
panic("bad StorageType")
|
||||
}
|
||||
|
||||
thumb, err := t.RenderFileAs(filePath, m.MimeType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set(`Cache-Control`, `max-age=31536000, immutable`)
|
||||
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(thumb)))
|
||||
w.Header().Set(`Content-Type`, `image/jpeg`)
|
||||
w.WriteHeader(200)
|
||||
|
|
24
upload.go
24
upload.go
|
@ -1,6 +1,7 @@
|
|||
package contented
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
|
@ -9,9 +10,7 @@ import (
|
|||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
@ -94,27 +93,14 @@ func (this *Server) handleUploadFile(src multipart.File, hdr *multipart.FileHead
|
|||
return "", err
|
||||
}
|
||||
|
||||
// Save file to disk
|
||||
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
dest, err := os.OpenFile(filepath.Join(this.opts.DataDirectory, fileHash), os.O_CREATE|os.O_WRONLY, this.opts.FileMode())
|
||||
shouldSave := true
|
||||
if err != nil && os.IsExist(err) {
|
||||
// hash matches existing upload
|
||||
// That's fine - but still persist the metadata separately
|
||||
shouldSave = false
|
||||
} else if err != nil {
|
||||
|
||||
// Save file to disk/s3
|
||||
err = this.SaveFile(context.Background(), fileHash, srcLen, src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if shouldSave {
|
||||
defer dest.Close()
|
||||
|
||||
_, err = io.CopyN(dest, src, int64(srcLen))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Determine mime type
|
||||
ctype := hdr.Header.Get("Content-Type")
|
||||
if ctype == "" {
|
||||
|
|
Loading…
Reference in New Issue