Compare commits

..

No commits in common. "master" and "v1.1.0-rc1" have entirely different histories.

30 changed files with 1116 additions and 1530 deletions

View File

@ -1,5 +1,6 @@
syntax: glob
cmd/contented/contented cmd/contented/contented
cmd/contented-multi/contented-multi
build/ build/
_dist/ _dist/
contented.db contented.db

2
.hgtags Normal file
View File

@ -0,0 +1,2 @@
e2250a7fd29052ea767f18e1459cabea4cd7efd3 release-1.0.0
b8975b9e75648a7c2a5003c67db92cf2216e01c0 release-1.0.1

View File

@ -2,7 +2,7 @@
# Makefile for contented # Makefile for contented
# #
VERSION:=1.2.2 VERSION:=1.1.0
SOURCES:=Makefile \ SOURCES:=Makefile \
static \ static \
@ -21,12 +21,22 @@ 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
# #
@ -38,6 +48,26 @@ 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)
hg archive --type=zip _dist/contented-$(VERSION)-src.zip

View File

@ -3,14 +3,13 @@ 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" "github.com/speps/go-hashids"
bolt "go.etcd.io/bbolt"
) )
const ( const (
@ -48,7 +47,7 @@ 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, _ := hashids.NewWithData(hd)
s, _ := h.EncodeInt64([]int64{int64(v)}) s, _ := h.EncodeInt64([]int64{int64(v)})
return s return s
} }
@ -63,17 +62,9 @@ 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, err := b.NextSequence() seq, _ := b.NextSequence() // cannot fail
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

162
README.md
View File

@ -1,162 +0,0 @@
# contented
[![](doc/image1.thumb.png)](doc/image1.png)
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.
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 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, 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)
```
Usage of contented:
-concurrentthumbs int
Simultaneous thumbnail generation (default 16)
-data string
Directory for stored content (default ".")
-db string
Path for metadata database (default "contented.db")
-diskFilesWorldReadable
Save files as 0644 instead of 0600
-enableHomepage
Enable homepage (disable for embedded use only) (default true)
-enableUpload
Enable uploads (disable for read-only mode) (default true)
-listen string
IP/Port to bind server (default "127.0.0.1:80")
-max int
Maximum size of uploaded files in MiB (set zero for unlimited) (default 8)
-speed int
Maximum upload speed in bytes/sec (set zero for unlimited)
-title string
Title used in web interface (default "contented")
-trustXForwardedFor
Trust X-Forwarded-For reverse proxy headers
```
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).
## Usage (HTTP)
The server responds on the following URLs:
URL |Method |Description
---------------------|-------|---
`/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)
`/thumb/{Type}/{ID}` |`GET` |Get item thumbnail image (JPEG). "Type" should match `[sbtmlh]`.
`/about` |`GET` |Get server metadata (JSON)
## 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:
```html
<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
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
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
- Fix an issue with dependencies causing failure to compile in Modules mode
2020-07-25: 1.3.0
- Feature: Option to limit concurrent thumbnail generation
- Enhancement: Set charset=UTF-8 when serving user-submitted text/plain content
- Fix an issue with large memory usage for multipart file uploads
2018-06-09: 1.2.1
- Feature: Add OpenGraph tags on preview pages, for rich metadata in chat applications
- Update thumbnailing library to improve quality
- Use dep for vendoring
- [⬇️ contented-1.2.1-win32.7z](https://git.ivysaur.me/attachments/88dea4f7-e314-4325-a957-096dcf8cdecc) *(1.51 MiB)*
- [⬇️ contented-1.2.1-src.zip](https://git.ivysaur.me/attachments/6fd2b963-3be4-48a6-a5bf-6f273bcaea24) *(1.49 MiB)*
- [⬇️ contented-1.2.1-linux64.tar.gz](https://git.ivysaur.me/attachments/c536f764-0250-4d67-886a-4797946e1124) *(2.21 MiB)*
2017-11-18: 1.2.0
- Feature: Thumbnail support
- Feature: File preview page
- Feature: Album mode (via URL `/p/{file1}-{file2}-...`)
- Feature: New `-diskFilesWorldReadable` option to save files with `0644` mode
- [⬇️ contented-1.2.0-win32.7z](https://git.ivysaur.me/attachments/f3453b62-b2a7-4e77-9b04-44c99dec35ba) *(1.36 MiB)*
- [⬇️ contented-1.2.0-src.zip](https://git.ivysaur.me/attachments/a6c1ecfb-fd6a-44b5-9dc8-aea7c439d1e6) *(178.94 KiB)*
- [⬇️ contented-1.2.0-linux64.tar.gz](https://git.ivysaur.me/attachments/6234754b-af17-4a72-8b66-56a5db21c7c7) *(2.03 MiB)*
2017-10-15: 1.1.0
- Feature: Drawing mode
- Feature: Ctrl+V image upload
- Feature: Option to trust X-Forwarded-For headers when using a reverse proxy
- Feature: Add `getDownloadURL`, `getInfoJSONURL`, `getPreviewURL` SDK methods
- Feature: Option to disable uploading via the homepage
- Feature: Add button to repeat when uploading from homepage
- Enhancement: Automatically load library dependencies
- Enhancement: Display homepage widget using the full screen size
- Include drawingboard.js 0.4.6 (MIT license)
- Fix a cosmetic issue with javascript console output
- Fix a cosmetic issue with error messages if an upload failed
- [⬇️ contented-1.1.0-win32.7z](https://git.ivysaur.me/attachments/bfb0a7fe-bf95-4d0e-933b-8137bc8071a4) *(1.11 MiB)*
- [⬇️ contented-1.1.0-src.zip](https://git.ivysaur.me/attachments/67401341-724f-4ea2-b9c7-44d08ab9d38a) *(142.82 KiB)*
- [⬇️ contented-1.1.0-linux64.tar.gz](https://git.ivysaur.me/attachments/a13752dd-5228-4830-b61d-0f7cc568b2ae) *(1.67 MiB)*
2017-10-08: 1.0.1
- Fix an issue with CORS preflight requests
- Fix an issue with index URLs
- [⬇️ contented-1.0.1-win32.7z](https://git.ivysaur.me/attachments/a873d510-da09-4797-95e9-ffcad690a77b) *(1.10 MiB)*
- [⬇️ contented-1.0.1-src.zip](https://git.ivysaur.me/attachments/43ac17d6-b6f1-4da7-98e9-b8af6fb5551a) *(109.08 KiB)*
- [⬇️ contented-1.0.1-linux64.tar.gz](https://git.ivysaur.me/attachments/34d74bed-db3f-4cef-a76f-266f0b9e6017) *(1.65 MiB)*
2017-10-08: 1.0.0
- Initial public release
- Include jQuery 1.12.4 (MIT license)
- [⬇️ contented-1.0.0-win32.7z](https://git.ivysaur.me/attachments/4ef132cf-dac8-4bcf-9da7-14ca1366e815) *(1.10 MiB)*
- [⬇️ contented-1.0.0-src.zip](https://git.ivysaur.me/attachments/74d77b3f-557b-44bf-9645-7b3b25ab17c1) *(102.45 KiB)*
- [⬇️ contented-1.0.0-linux64.tar.gz](https://git.ivysaur.me/attachments/1c28a913-686b-44cf-b63d-db22968a93b6) *(1.65 MiB)*

103
Server.go
View File

@ -1,82 +1,38 @@
package contented package contented
import ( import (
"embed" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io/fs"
"log" "log"
"net/http" "net/http"
"os"
"regexp"
"strings" "strings"
"time" "time"
"github.com/minio/minio-go/v7" "github.com/boltdb/bolt"
"github.com/minio/minio-go/v7/pkg/credentials"
"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
DEFAULT_MAX_THUMBSIZE = 20 * 1024 * 1024 // 20 MiB
ALBUM_MIMETYPE = `contented/album`
STORAGE_LOCAL int = 0
STORAGE_S3 int = 1
)
type ServerPublicProperties struct { type ServerPublicProperties struct {
AppTitle string AppTitle string
MaxUploadBytes int64 MaxUploadBytes int64
CanonicalBaseURL string
} }
type ServerOptions struct { type ServerOptions struct {
StorageType int // STORAGE_xx
DataDirectory string DataDirectory string
DataS3Options struct {
Hostname string
AccessKey string
SecretKey string
Bucket string
Prefix string
}
DBPath string DBPath string
DiskFilesWorldReadable bool
BandwidthLimit int64 BandwidthLimit int64
TrustXForwardedFor bool TrustXForwardedFor bool
EnableHomepage bool EnableHomepage bool
EnableUpload bool
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
s3client *minio.Client
db *bolt.DB db *bolt.DB
startTime time.Time startTime time.Time
thumbnailSem chan struct{}
metadataBucket []byte metadataBucket []byte
staticDir fs.FS // interface
} }
func NewServer(opts *ServerOptions) (*Server, error) { func NewServer(opts *ServerOptions) (*Server, error) {
@ -86,37 +42,6 @@ func NewServer(opts *ServerOptions) (*Server, error) {
startTime: time.Now(), startTime: time.Now(),
} }
if s.opts.MaxConcurrentThumbs <= 0 {
s.opts.MaxConcurrentThumbs = DEFAULT_MAX_CONCURRENT_THUMBS // default
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 {
s.thumbnailSem <- struct{}{}
}
b, err := bolt.Open(opts.DBPath, 0644, bolt.DefaultOptions) b, err := bolt.Open(opts.DBPath, 0644, bolt.DefaultOptions)
if err != nil { if err != nil {
return nil, err return nil, err
@ -165,11 +90,8 @@ func (this *Server) remoteIP(r *http.Request) string {
const ( const (
downloadUrlPrefix = `/get/` downloadUrlPrefix = `/get/`
metadataUrlPrefix = `/info/` metadataUrlPrefix = `/info/`
previewUrlPrefix = `/p/`
) )
var rxThumbUrl = regexp.MustCompile(`^/thumb/(.)/(.*)$`)
func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set(`Server`, SERVER_HEADER) w.Header().Set(`Server`, SERVER_HEADER)
w.Header().Set(`Access-Control-Allow-Origin`, `*`) // Blanket allow CORS w.Header().Set(`Access-Control-Allow-Origin`, `*`) // Blanket allow CORS
@ -188,13 +110,6 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, metadataUrlPrefix) { } else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, metadataUrlPrefix) {
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) {
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)
this.handleThumb(w, r, parts[1][0], parts[2])
} else if r.Method == "GET" && r.URL.Path == `/about` { } else if r.Method == "GET" && r.URL.Path == `/about` {
this.handleAbout(w) this.handleAbout(w)
@ -205,17 +120,11 @@ 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" { } else if r.Method == "GET" && r.URL.Path == `/` && this.opts.EnableHomepage {
http.Redirect(w, r, `/index.html`, http.StatusFound)
// Conditionally block homepage access } else if static, err := Asset(r.URL.Path[1:]); err == nil && r.Method == "GET" && (this.opts.EnableHomepage || r.URL.Path != `/index.html`) {
if !this.opts.EnableHomepage && (r.URL.Path == `/index.html` || r.URL.Path == `/`) { http.ServeContent(w, r, r.URL.Path[1:], this.startTime, bytes.NewReader(static))
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)

13
TODO.txt Normal file
View File

@ -0,0 +1,13 @@
TODO
- View server-wide recent uploads history / all upload history
- Display 'my uploads' (id + metadata history kept in localstorage)
- Encrypted at rest (anti- provider snooping)
- Nicer preview page after uploading
- Gallery preview (multiple element hashid)
- Prevent selecting around the toggle buttons (firefox for android)

82
_dist/README.txt Normal file
View File

@ -0,0 +1,82 @@
A file / image / paste upload server with a focus on embedding.
Written in Go
You can use contented as a standalone upload server, or you can use the SDK to embed its upload widget into another website.
=FEATURES=
- Drag and drop upload
- Multiple files upload
- Pastebin upload
- Custom drawing upload ([url=https://github.com/Leimi/drawingboard.js]via[/url])
- Ctrl-V upload
- 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
- Short URLs (using [url=http://hashids.org]Hashids[/url] algorithm)
=USAGE (SERVER)=
`Usage of contented:
-data string
Directory for stored content (default "")
-db string
Path for metadata database (default "contented.db")
-enableHomepage
Enable homepage (disable for embedded use only) (default true)
-listen string
(default "127.0.0.1:80")
-max int
Maximum size of uploaded files in MiB (set zero for unlimited) (default 8)
-speed int
Maximum upload speed in bytes/sec (set zero for unlimited)
-title string
Title used in web interface (default "contented")
-trustXForwardedFor
Trust X-Forwarded-For reverse proxy headers
`
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).
=USAGE (HTTP)=
The server responds on the following URLs:
- `/get/{ID}`: Download item content
- `/info/{ID}`: Get item content metadata (JSON)
- `/about`: Get server metadata (JSON)
=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.
`<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
contented.init("#target", function(/* String[] */ items) {});
`
=CHANGELOG=
2017-10-15: 1.1.0
- Feature: Drawing mode
- Feature: Ctrl+V image upload
- Feature: Option to trust X-Forwarded-For headers when using a reverse proxy
- Feature: Add `getDownloadURL`, `getInfoJSONURL`, `getPreviewURL` SDK methods
- Feature: Option to disable uploading via the homepage
- Feature: Add button to repeat when uploading from homepage
- Enhancement: Automatically load library dependencies
- Enhancement: Display homepage widget using the full screen size
- Include drawingboard.js 0.4.6 (MIT license)
- Fix a cosmetic issue with javascript console output
- Fix a cosmetic issue with error messages if an upload failed
2017-10-08: 1.0.1
- Fix an issue with CORS preflight requests
- Fix an issue with index URLs
2017-10-08: 1.0.0
- Initial public release
- Include jQuery 1.12.4 (MIT license)

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -1,58 +0,0 @@
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,59 +12,28 @@ import (
func main() { func main() {
cwd, _ := os.Getwd() cwd, _ := os.Getwd()
var ( listenAddr := flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
listenAddr = flag.String("listen", "127.0.0.1:80", "IP/Port to bind server") dataDir := flag.String("data", cwd, "Directory for stored content")
dataDir = flag.String("data", cwd, "Directory for stored content") dbPath := flag.String("db", "contented.db", "Path for metadata database")
dbPath = flag.String("db", "contented.db", "Path for metadata database") appTitle := flag.String("title", "contented", "Title used in web interface")
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)")
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)")
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")
trustXForwardedFor = flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers") enableHomepage := flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
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() flag.Parse()
opts := contented.ServerOptions{ svr, err := contented.NewServer(&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,
MaxConcurrentThumbs: *maxConcurrentThumbs,
ServerPublicProperties: contented.ServerPublicProperties{ ServerPublicProperties: contented.ServerPublicProperties{
AppTitle: *appTitle, AppTitle: *appTitle,
MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024, 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 { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
os.Exit(1) os.Exit(1)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -4,6 +4,7 @@ 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) {
@ -27,7 +28,7 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
} }
// Load file // Load file
f, err := this.ReadFile(r.Context(), m.FileHash) f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
if err != nil { if err != nil {
return err return err
} }
@ -36,31 +37,12 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
// ServeContent only uses the filename to get the mime type, which we can // ServeContent only uses the filename to get the mime type, which we can
// set accurately (including blacklist) // set accurately (including blacklist)
switch m.MimeType {
case `text/plain`:
w.Header().Set(`Content-Type`, `text/plain; charset=UTF-8`)
case `application/octet-stream`:
w.Header().Set(`Content-Type`, m.MimeType) w.Header().Set(`Content-Type`, m.MimeType)
if m.MimeType == `application/octet-stream` {
w.Header().Set(`Content-Disposition`, `attachment; filename="`+m.Filename+`"`) w.Header().Set(`Content-Disposition`, `attachment; filename="`+m.Filename+`"`)
default:
w.Header().Set(`Content-Type`, m.MimeType)
} }
/*
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) http.ServeContent(w, r, "", m.UploadTime, f)
return nil
return nil
} }

33
go.mod
View File

@ -1,33 +0,0 @@
module code.ivysaur.me/contented
require (
code.ivysaur.me/thumbnail v1.0.2
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
github.com/speps/go-hashids v1.0.0
go.etcd.io/bbolt v1.3.7
)
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/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

69
go.sum
View File

@ -1,69 +0,0 @@
code.ivysaur.me/imagequant/v2 v2.12.6 h1:xYrGj6GOdAcutmzqBxG7bDZ70r4jYHADOCZ+ktyMU3Y=
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/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=

View File

@ -1,215 +0,0 @@
package contented
import (
"context"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"os"
"strings"
"time"
)
func (this *Server) handlePreview(ctx context.Context, w http.ResponseWriter, fileIDList string) {
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>
<html prefix="og: http://ogp.me/ns#">
<head>
<title>` + html.EscapeString(specialTitle+" | "+this.opts.ServerPublicProperties.AppTitle) + `</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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:type" content="website" />
`
if len(this.opts.ServerPublicProperties.CanonicalBaseURL) > 0 {
tmpl += `
<meta property="og:url" content="` + html.EscapeString(this.opts.ServerPublicProperties.CanonicalBaseURL+`p/`+fileIDList) + `" />
`
}
for _, fileID := range fileIDs {
tmpl += `
<meta property="og:image" content="` + html.EscapeString(`/thumb/m/`+fileID) + `" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="300" />
`
}
tmpl += `
<style type="text/css">
html, body {
background: #333;
color: #F0F0F0;
font-family: sans-serif;
}
.entry {
display: inline-block;
margin: 4px;
border-radius: 4px;
max-width: 340px;
}
.thumbnail {
line-height: 0;
width: 340px;
height: 340px;
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 {
background: #000;
padding: 4px;
word-break: break-word;
}
</style>
</head>
<body>
<div class="container">
`
for _, fileID := range fileIDs {
m, err := this.Metadata(fileID)
if err != nil {
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)
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())
http.Error(w, "Internal error", 500)
return
}
if m.MimeType == ALBUM_MIMETYPE {
// Special handling for albums
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)
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 += `
<div class="entry">
<div class="thumbnail">
<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 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> ` + html.EscapeString(m.MimeType) + `<br>
<b>Size:</b> ` + html.EscapeString(fmt.Sprintf("%d", m.FileSize)) + `<br>
<b>Uploader:</b> ` + html.EscapeString(m.UploadIP) + `<br>
<b>Uploaded at:</b> ` + html.EscapeString(m.UploadTime.Format(time.RFC3339)) + `<br>
</div>
</div>
`
}
}
if this.opts.EnableHomepage {
tmpl += `
<div class="return">
<button onclick="window.location.href='/'">Again...</button>
</div>
`
}
tmpl += `
</div>
</body>
</html>`
w.Header().Set(`Content-Type`, `text/html; charset=UTF-8`)
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(tmpl)))
w.WriteHeader(200)
w.Write([]byte(tmpl))
}

View File

@ -38,7 +38,7 @@ button.again {
</div> </div>
</div> </div>
<script type="text/javascript" src="/jquery-3.7.0.min.js"></script> <script type="text/javascript" src="/jquery-1.12.4.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";
@ -56,7 +56,22 @@ $.get("/about", function(ret) {
// Load upload widget // Load upload widget
contented.init("#surrogate-area", function(items) { contented.init("#surrogate-area", function(items) {
window.location.href = contented.getMultiPreviewURL(items);
var $table = $("<table>");
for (var i = 0; i < items.length; ++i) {
$table.append($("<tr>").append([
$("<td>").text(items[i]),
$("<td>").html("<a target='_blank' href='" + contented.getDownloadURL(items[i]) + "'>get</a>"),
$("<td>").html("<a target='_blank' href='" + contented.getInfoJSONURL(items[i]) + "'>info</a>"),
]))
}
$("#surrogate-area").html([
$table,
$("<button>").addClass("again").text("Again...").click(function() {
window.location.href = window.location.href;
}),
]);
}); });
</script> </script>
</body> </body>

5
static/jquery-1.12.4.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,6 +1,28 @@
;(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
@ -12,14 +34,75 @@
return currentScript.replace(currentScriptFile, ''); return currentScript.replace(currentScriptFile, '');
}; };
var loadScript = function(url) { var currentScriptPath = getCurrentScriptPath();
return new Promise(function(resolve, reject) { var baseURL = currentScriptPath.replace('sdk.js', '');
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 + "get/" + id; // n.b. there's no better preview URL yet
},
"getDownloadURL": function(id) {
return baseURL + "get/" + id;
},
"getInfoJSONURL": function(id) {
return baseURL + "info/" + id;
}
};
})();
;(function() {
"use strict";
var loadScript = function(url, onLoad) {
var script = document.createElement('script'); var script = document.createElement('script');
script.onload = resolve; script.onload = onLoad;
script.onerror = reject;
script.src = url; script.src = url;
document.head.appendChild(script); document.head.appendChild(script);
}); };
var loadScripts = function(urls, onLoad) {
if (urls.length === 0) {
onLoad();
return;
}
var totalLoaded = 0;
var cb = function() {
totalLoaded += 1;
if (totalLoaded == urls.length) {
onLoad();
}
};
for (var i = 0; i < urls.length; ++i) {
loadScript(urls[i], cb);
}
}; };
var formatBytes = function(bytes) { var formatBytes = function(bytes) {
@ -44,184 +127,9 @@
}); });
}; };
var widgetHtml = ` var afterScriptsLoaded = function() {
<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; var initArea = function (elementSelector, onUploaded, onClose) {
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 () { };
@ -233,12 +141,15 @@
// <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 (aboutInfo.MaxUploadBytes > 0) { if (ret.MaxUploadBytes > 0) {
extraText = " (max " + formatBytes(aboutInfo.MaxUploadBytes) + ")"; extraText = " (max " + formatBytes(ret.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);
@ -320,37 +231,6 @@
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) {
@ -414,10 +294,6 @@
var setupDrawingBoard = function() { var setupDrawingBoard = function() {
$("head").append(
'<link rel="stylesheet" type="text/css" href="' + contented.baseURL + 'drawingboard-0.4.6.min.css">'
);
var db_id = "contented-drawing-area-" + guid(); var db_id = "contented-drawing-area-" + guid();
var $db = $("<div>").attr('id', db_id); var $db = $("<div>").attr('id', db_id);
@ -487,10 +363,6 @@
// 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...");
@ -544,97 +416,33 @@
} }
}; // .
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();
}
}).then(function() { return new Promise(function(resolve, reject) {
if (typeof DrawingBoard === "undefined") {
loadScript(contented.baseURL + "drawingboard-0.4.6.min.js").then(resolve);
} else {
resolve();
}
})}).then(function() { return new Promise(function(resolve, reject) {
$.get(contented.baseURL + "about", function (aboutInfo) {
resolve(aboutInfo);
}); });
})}).then(function(aboutInfo) { });
}
// Update fields in global variable // Update fields in global variable
window.contented.loaded = true; contented.init = initArea;
contented.loaded = true;
return aboutInfo; // Call initArea for all pre-initialised elements
}); for (var i = 0; i < contented.__preInit.length; ++i) {
initArea(contented.__preInit[i][0], contented.__preInit[i][1], contented.__preInit[i][2]);
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("jquery-1.12.4.min.js");
}
if (typeof DrawingBoard === "undefined") {
needScripts.push("drawingboard-0.4.6.min.js");
}
init(); loadScripts(needScripts, afterScriptsLoaded);
})() })()

160
static/widget.html Normal file
View File

@ -0,0 +1,160 @@
<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;
}
.contented-progress-element {
position:absolute;
background:darkgreen;
left:0;
width:0%;
}
</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">
<link rel="stylesheet" type="text/css" href="/drawingboard-0.4.6.min.css">
<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>

350
staticResources.go Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,57 +0,0 @@
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")
}
}

144
thumb.go
View File

@ -1,144 +0,0 @@
package contented
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"code.ivysaur.me/thumbnail"
)
func getThumbnailerConfig(t byte) (*thumbnail.Config, error) {
// Modelled on what imgur.com offers
// @ref https://api.imgur.com/models/image#thumbs
opts := thumbnail.Config{
Aspect: thumbnail.FitOutside,
Output: thumbnail.Jpeg,
Scale: thumbnail.Bicubic,
}
switch t {
case 's':
opts.Width = 90
opts.Height = 90
case 'b':
opts.Width = 160
opts.Height = 160
case 't':
opts.Width = 160
opts.Height = 160
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
case 'm':
opts.Width = 340
opts.Height = 340
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
case 'l':
opts.Width = 640
opts.Height = 640
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
case 'h':
opts.Width = 1024
opts.Height = 1024
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
default:
return nil, errors.New("Unsupported thumbnail type (should be s/b/t/m/l/h)")
}
return &opts, nil
}
func (this *Server) handleThumb(w http.ResponseWriter, r *http.Request, thumbnailType byte, fileId string) {
ctx := r.Context()
opts, err := getThumbnailerConfig(thumbnailType)
if err != nil {
log.Printf("%s Thumbnail failed: %s\n", this.remoteIP(r), err.Error())
http.Error(w, err.Error(), 400)
return
}
// Only a limited number of thumbnails can be generated concurrently
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{}{} }()
if ctx.Err() != nil {
// The request was already cancelled
return
}
t := thumbnail.NewThumbnailerEx(opts)
err = this.handleThumbInternal(ctx, w, t, fileId)
if err != nil {
log.Printf("%s Thumbnail failed: %s\n", this.remoteIP(r), err.Error())
w.Header().Set(`Location`, fmt.Sprintf(`/nothumb_%d.png`, opts.Height))
w.WriteHeader(302)
}
}
func (this *Server) handleThumbInternal(ctx context.Context, w http.ResponseWriter, t thumbnail.Thumbnailer, fileId string) error {
// Load metadata
m, err := this.Metadata(fileId)
if err != nil {
return err
}
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)
w.Write(thumb)
return nil
}

View File

@ -1,7 +1,6 @@
package contented package contented
import ( import (
"context"
"crypto/sha512" "crypto/sha512"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -10,21 +9,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(this.opts.MaxUploadBytes * 2)
if err != nil { if err != nil {
log.Printf("%s Invalid request: %s\n", remoteIP, err.Error()) log.Printf("%s Invalid request: %s\n", remoteIP, err.Error())
http.Error(w, "Invalid request", 400) http.Error(w, "Invalid request", 400)
@ -93,13 +89,26 @@ 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, 0600)
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
}
// Save file to disk/s3 if shouldSave {
err = this.SaveFile(context.Background(), fileHash, srcLen, src) defer dest.Close()
_, 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")