38 Commits

Author SHA1 Message Date
ea9ef2e466 doc/README: changelog for v1.6.0 2025-08-20 16:03:05 +12:00
6b5b11fb5f deps update 2025-08-20 16:02:55 +12:00
156115d5f9 cmd/contented: allow both local+s3 on cli to use tiered storage 2025-08-20 15:56:21 +12:00
44af6efc87 storage: support new STORAGE_TIERED 2025-08-20 15:56:10 +12:00
9bd6e881b2 storage: refactor local/s3 to use an interface 2025-08-18 19:18:24 +12:00
bfd669bc96 doc: update changelog for v1.5.1 2023-05-20 14:33:21 +12:00
ea1309eb75 preview: support skipping over single missing images inside large albums 2023-05-20 13:50:46 +12:00
b146db9d0a preview: add minimal preview for albums with no images 2023-05-20 13:43:27 +12:00
0a22d1ca8a doc/README: update feature list 2023-05-19 19:39:58 +12:00
bc911327cf doc: update changelog for v1.5.0 2023-05-19 19:17:04 +12:00
e04900f672 gitignore: exclude for go build in contented-multi directory 2023-05-19 19:15:15 +12:00
d10026ae82 contented-multi: initial commit 2023-05-19 19:15:15 +12:00
36b4b124f7 s3 backend support 2023-05-19 19:15:15 +12:00
8d40031edc go: import minio-go library (Apache-2.0 license) 2023-05-19 19:15:15 +12:00
15c02efe96 preview: accept context parameter 2023-05-19 19:15:15 +12:00
01ff8f69aa preview: enable client-side caching for up to 1 year 2023-05-19 19:12:26 +12:00
3e5fb091c9 preview: add default block height for slow-loading thumbnails 2023-05-19 19:12:05 +12:00
caf521c318 contented: new option to cap the filesize for thumbnailing (default 20MiB) 2023-05-19 19:06:57 +12:00
77a4061cdd contented: move flag parsing, consts into block declaration 2023-05-19 19:06:07 +12:00
5ce06d6e6b doc/CHANGELOG: changelog for v1.4.0 2023-05-17 19:27:32 +12:00
0044d7dc77 doc/README: update for latest feature changes 2023-05-17 19:27:21 +12:00
6453700648 preview: better title element 2023-05-17 19:25:45 +12:00
7407353dfb albums: support custom album titles 2023-05-17 19:21:48 +12:00
660038b897 doc/README: update list of endpoints, SDK usage 2023-05-17 19:09:36 +12:00
6cef907cf9 doc/README: alphabetical ordering of options 2023-05-17 19:08:58 +12:00
b959f30882 preview: use browser-side lazy loading for very large galleries 2023-05-17 19:08:36 +12:00
64b900d90c server: add readonly mode to block uploads 2023-05-17 19:08:24 +12:00
e26a3b58b0 upload: return error if the short IDs collide for whatever reason 2023-05-17 18:54:30 +12:00
4294738337 albums: basic support (json array using contented/album mime-type) 2023-05-17 18:52:13 +12:00
156e2ab540 thumbnail: if the request is cancelled, don't wait for the semaphore 2023-05-17 18:18:02 +12:00
ad56309cb0 sdk: replace all the early-load logic with promises 2023-05-17 18:09:28 +12:00
f13618fef1 sdk: inline the widget html definition 2023-05-17 18:09:15 +12:00
e8dd95a830 sdk: drop polyfills, use jquery 3 2023-05-17 17:38:32 +12:00
bf3339fec7 sdk: flatten out some nested callbacks 2023-05-17 17:34:22 +12:00
cb29cf83ac server: replace go-bindata with stdlib embed.FS 2023-05-17 17:24:47 +12:00
ad95ac219d mod: declare go1.19 (debian bookworm) 2023-05-17 17:24:23 +12:00
11003e010d vendor: update bolt v1.3.1 -> etcd.io/bbolt v1.3.7 2023-05-17 17:24:00 +12:00
6b4c2cc208 makefile: remove win32 and src targets 2023-05-17 17:22:47 +12:00
20 changed files with 1304 additions and 1170 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
cmd/contented/contented cmd/contented/contented
cmd/contented-multi/contented-multi
build/ build/
_dist/ _dist/
contented.db contented.db

View File

@@ -21,22 +21,12 @@ all: build/linux64/contented build/win32/contented.exe
dist: \ dist: \
_dist/contented-$(VERSION)-linux64.tar.gz \ _dist/contented-$(VERSION)-linux64.tar.gz \
_dist/contented-$(VERSION)-win32.7z \
_dist/contented-$(VERSION)-src.zip _dist/contented-$(VERSION)-src.zip
clean: clean:
if [ -f ./staticResources.go ] ; then rm ./staticResources.go ; fi
if [ -d ./build ] ; then rm -r ./build ; fi if [ -d ./build ] ; then rm -r ./build ; fi
if [ -f ./contented ] ; then rm ./contented ; fi if [ -f ./contented ] ; then rm ./contented ; fi
#
# Generated files
#
staticResources.go: static/ static/*
go-bindata -o staticResources.go -prefix static -pkg contented static
# #
# Release artefacts # Release artefacts
# #
@@ -48,26 +38,6 @@ build/linux64/contented: $(SOURCES) staticResources.go
go build $(GOFLAGS) -o ../../build/linux64/contented \ go build $(GOFLAGS) -o ../../build/linux64/contented \
) )
build/win32/contented.exe: $(SOURCES) staticResources.go
mkdir -p build/win32
(cd cmd/contented ; \
PATH=/usr/lib/mxe/usr/bin:$(PATH) CC=i686-w64-mingw32.static-gcc \
CGO_ENABLED=1 GOOS=windows GOARCH=386 \
go build $(GOFLAGS) -o ../../build/win32/contented.exe \
)
_dist/contented-$(VERSION)-linux64.tar.gz: build/linux64/contented _dist/contented-$(VERSION)-linux64.tar.gz: build/linux64/contented
mkdir -p _dist mkdir -p _dist
tar caf _dist/contented-$(VERSION)-linux64.tar.gz -C build/linux64 contented --owner=0 --group=0 tar caf _dist/contented-$(VERSION)-linux64.tar.gz -C build/linux64 contented --owner=0 --group=0
_dist/contented-$(VERSION)-win32.7z: build/win32/contented.exe
mkdir -p _dist
( cd build/win32 ; \
if [ -f dist.7z ] ; then rm dist.7z ; fi ; \
7z a dist.7z contented.exe ; \
mv dist.7z ../../_dist/contented-$(VERSION)-win32.7z \
)
_dist/contented-$(VERSION)-src.zip: $(SOURCES)
git archive HEAD -o _dist/contented-$(VERSION)-src.zip

View File

@@ -3,13 +3,14 @@ package contented
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/boltdb/bolt" "github.com/speps/go-hashids/v2"
"github.com/speps/go-hashids" bolt "go.etcd.io/bbolt"
) )
const ( const (
@@ -47,7 +48,11 @@ func idToString(v uint64) string {
hd := hashids.NewData() hd := hashids.NewData()
hd.Salt = hashIdSalt hd.Salt = hashIdSalt
hd.MinLength = hashIdMinLength hd.MinLength = hashIdMinLength
h := hashids.NewWithData(hd) h, err := hashids.NewWithData(hd)
if err != nil {
panic(err) // developer error
}
s, _ := h.EncodeInt64([]int64{int64(v)}) s, _ := h.EncodeInt64([]int64{int64(v)})
return s return s
} }
@@ -62,9 +67,17 @@ func (this *Server) AddMetadata(m Metadata) (string, error) {
err = this.db.Update(func(tx *bolt.Tx) error { err = this.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(this.metadataBucket) b := tx.Bucket(this.metadataBucket)
seq, _ := b.NextSequence() // cannot fail seq, err := b.NextSequence()
if err != nil {
return fmt.Errorf("NextSequence: %w", err)
}
shortRef = idToString(seq) shortRef = idToString(seq)
return tx.Bucket(this.metadataBucket).Put([]byte(shortRef), jb)
if b.Get([]byte(shortRef)) != nil {
return fmt.Errorf("Next bucket sequence %d creates colliding ID %s", seq, shortRef)
}
return b.Put([]byte(shortRef), jb)
}) })
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -6,34 +6,44 @@ A file / image / paste upload server with a focus on embedding.
You can use contented as a standalone upload server, or you can use the SDK to embed its upload widget into another website. You can use contented as a standalone upload server, or you can use the SDK to embed its upload widget into another website.
The name is a pun on "content" and the -d suffix for server daemons.
## Features ## Features
- Use local disk or S3-backed storage
- Optional hot/cold storage tiering
- Drag and drop upload - Drag and drop upload
- Multiple files upload - Multiple files upload
- Pastebin upload - Pastebin upload
- Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js)) - Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js))
- Ctrl-V upload - Ctrl-V upload
- Galleries and nested galleries
- SDK-oriented design for embedding, including CORS support - SDK-oriented design for embedding, including CORS support
- Mobile friendly HTML interface - Mobile friendly HTML interface
- Preserves uploaded filename and content-type metadata - Preserves uploaded filename and content-type metadata
- Hash verification (SHA512/256) - Hash verification (SHA512/256)
- Detect duplicate upload content and reuse storage - 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) - Short URLs (using [Hashids](http://hashids.org) algorithm)
- Image thumbnailing - Image thumbnailing
- Optional multi-tenant binary (`contented-multi`)
## Usage (Server) ## Usage (Server)
``` ```
Usage of contented: Usage of contented:
-concurrentthumbs int
Simultaneous thumbnail generation (default 16)
-data string -data string
Directory for stored content (default "") Directory for stored content (default ".")
-db string -db string
Path for metadata database (default "contented.db") Path for metadata database (default "contented.db")
-diskFilesWorldReadable -diskFilesWorldReadable
Save files as 0644 instead of 0600 Save files as 0644 instead of 0600
-enableHomepage -enableHomepage
Enable homepage (disable for embedded use only) (default true) Enable homepage (disable for embedded use only) (default true)
-enableUpload
Enable uploads (disable for read-only mode) (default true)
-listen string -listen string
IP/Port to bind server (default "127.0.0.1:80") IP/Port to bind server (default "127.0.0.1:80")
-max int -max int
@@ -44,8 +54,6 @@ Usage of contented:
Title used in web interface (default "contented") Title used in web interface (default "contented")
-trustXForwardedFor -trustXForwardedFor
Trust X-Forwarded-For reverse proxy headers Trust X-Forwarded-For reverse proxy headers
-concurrentthumbs
Simultaneous thumbnail generation (default 16)
``` ```
If you are hosting behind a reverse proxy, remember to set its post body size parameter appropriately (e.g. `client_max_body_size` for nginx). If you are hosting behind a reverse proxy, remember to set its post body size parameter appropriately (e.g. `client_max_body_size` for nginx).
@@ -57,21 +65,52 @@ The server responds on the following URLs:
URL |Method |Description URL |Method |Description
---------------------|-------|--- ---------------------|-------|---
`/get/{ID}` |`GET` |Download item content `/get/{ID}` |`GET` |Download item content
`/p/{ID}` |`GET` |Preview item content (HTML)
`/p/{ID}-{ID}-...` |`GET` |Preview multiple item content (HTML)
`/info/{ID}` |`GET` |Get item content metadata (JSON) `/info/{ID}` |`GET` |Get item content metadata (JSON)
`/thumb/{Type}/{ID}` |`GET` |Get item thumbnail image `/thumb/{Type}/{ID}` |`GET` |Get item thumbnail image (JPEG). "Type" should match `[sbtmlh]`.
`/about` |`GET` |Get server metadata (JSON) `/about` |`GET` |Get server metadata (JSON)
## Usage (Embedding for web) ## Usage (Embedding for web)
Your webpage should load the SDK from the contented server, then call the `contented.init` function to display the upload widget over the top of an existing DOM element. Your callback will be passed an array of file IDs of any uploaded items. Your webpage should load the SDK from the contented server, then call the `contented.init` function to display the upload widget over the top of an existing DOM element:
```html ```html
<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script> <script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
contented.init("#target", function(/* String[] */ items) {}); contented.init("#target");
``` ```
You can optionally supply additional ordered parameters to `contented.init`:
1. A callback, that will be passed an array of file IDs of any uploaded items
2. A callback, that will be called if the SDK widget is closed
## Changelog ## Changelog
2025-08-20: v1.6.0
- Support hot/cold tiered storage to move files between local path and S3 bucket
- Upgrade all dependencies
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
- Feature: Support readonly mode
- Enhancement: Use lazy-loading for large image galleries
- Enhancement: Better tab titles on preview pages
- Fix an issue with continuing server-side thumbnailing work even if the http client has gone away
- Fix an issue with not warning on colliding hashIDs
- Internal: Refactor the SDK's initialization phase
- Internal: Update bolt library dependency
2020-07-25: 1.3.1 2020-07-25: 1.3.1
- Fix an issue with dependencies causing failure to compile in Modules mode - Fix an issue with dependencies causing failure to compile in Modules mode

View File

@@ -1,22 +1,35 @@
package contented package contented
import ( import (
"bytes" "embed"
"encoding/json" "encoding/json"
"fmt"
"io/fs"
"log" "log"
"net/http" "net/http"
"os"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/boltdb/bolt"
"github.com/mxk/go-flowrate/flowrate" "github.com/mxk/go-flowrate/flowrate"
bolt "go.etcd.io/bbolt"
) )
//go:embed static
var staticAssets embed.FS
var SERVER_HEADER string = `contented/0.0.0-dev` 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
ALBUM_MIMETYPE = `contented/album`
STORAGE_LOCAL int = 0
STORAGE_S3 int = 1
STORAGE_TIERED int = 2
)
type ServerPublicProperties struct { type ServerPublicProperties struct {
AppTitle string AppTitle string
@@ -24,31 +37,37 @@ type ServerPublicProperties struct {
CanonicalBaseURL string CanonicalBaseURL string
} }
type ServerS3StorageOptions struct {
Hostname string
AccessKey string
SecretKey string
Bucket string
Prefix string
}
type ServerOptions struct { type ServerOptions struct {
StorageType int // STORAGE_xx
DataDirectory string DataDirectory string
DataS3Options ServerS3StorageOptions
DBPath string DBPath string
DiskFilesWorldReadable bool DiskFilesWorldReadable bool
BandwidthLimit int64 BandwidthLimit int64
TrustXForwardedFor bool TrustXForwardedFor bool
EnableHomepage bool EnableHomepage bool
EnableUpload bool
MaxConcurrentThumbs int MaxConcurrentThumbs int
MaxThumbSizeBytes int64
ServerPublicProperties ServerPublicProperties
} }
func (this *ServerOptions) FileMode() os.FileMode {
if this.DiskFilesWorldReadable {
return 0644
} else {
return 0600
}
}
type Server struct { type Server struct {
opts ServerOptions opts ServerOptions
db *bolt.DB db *bolt.DB
startTime time.Time startTime time.Time
thumbnailSem chan struct{} thumbnailSem chan struct{}
metadataBucket []byte metadataBucket []byte
staticDir fs.FS // interface
store Storage
} }
func NewServer(opts *ServerOptions) (*Server, error) { func NewServer(opts *ServerOptions) (*Server, error) {
@@ -63,6 +82,40 @@ func NewServer(opts *ServerOptions) (*Server, error) {
log.Printf("Allowing %d concurrent thumbnails", s.opts.MaxConcurrentThumbs) 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
var err error = nil
switch s.opts.StorageType {
case STORAGE_S3:
s.store, err = NewS3Storage(s.opts.DataS3Options)
if err != nil {
return nil, err
}
case STORAGE_LOCAL:
s.store = NewLocalStorage(s.opts.DataDirectory, s.opts.DiskFilesWorldReadable)
case STORAGE_TIERED:
coldStore, err := NewS3Storage(s.opts.DataS3Options)
if err != nil {
return nil, err
}
s.store = NewTieredStorage(
NewLocalStorage(s.opts.DataDirectory, s.opts.DiskFilesWorldReadable),
coldStore,
)
default:
return nil, fmt.Errorf("Invalid storage type %d", s.opts.StorageType)
}
// "fill" the thumbnailer semaphore // "fill" the thumbnailer semaphore
s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs) s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs)
for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 { for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 {
@@ -141,7 +194,7 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):]) this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):])
} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) { } 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) { } else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) {
parts := rxThumbUrl.FindStringSubmatch(r.URL.Path) parts := rxThumbUrl.FindStringSubmatch(r.URL.Path)
@@ -157,11 +210,17 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Blanket allow (headers already set) // Blanket allow (headers already set)
w.WriteHeader(200) w.WriteHeader(200)
} else if r.Method == "GET" && r.URL.Path == `/` && this.opts.EnableHomepage { } else if r.Method == "GET" {
http.Redirect(w, r, `/index.html`, http.StatusFound)
} else if static, err := Asset(r.URL.Path[1:]); err == nil && r.Method == "GET" && (this.opts.EnableHomepage || r.URL.Path != `/index.html`) { // Conditionally block homepage access
http.ServeContent(w, r, r.URL.Path[1:], this.startTime, bytes.NewReader(static)) if !this.opts.EnableHomepage && (r.URL.Path == `/index.html` || r.URL.Path == `/`) {
http.Error(w, "Not found", 404)
return
}
// Serve static html/css/js assets
// http.FileServer transparently redirects index.html->/ internally
http.FileServer(http.FS(this.staticDir)).ServeHTTP(w, r)
} else { } else {
http.Error(w, "Not found", 404) http.Error(w, "Not found", 404)

View File

@@ -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()
}

View File

@@ -12,32 +12,66 @@ import (
func main() { func main() {
cwd, _ := os.Getwd() cwd, _ := os.Getwd()
listenAddr := flag.String("listen", "127.0.0.1:80", "IP/Port to bind server") var (
dataDir := flag.String("data", cwd, "Directory for stored content") listenAddr = flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
dbPath := flag.String("db", "contented.db", "Path for metadata database") dataDir = flag.String("data", cwd, "Directory for stored content")
appTitle := flag.String("title", "contented", "Title used in web interface") dbPath = flag.String("db", "contented.db", "Path for metadata database")
maxUploadMb := flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)") appTitle = flag.String("title", "contented", "Title used in web interface")
maxUploadSpeed := flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)") maxUploadMb = flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
trustXForwardedFor := flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers") maxUploadSpeed = flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
enableHomepage := flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)") trustXForwardedFor = flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
diskFilesWorldReadable := flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600") enableHomepage = flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
maxConcurrentThumbs := flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation") 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() flag.Parse()
svr, err := contented.NewServer(&contented.ServerOptions{ opts := contented.ServerOptions{
DataDirectory: *dataDir,
DBPath: *dbPath, DBPath: *dbPath,
BandwidthLimit: int64(*maxUploadSpeed), BandwidthLimit: int64(*maxUploadSpeed),
TrustXForwardedFor: *trustXForwardedFor, TrustXForwardedFor: *trustXForwardedFor,
EnableHomepage: *enableHomepage, EnableHomepage: *enableHomepage,
EnableUpload: *enableUpload,
DiskFilesWorldReadable: *diskFilesWorldReadable, DiskFilesWorldReadable: *diskFilesWorldReadable,
MaxConcurrentThumbs: *maxConcurrentThumbs, MaxConcurrentThumbs: *maxConcurrentThumbs,
ServerPublicProperties: contented.ServerPublicProperties{ ServerPublicProperties: contented.ServerPublicProperties{
AppTitle: *appTitle, AppTitle: *appTitle,
MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024, MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
}, },
}) }
// s3 or tiered storage
opts.DataS3Options.Hostname = *s3Host
opts.DataS3Options.AccessKey = *s3AccessKey
opts.DataS3Options.SecretKey = *s3SecretKey
opts.DataS3Options.Bucket = *s3Bucket
opts.DataS3Options.Prefix = *s3Prefix
// local or tiered storage
opts.DataDirectory = *dataDir
if len(*dataDir) > 0 && len(*s3AccessKey) > 0 {
opts.StorageType = contented.STORAGE_TIERED
} else if len(*s3AccessKey) > 0 {
opts.StorageType = contented.STORAGE_S3
} else if len(*dataDir) > 0 {
opts.StorageType = contented.STORAGE_LOCAL
} else {
log.Println("Please specify either the -data or -s3__ options.")
os.Exit(1)
}
svr, err := contented.NewServer(&opts)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
os.Exit(1) os.Exit(1)

View File

@@ -4,7 +4,6 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
) )
func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) { 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 // Load file
f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash)) f, err := this.store.ReadFile(r.Context(), m.FileHash)
if err != nil { if err != nil {
return err return err
} }
@@ -50,7 +49,18 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
w.Header().Set(`Content-Type`, m.MimeType) 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
return nil
} }

31
go.mod
View File

@@ -2,10 +2,33 @@ module code.ivysaur.me/contented
require ( require (
code.ivysaur.me/thumbnail v1.0.2 code.ivysaur.me/thumbnail v1.0.2
github.com/boltdb/bolt v1.3.1 github.com/minio/minio-go/v7 v7.0.95
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
github.com/speps/go-hashids v1.0.0 github.com/speps/go-hashids/v2 v2.0.1
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b // indirect go.etcd.io/bbolt v1.4.3
) )
go 1.13 require (
code.ivysaur.me/imagequant/v2 v2.12.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/image v0.30.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
go 1.23.0
toolchain go1.24.4

59
go.sum
View File

@@ -2,16 +2,59 @@ code.ivysaur.me/imagequant/v2 v2.12.6 h1:xYrGj6GOdAcutmzqBxG7bDZ70r4jYHADOCZ+kty
code.ivysaur.me/imagequant/v2 v2.12.6/go.mod h1:seCAm0sP2IBsb1YNBj4D+EZovIuGe16+6Xo0aiGyhDU= code.ivysaur.me/imagequant/v2 v2.12.6/go.mod h1:seCAm0sP2IBsb1YNBj4D+EZovIuGe16+6Xo0aiGyhDU=
code.ivysaur.me/thumbnail v1.0.2 h1:vQaRPbBZOUGpr4b5rrUOHiZv08XSRJ83uu64WXFx7mo= code.ivysaur.me/thumbnail v1.0.2 h1:vQaRPbBZOUGpr4b5rrUOHiZv08XSRJ83uu64WXFx7mo=
code.ivysaur.me/thumbnail v1.0.2/go.mod h1:sXeHBfmPfiSe5ZBKsbGSES13C9OSZq0WmT4yZ/XBeeE= code.ivysaur.me/thumbnail v1.0.2/go.mod h1:sXeHBfmPfiSe5ZBKsbGSES13C9OSZq0WmT4yZ/XBeeE=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= 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/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
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.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= 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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/speps/go-hashids v1.0.0 h1:jdFC07PrExRM4Og5Ev4411Tox75aFpkC77NlmutadNI= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/speps/go-hashids v1.0.0/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/speps/go-hashids/v2 v2.0.1 h1:ViWOEqWES/pdOSq+C1SLVa8/Tnsd52XC34RY7lt7m4g=
github.com/speps/go-hashids/v2 v2.0.1/go.mod h1:47LKunwvDZki/uRVD6NImtyk712yFzIs3UF3KlHohGw=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b h1:5rOiLYVqtE+JehJPVJTXQJaP8aT3cpJC1Iy22+5WLFU= golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,6 +1,8 @@
package contented package contented
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"html" "html"
"log" "log"
@@ -10,14 +12,34 @@ import (
"time" "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, `-`) fileIDs := strings.Split(fileIDList, `-`)
// Early get metadata for the first listed element
specialTitle := ""
if len(fileIDs) == 1 {
mFirst, err := this.Metadata(fileIDs[0])
if err != nil { // Same error handling as below -
if os.IsNotExist(err) {
http.Error(w, "Not found", 404)
return
}
log.Println(err.Error())
http.Error(w, "Internal error", 500)
return
}
specialTitle = mFirst.Filename + " (" + fileIDs[0] + ")"
} else {
specialTitle = fmt.Sprintf("%d images", len(fileIDs))
}
tmpl := `<!DOCTYPE html> tmpl := `<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#"> <html prefix="og: http://ogp.me/ns#">
<head> <head>
<title>` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `</title> <title>` + html.EscapeString(specialTitle+" | "+this.opts.ServerPublicProperties.AppTitle) + `</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content="` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `" /> <meta property="og:title" content="` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `" />
<meta property="og:site_name" content="` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `" /> <meta property="og:site_name" content="` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `" />
@@ -55,7 +77,21 @@ html, body {
.thumbnail { .thumbnail {
line-height: 0; line-height: 0;
width: 340px; width: 340px;
height: 340px;
text-align: center; text-align: center;
position: relative;
}
.thumbnail-overlay {
position: absolute;
bottom: 4px;
right: 4px;
padding: 4px 8px;
line-height: 1.5em;
pointer-events: none;
background: red;
color: white;
} }
.properties { .properties {
background: #000; background: #000;
@@ -73,19 +109,79 @@ html, body {
m, err := this.Metadata(fileID) m, err := this.Metadata(fileID)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// 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) http.Error(w, "Not found", 404)
return 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()) log.Println(err.Error())
http.Error(w, "Internal error", 500) http.Error(w, "Internal error", 500)
return return
} }
if m.MimeType == ALBUM_MIMETYPE {
// Special handling for albums
f, err := this.store.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)
return
}
var childIDs []string
err = json.NewDecoder(f).Decode(&childIDs)
f.Close()
if err != nil {
log.Printf("Failed to parse album '%s': %s", fileID, err)
http.Error(w, "Internal error", 500)
return
}
albumThumb := `/nothumb_340.png`
if len(childIDs) > 0 {
albumThumb = `/thumb/m/` + childIDs[0]
}
tmpl += ` tmpl += `
<div class="entry"> <div class="entry">
<div class="thumbnail"> <div class="thumbnail">
<a href="` + html.EscapeString(`/get/`+fileID) + `"><img src="` + html.EscapeString(`/thumb/m/`+fileID) + `"></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">
<b>Name:</b> ` + html.EscapeString(m.Filename) + `<br>
<b>Hash:</b> <span title="` + html.EscapeString(m.FileHash) + `">hover</span><br>
<b>File type:</b> Album<br>
<b>Size:</b> ` + fmt.Sprintf("%d", len(childIDs)) + ` image(s)<br>
<b>Uploader:</b> ` + html.EscapeString(m.UploadIP) + `<br>
<b>Uploaded at:</b> ` + html.EscapeString(m.UploadTime.Format(time.RFC3339)) + `<br>
</div>
</div>
`
} else {
tmpl += `
<div class="entry">
<div class="thumbnail">
<a href="` + html.EscapeString(`/get/`+fileID) + `"><img loading="lazy" src="` + html.EscapeString(`/thumb/m/`+fileID) + `"></a>
</div> </div>
<div class="properties"> <div class="properties">
<b>Name:</b> ` + html.EscapeString(m.Filename) + `<br> <b>Name:</b> ` + html.EscapeString(m.Filename) + `<br>
@@ -98,6 +194,7 @@ html, body {
</div> </div>
` `
} }
}
if this.opts.EnableHomepage { if this.opts.EnableHomepage {
tmpl += ` tmpl += `

View File

@@ -38,7 +38,7 @@ button.again {
</div> </div>
</div> </div>
<script type="text/javascript" src="/jquery-1.12.4.min.js"></script> <script type="text/javascript" src="/jquery-3.7.0.min.js"></script>
<script type="text/javascript" src="/sdk.js"></script> <script type="text/javascript" src="/sdk.js"></script>
<script type="text/javascript"> <script type="text/javascript">
"use strict"; "use strict";

File diff suppressed because one or more lines are too long

2
static/jquery-3.7.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,28 +1,6 @@
; ;(function() {
//
var contented = (function() {
"use strict"; "use strict";
// @ref https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
if (!HTMLCanvasElement.prototype.toBlob) {
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value: function (callback, type, quality) {
var binStr = atob( this.toDataURL(type, quality).split(',')[1] ),
len = binStr.length,
arr = new Uint8Array(len);
for (var i = 0; i < len; i++ ) {
arr[i] = binStr.charCodeAt(i);
}
callback( new Blob( [arr], {type: type || 'image/png'} ) );
}
});
}
var getCurrentScriptPath = function () { var getCurrentScriptPath = function () {
// Determine current script path // Determine current script path
// @ref https://stackoverflow.com/a/26023176 // @ref https://stackoverflow.com/a/26023176
@@ -34,87 +12,14 @@ var contented = (function() {
return currentScript.replace(currentScriptFile, ''); return currentScript.replace(currentScriptFile, '');
}; };
var currentScriptPath = getCurrentScriptPath(); var loadScript = function(url) {
var baseURL = currentScriptPath.replace('sdk.js', ''); return new Promise(function(resolve, reject) {
return {
"loaded": false,
"baseURL": baseURL,
"__preInit": [],
/**
* initArea shows the contented upload widget over the top of a target DOM element.
*
* @param any element Drop target (string selector / DOMElement / jQuery)
* @param Function onUploaded Called with an array of upload IDs
* @param Function onClose Called when the widget is being destroyed
*/
"init": function(elementSelector, onUploaded, onClose) {
contented.__preInit.push([elementSelector, onUploaded, onClose]);
},
/**
* supportsDrop returns whether drag-and-drop is supported by this browser.
*
* @return bool
*/
"supportsDrop": function() {
return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
},
"getPreviewURL": function(id) {
return baseURL + "p/" + encodeURIComponent(id);
},
"getMultiPreviewURL": function(items) {
return baseURL + "p/" + encodeURIComponent(items.join("-"));
},
"getDownloadURL": function(id) {
return baseURL + "get/" + encodeURIComponent(id);
},
"getInfoJSONURL": function(id) {
return baseURL + "info/" + encodeURIComponent(id);
},
"getThumbnailURL": function(thumbnailType, id) {
return baseURL + "thumb/" + encodeURIComponent(thumbnailType) + "/" + encodeURIComponent(id);
},
"thumbnail": {
"small_square": "s",
"medium_square": "b",
"medium": "t",
"large": "m",
"xlarge": "l",
"xxlarge": "h"
}
};
})();
;(function() {
"use strict";
var loadScript = function(url, onLoad) {
var script = document.createElement('script'); var script = document.createElement('script');
script.onload = onLoad; script.onload = resolve;
script.onerror = reject;
script.src = url; script.src = url;
document.head.appendChild(script); document.head.appendChild(script);
}; });
var loadScripts = function(urls, onLoad) {
// load sequentially
var i = 0;
var loadNext = function() {
if (i === urls.length) {
onLoad();
return;
}
var url = urls[i];
i += 1;
loadScript(url, loadNext);
};
loadNext();
}; };
var formatBytes = function(bytes) { var formatBytes = function(bytes) {
@@ -139,9 +44,184 @@ var contented = (function() {
}); });
}; };
var afterScriptsLoaded = function() { var widgetHtml = `
<style type="text/css">
.contented {
box-sizing:border-box;
text-align: center;
border: 8px dashed lightgrey;
padding: 12px;
background:white; /* not transparent */
var initArea = function (elementSelector, onUploaded, onClose) { text-overflow:hidden;
overflow:auto;
width:100%;
height:100%;
position:relative;
}
.contented .contented-close {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
cursor: pointer;
}
.contented .contented-upload-type-selector {
display:block;
margin-bottom: 1em;
-webkit-user-select: none;
user-select: none;
}
.contented .contented-upload-type {
display:inline-block;
opacity:0.2;
transition:opacity linear 0.1s;
cursor:pointer;
}
.contented .contented-upload-type:hover {
opacity:0.5;
transition:opacity linear 0s;
}
.contented .contented-upload-type svg {
width:36px;
height:36px;
}
.contented .contented-upload-type.contented-upload-type-active {
opacity:1;
}
.contented.is-dragging {
background: lightblue;
}
.contented-content-area {
position:absolute;
top: 60px;
bottom: 10px;
left: 10px;
width: calc(100% - 20px);
/* Prevent blur under translateY */
-webkit-transform-style: preserve-3d;
-moz-transform-style: preserve-3d;
transform-style: preserve-3d;
}
.contented-content-area > div {
position: relative;
top: 50%;
transform: translateY(-50%);
}
.contented-upload-if {
display:none;
}
.contented-if-paste, .contented-if-drawing {
height:100%;
}
.contented-upload-if.contented-active {
display:block;
}
.contented textarea {
resize: none;
width:100%;
height:calc(100% - 1em - 15px);
box-sizing:border-box;
}
.contented-progress-bar {
display: block;
width:90%;
margin:0.5em auto 0 auto;
height:16px;
border-radius:8px;
background:lightgrey;
position:relative;
overflow:hidden;
}
.contented-progress-element {
position:absolute;
background:darkgreen;
left:0;
width:0%;
height:100%;
}
</style>
<div class="contented">
<div class="contented-close">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</svg>
</div>
<div class="contented-upload-type-selector">
<div class="contented-upload-type contented-upload-type-active" data-upload-type="drag" title="Drag and drop">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path>
</svg>
</div>
<div class="contented-upload-type" data-upload-type="file" title="Multiple files">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"></path>
</svg>
</div>
<div class="contented-upload-type" data-upload-type="paste" title="Paste">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M19,20H5V4H7V7H17V4H19M12,2A1,1 0 0,1 13,3A1,1 0 0,1 12,4A1,1 0 0,1 11,3A1,1 0 0,1 12,2M19,2H14.82C14.4,0.84 13.3,0 12,0C10.7,0 9.6,0.84 9.18,2H5A2,2 0 0,0 3,4V20A2,2 0 0,0 5,22H19A2,2 0 0,0 21,20V4A2,2 0 0,0 19,2Z" />
</svg>
</div>
<div class="contented-upload-type" data-upload-type="drawing" title="Drawing">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M16.84,2.73C16.45,2.73 16.07,2.88 15.77,3.17L13.65,5.29L18.95,10.6L21.07,8.5C21.67,7.89 21.67,6.94 21.07,6.36L17.9,3.17C17.6,2.88 17.22,2.73 16.84,2.73M12.94,6L4.84,14.11L7.4,14.39L7.58,16.68L9.86,16.85L10.15,19.41L18.25,11.3M4.25,15.04L2.5,21.73L9.2,19.94L8.96,17.78L6.65,17.61L6.47,15.29" />
</svg>
</div>
<div class="contented-upload-type" data-upload-type="album" title="Album">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M3,3H21V7H3V3M4,8H20V21H4V8M9.5,11A0.5,0.5 0 0,0 9,11.5V13H15V11.5A0.5,0.5 0 0,0 14.5,11H9.5Z" />
</svg>
</div>
</div>
<div class="contented-content-area">
<div class="contented-upload-if contented-if-drag contented-active">
<label>Drop files or Ctrl-V to upload <span class="contented-extratext"></span></label>
</div>
<div class="contented-upload-if contented-if-file">
<label>Select files to upload <span class="contented-extratext"></span></label><br>
<input class="contented-file-selector" type="file" multiple>
<button class="contented-file-upload">Upload &raquo;</button>
</div>
<div class="contented-upload-if contented-if-paste">
<textarea placeholder="Paste content here"></textarea>
<button class="contented-paste-upload">Upload &raquo;</button>
</div>
<div class="contented-upload-if contented-if-drawing">
<div class="contented-drawing-area"></div>
</div>
<div class="contented-upload-if contented-if-progress">
<label>...</label>
<div class="contented-progress-bar"><div class="contented-progress-element"></div></div>
</div>
<div class="contented-upload-if contented-if-album">
<input type="text" class="contented-album-title" placeholder="Album name"><br>
<input type="text" class="contented-album-items" placeholder="Image IDs, separated with commas">
<br><br>
<button class="contented-album-upload">Make album &raquo;</button>
</div>
</div>
</div>
`;
var initArea = function (aboutInfo, elementSelector, onUploaded, onClose) {
onUploaded = onUploaded || function () { }; onUploaded = onUploaded || function () { };
onClose = onClose || function () { }; onClose = onClose || function () { };
@@ -153,15 +233,12 @@ var contented = (function() {
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" /> // <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
// Create a new div for ourselves on top of the existing area // Create a new div for ourselves on top of the existing area
$.get(contented.baseURL + "about", function (ret) {
var extraText = ""; var extraText = "";
if (ret.MaxUploadBytes > 0) { if (aboutInfo.MaxUploadBytes > 0) {
extraText = " (max " + formatBytes(ret.MaxUploadBytes) + ")"; extraText = " (max " + formatBytes(aboutInfo.MaxUploadBytes) + ")";
} }
$.get(contented.baseURL + "widget.html", function (widgetHtml) {
var $f = $("<div>").html(widgetHtml); var $f = $("<div>").html(widgetHtml);
$f.find(".contented-extratext").text(extraText); $f.find(".contented-extratext").text(extraText);
@@ -243,6 +320,37 @@ var contented = (function() {
handleUploadFrom([blob]); handleUploadFrom([blob]);
}); });
// Album
$f.find(".contented-album-upload").on("click", function(e) {
e.preventDefault();
e.stopPropagation();
var title = $(".contented-album-title").val();
if (title === "") {
title = "Untitled album";
}
var childIDs = $(".contented-album-items").val().split(",");
for (var i = 0; i < childIDs.length; ++i) {
childIDs[i] = childIDs[i].trim(); // Basic validation - can't really perform full validation here
if (childIDs[i].length == 0) {
alert("Entry " + (i+1) + " is too short, expected non-zero length");
return;
}
if (! childIDs[i].match(/^[a-zA-Z0-9]+$/)) {
alert("Entry " + (i+1) + " contains unexpected character");
return;
}
}
var blob = new Blob([JSON.stringify(childIDs)], {type : 'contented/album'});
handleUploadFrom([new File([blob], title, {type: 'contented/album'})]);
});
// Ctrl+V uploads // Ctrl+V uploads
var pasteHandler = function(e) { var pasteHandler = function(e) {
@@ -379,6 +487,10 @@ var contented = (function() {
// Common upload handler // Common upload handler
/**
*
* @param {File[]|Blob[]} files
*/
var handleUploadFrom = function(files) { var handleUploadFrom = function(files) {
setProgressCaption("Uploading, please wait..."); setProgressCaption("Uploading, please wait...");
@@ -432,33 +544,97 @@ var contented = (function() {
} }
// . };
}); var init = function() {
}); var currentScriptPath = getCurrentScriptPath();
var baseURL = currentScriptPath.replace('sdk.js', '');
// Kick off background promises
var loader = new Promise(function(resolve, reject) {
if (typeof jQuery === "undefined") {
loadScript(contented.baseURL + "jquery-3.7.0.min.js").then(resolve);
} else {
resolve();
} }
// Update fields in global variable }).then(function() { return new Promise(function(resolve, reject) {
contented.init = initArea; if (typeof DrawingBoard === "undefined") {
contented.loaded = true; loadScript(contented.baseURL + "drawingboard-0.4.6.min.js").then(resolve);
} else {
resolve();
}
// Call initArea for all pre-initialised elements })}).then(function() { return new Promise(function(resolve, reject) {
for (var i = 0; i < contented.__preInit.length; ++i) { $.get(contented.baseURL + "about", function (aboutInfo) {
initArea(contented.__preInit[i][0], contented.__preInit[i][1], contented.__preInit[i][2]); resolve(aboutInfo);
});
})}).then(function(aboutInfo) {
// Update fields in global variable
window.contented.loaded = true;
return aboutInfo;
});
window.contented = {
"loaded": false,
"baseURL": baseURL,
"__preInit": [],
/**
* initArea shows the contented upload widget over the top of a target DOM element.
*
* @param any element Drop target (string selector / DOMElement / jQuery)
* @param Function onUploaded Called with an array of upload IDs
* @param Function onClose Called when the widget is being destroyed
*/
"init": function(elementSelector, onUploaded, onClose) {
loader.then(function(aboutInfo) {
initArea(aboutInfo, elementSelector, onUploaded, onClose);
});
},
/**
* supportsDrop returns whether drag-and-drop is supported by this browser.
*
* @return bool
*/
"supportsDrop": function() {
return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
},
"getPreviewURL": function(id) {
return baseURL + "p/" + encodeURIComponent(id);
},
"getMultiPreviewURL": function(items) {
return baseURL + "p/" + encodeURIComponent(items.join("-"));
},
"getDownloadURL": function(id) {
return baseURL + "get/" + encodeURIComponent(id);
},
"getInfoJSONURL": function(id) {
return baseURL + "info/" + encodeURIComponent(id);
},
"getThumbnailURL": function(thumbnailType, id) {
return baseURL + "thumb/" + encodeURIComponent(thumbnailType) + "/" + encodeURIComponent(id);
},
"thumbnail": {
"small_square": "s",
"medium_square": "b",
"medium": "t",
"large": "m",
"xlarge": "l",
"xxlarge": "h"
} }
}; };
// Load scripts };
var needScripts = [];
if (typeof jQuery === "undefined") {
needScripts.push(contented.baseURL + "jquery-1.12.4.min.js");
}
if (typeof DrawingBoard === "undefined") {
needScripts.push(contented.baseURL + "drawingboard-0.4.6.min.js");
}
loadScripts(needScripts, afterScriptsLoaded); init();
})() })()

View File

@@ -1,161 +0,0 @@
<style type="text/css">
.contented {
box-sizing:border-box;
text-align: center;
border: 8px dashed lightgrey;
padding: 12px;
background:white; /* not transparent */
text-overflow:hidden;
overflow:auto;
width:100%;
height:100%;
position:relative;
}
.contented .contented-close {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
cursor: pointer;
}
.contented .contented-upload-type-selector {
display:block;
margin-bottom: 1em;
-webkit-user-select: none;
user-select: none;
}
.contented .contented-upload-type {
display:inline-block;
opacity:0.2;
transition:opacity linear 0.1s;
cursor:pointer;
}
.contented .contented-upload-type:hover {
opacity:0.5;
transition:opacity linear 0s;
}
.contented .contented-upload-type svg {
width:36px;
height:36px;
}
.contented .contented-upload-type.contented-upload-type-active {
opacity:1;
}
.contented.is-dragging {
background: lightblue;
}
.contented-content-area {
position:absolute;
top: 60px;
bottom: 10px;
left: 10px;
width: calc(100% - 20px);
/* Prevent blur under translateY */
-webkit-transform-style: preserve-3d;
-moz-transform-style: preserve-3d;
transform-style: preserve-3d;
}
.contented-content-area > div {
position: relative;
top: 50%;
transform: translateY(-50%);
}
.contented-upload-if {
display:none;
}
.contented-if-paste, .contented-if-drawing {
height:100%;
}
.contented-upload-if.contented-active {
display:block;
}
.contented textarea {
resize: none;
width:100%;
height:calc(100% - 1em - 15px);
box-sizing:border-box;
}
.contented-progress-bar {
display: block;
width:90%;
margin:0.5em auto 0 auto;
height:16px;
border-radius:8px;
background:lightgrey;
position:relative;
overflow:hidden;
}
.contented-progress-element {
position:absolute;
background:darkgreen;
left:0;
width:0%;
height:100%;
}
</style>
<div class="contented">
<div class="contented-close">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</svg>
</div>
<div class="contented-upload-type-selector">
<div class="contented-upload-type contented-upload-type-active" data-upload-type="drag" title="Drag and drop">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path>
</svg>
</div>
<div class="contented-upload-type" data-upload-type="file" title="Multiple files">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"></path>
</svg>
</div>
<div class="contented-upload-type" data-upload-type="paste" title="Paste">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M19,20H5V4H7V7H17V4H19M12,2A1,1 0 0,1 13,3A1,1 0 0,1 12,4A1,1 0 0,1 11,3A1,1 0 0,1 12,2M19,2H14.82C14.4,0.84 13.3,0 12,0C10.7,0 9.6,0.84 9.18,2H5A2,2 0 0,0 3,4V20A2,2 0 0,0 5,22H19A2,2 0 0,0 21,20V4A2,2 0 0,0 19,2Z" />
</svg>
</div>
<div class="contented-upload-type" data-upload-type="drawing" title="Drawing">
<svg viewBox="0 0 24 24">
<path fill="#000000" d="M16.84,2.73C16.45,2.73 16.07,2.88 15.77,3.17L13.65,5.29L18.95,10.6L21.07,8.5C21.67,7.89 21.67,6.94 21.07,6.36L17.9,3.17C17.6,2.88 17.22,2.73 16.84,2.73M12.94,6L4.84,14.11L7.4,14.39L7.58,16.68L9.86,16.85L10.15,19.41L18.25,11.3M4.25,15.04L2.5,21.73L9.2,19.94L8.96,17.78L6.65,17.61L6.47,15.29" />
</svg>
</div>
</div>
<div class="contented-content-area">
<div class="contented-upload-if contented-if-drag contented-active">
<label>Drop files or Ctrl-V to upload <span class="contented-extratext"></span></label>
</div>
<div class="contented-upload-if contented-if-file">
<label>Select files to upload <span class="contented-extratext"></span></label><br>
<input class="contented-file-selector" type="file" multiple>
<button class="contented-file-upload">Upload &raquo;</button>
</div>
<div class="contented-upload-if contented-if-paste">
<textarea placeholder="Paste content here"></textarea>
<button class="contented-paste-upload">Upload &raquo;</button>
</div>
<div class="contented-upload-if contented-if-drawing">
<div class="contented-drawing-area"></div>
</div>
<div class="contented-upload-if contented-if-progress">
<label>...</label>
<div class="contented-progress-bar"><div class="contented-progress-element"></div></div>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

209
storage.go Normal file
View File

@@ -0,0 +1,209 @@
package contented
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Storage interface {
ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error)
SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error
}
//
type localStorage struct {
dataDir string
fileMode os.FileMode
}
func NewLocalStorage(dataDir string, worldReadable bool) *localStorage {
ls := &localStorage{
dataDir: dataDir,
fileMode: 0600,
}
if worldReadable {
ls.fileMode = 0644
}
return ls
}
func (ls *localStorage) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
fh, err := os.Open(filepath.Join(ls.dataDir, fileHash))
return fh, err
}
func (ls *localStorage) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
// Save file to disk
dest, err := os.OpenFile(filepath.Join(ls.dataDir, fileHash), os.O_CREATE|os.O_WRONLY, ls.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
}
var _ Storage = &localStorage{} // interface assertion
//
type s3Storage struct {
s3client *minio.Client
ServerS3StorageOptions
}
func NewS3Storage(opts ServerS3StorageOptions) (*s3Storage, error) {
cl, err := minio.New(opts.Hostname, &minio.Options{
Creds: credentials.NewStaticV4(opts.AccessKey, opts.SecretKey, ""),
Secure: true,
})
if err != nil {
return nil, fmt.Errorf("Connecting to S3 host: %w", err)
}
return &s3Storage{
s3client: cl,
ServerS3StorageOptions: opts,
}, nil
}
func (ss *s3Storage) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
obj, err := ss.s3client.GetObject(ctx, ss.Bucket, ss.Prefix+fileHash, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
return obj, nil
}
func (ss *s3Storage) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
_, err := ss.s3client.PutObject(ctx, ss.Bucket, ss.Prefix+fileHash, src, srcLen, minio.PutObjectOptions{})
return err
}
var _ Storage = &s3Storage{} // interface assertion
//
const (
TierMigrationAfter = 14 * 24 * time.Hour // 14 days
TierMigrationEvery = 4 * time.Hour // 4 hours
TierMigrationDelayStartup = 60 * time.Second
)
type tieredStorage struct {
hot *localStorage
cold *s3Storage
}
func NewTieredStorage(hot *localStorage, cold *s3Storage) *tieredStorage {
ts := &tieredStorage{
hot: hot,
cold: cold,
}
go ts.migrationWorker()
return ts
}
// migrationWorker is a background goroutine to trigger tier migrations.
func (ts *tieredStorage) migrationWorker() {
// Startup delay
time.Sleep(TierMigrationDelayStartup)
// Worker loop
for {
err := ts.migrateNow()
if err != nil {
log.Printf("tier-migration: %v", err)
}
time.Sleep(TierMigrationEvery)
}
}
// migrateNow performs a tier migration for old files.
func (ts *tieredStorage) migrateNow() error {
// List local files
dirents, err := os.ReadDir(ts.hot.dataDir)
if err != nil {
return err
}
cutOff := time.Now().Add(-TierMigrationAfter)
for _, dirent := range dirents {
fi, err := dirent.Info()
if err != nil {
return err // local files can't be stat'd = important error
}
if !fi.ModTime().After(cutOff) {
continue // not eligible
}
fileHash := dirent.Name()
// Copy to cold storage
// Any concurrent reads will be serviced from the hot storage, so this
// is a safe operation
rc, err := ts.cold.ReadFile(context.Background(), fileHash)
if err != nil {
return err // can't cat local file
}
err = ts.cold.SaveFile(context.Background(), fileHash, fi.Size(), rc)
_ = rc.Close()
if err != nil {
return err // can't save local file
}
// Copy was successful. Delete local file
err = os.Remove(filepath.Join(ts.hot.dataDir, fileHash))
if err != nil {
return err // can't rm local file
}
}
// Migrated everything we can for now
return nil
}
func (ts *tieredStorage) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
if rc, err := ts.hot.ReadFile(ctx, fileHash); err == nil {
return rc, nil
}
return ts.cold.ReadFile(ctx, fileHash)
}
func (ts *tieredStorage) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
return ts.hot.SaveFile(ctx, fileHash, srcLen, src)
}
var _ Storage = &tieredStorage{} // interface assertion

View File

@@ -4,8 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"code.ivysaur.me/thumbnail" "code.ivysaur.me/thumbnail"
@@ -62,7 +64,12 @@ func (this *Server) handleThumb(w http.ResponseWriter, r *http.Request, thumbnai
} }
// Only a limited number of thumbnails can be generated concurrently // Only a limited number of thumbnails can be generated concurrently
<-this.thumbnailSem select {
case <-this.thumbnailSem:
case <-r.Context().Done():
http.Error(w, r.Context().Err().Error(), 400) // probably won't be delivered anyway
return
}
defer func() { this.thumbnailSem <- struct{}{} }() defer func() { this.thumbnailSem <- struct{}{} }()
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -89,12 +96,45 @@ func (this *Server) handleThumbInternal(ctx context.Context, w http.ResponseWrit
return err 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.store.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) thumb, err := t.RenderFileAs(filePath, m.MimeType)
if err != nil { if err != nil {
return err 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-Length`, fmt.Sprintf("%d", len(thumb)))
w.Header().Set(`Content-Type`, `image/jpeg`) w.Header().Set(`Content-Type`, `image/jpeg`)
w.WriteHeader(200) w.WriteHeader(200)

View File

@@ -1,6 +1,7 @@
package contented package contented
import ( import (
"context"
"crypto/sha512" "crypto/sha512"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -9,15 +10,18 @@ import (
"mime" "mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os"
"path" "path"
"path/filepath"
"strings" "strings"
"time" "time"
) )
func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) { func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if !this.opts.EnableUpload {
http.Error(w, "Server is read-only", 403)
return
}
remoteIP := this.remoteIP(r) remoteIP := this.remoteIP(r)
err := r.ParseMultipartForm(0) // buffer upload in temporary files on disk, not memory err := r.ParseMultipartForm(0) // buffer upload in temporary files on disk, not memory
@@ -89,26 +93,13 @@ func (this *Server) handleUploadFile(src multipart.File, hdr *multipart.FileHead
return "", err return "", err
} }
// Save file to disk
fileHash := hex.EncodeToString(hasher.Sum(nil)) 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 {
return "", err
}
if shouldSave { // Save file to disk/s3
defer dest.Close() err = this.store.SaveFile(context.Background(), fileHash, srcLen, src)
_, err = io.CopyN(dest, src, int64(srcLen))
if err != nil { if err != nil {
return "", err return "", err
} }
}
// Determine mime type // Determine mime type
ctype := hdr.Header.Get("Content-Type") ctype := hdr.Header.Get("Content-Type")