Compare commits
	
		
			138 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2434cccf59 | |||
| 426d5738a0 | |||
| 76c2e552ab | |||
| ea9ef2e466 | |||
| 6b5b11fb5f | |||
| 156115d5f9 | |||
| 44af6efc87 | |||
| 9bd6e881b2 | |||
| bfd669bc96 | |||
| ea1309eb75 | |||
| b146db9d0a | |||
| 0a22d1ca8a | |||
| bc911327cf | |||
| e04900f672 | |||
| d10026ae82 | |||
| 36b4b124f7 | |||
| 8d40031edc | |||
| 15c02efe96 | |||
| 01ff8f69aa | |||
| 3e5fb091c9 | |||
| caf521c318 | |||
| 77a4061cdd | |||
| 5ce06d6e6b | |||
| 0044d7dc77 | |||
| 6453700648 | |||
| 7407353dfb | |||
| 660038b897 | |||
| 6cef907cf9 | |||
| b959f30882 | |||
| 64b900d90c | |||
| e26a3b58b0 | |||
| 4294738337 | |||
| 156e2ab540 | |||
| ad56309cb0 | |||
| f13618fef1 | |||
| e8dd95a830 | |||
| bf3339fec7 | |||
| cb29cf83ac | |||
| ad95ac219d | |||
| 11003e010d | |||
| 6b4c2cc208 | |||
| ccc1f73e52 | |||
| b48d1347c7 | |||
| bed8a88b09 | |||
| 0a70d99af5 | |||
| 6720cbc0d9 | |||
| 8d11b7c434 | |||
| 147608a327 | |||
| 951c87b63f | |||
| 25180afaa2 | |||
| 33ca03b9d7 | |||
| b562ca4bd4 | |||
| a25dc5827b | |||
| 5263f96956 | |||
| 2b2add62b2 | |||
| fdc93e6485 | |||
| b08f1c33d5 | |||
| 524f37d9fe | |||
| c958c57794 | |||
| 8fbad2a1e0 | |||
| 989cd195f8 | |||
| cb4933fa72 | |||
| f504ab5929 | |||
| f8e95a8037 | |||
| feaa51cfcd | |||
| ddf76aacc5 | |||
| ca8a0d55ba | |||
| fe4aace777 | |||
| be864235cb | |||
| dba699524f | |||
| 7950ca3004 | |||
| 6e5ca0c61c | |||
| 7255cd03cb | |||
| 3cf4986418 | |||
| 0a338c4568 | |||
| 0fbf401fd3 | |||
| 48ca68fafd | |||
| f3e594d307 | |||
| 396672b02b | |||
| d25b867e90 | |||
| cd60e4c855 | |||
| ee72f188a2 | |||
| 30f5b40e1d | |||
| 3a103ae484 | |||
| 08321818ff | |||
| 14456e6539 | |||
| f27d14aac4 | |||
| 6ab2b08099 | |||
| 930869759b | |||
| d35c81ed21 | |||
| 139117b4d5 | |||
| b88273ec64 | |||
| 79cb8733e5 | |||
| 366c307e02 | |||
| 23ad509f33 | |||
| a6e495f74d | |||
| b3ec40ae65 | |||
| 87f0cb016d | |||
| 381e67bb39 | |||
| c2f4de822f | |||
| ffd4c03d9c | |||
| 2e08ac06ca | |||
| 6d739972de | |||
| e36fd43f9b | |||
| 35bbc6c61b | |||
| 987c704730 | |||
| 9669f2aa0b | |||
| 99139e360d | |||
| 696a92096d | |||
| f125c23fb9 | |||
| c50d6c4bfe | |||
| 5edea74333 | |||
| 1882a94e65 | |||
| 1309293705 | |||
| d24c3d4895 | |||
| 5e9fcc09c8 | |||
| fb7f1dd3a5 | |||
| 2f4fe0a55f | |||
| a3fc9092e3 | |||
| 6eb48bf72f | |||
| 40aa6c8917 | |||
| 24febefba4 | |||
| 57d9b4d324 | |||
| 3ca73e3221 | |||
| 27cf4cf0c0 | |||
| ef6b680b7e | |||
| 188ca7e679 | |||
| b8f1b26aba | |||
| ac768524ee | |||
| a5008ab455 | |||
| 9436dcd43e | |||
| f543347b1c | |||
| 582854878b | |||
| 642ee5a485 | |||
| 19b20daded | |||
| 270e6a9397 | |||
| ef5351a075 | |||
| 01ac021c7e | 
							
								
								
									
										3
									
								
								.hgignore → .gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,6 +1,5 @@
 | 
				
			|||||||
syntax: glob
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
cmd/contented/contented
 | 
					cmd/contented/contented
 | 
				
			||||||
 | 
					cmd/contented-multi/contented-multi
 | 
				
			||||||
build/
 | 
					build/
 | 
				
			||||||
_dist/
 | 
					_dist/
 | 
				
			||||||
contented.db
 | 
					contented.db
 | 
				
			||||||
							
								
								
									
										32
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						@@ -2,7 +2,7 @@
 | 
				
			|||||||
# Makefile for contented
 | 
					# Makefile for contented
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
VERSION:=1.0.1
 | 
					VERSION:=1.2.2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SOURCES:=Makefile \
 | 
					SOURCES:=Makefile \
 | 
				
			||||||
	static \
 | 
						static \
 | 
				
			||||||
@@ -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)
 | 
					 | 
				
			||||||
	 hg archive --type=zip _dist/contented-$(VERSION)-src.zip
 | 
					 | 
				
			||||||
	
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										23
									
								
								Metadata.go
									
									
									
									
									
								
							
							
						
						@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										170
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,170 @@
 | 
				
			|||||||
 | 
					# contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[](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
 | 
				
			||||||
 | 
					- Optional hot/cold storage tiering
 | 
				
			||||||
 | 
					- 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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2025-08-20: v1.6.1
 | 
				
			||||||
 | 
					- Expanded error logging for tiered storage migrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					- 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)*
 | 
				
			||||||
							
								
								
									
										118
									
								
								Server.go
									
									
									
									
									
								
							
							
						
						@@ -1,28 +1,62 @@
 | 
				
			|||||||
package contented
 | 
					package contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"bytes"
 | 
						"embed"
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"io/fs"
 | 
				
			||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
						"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
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
	MaxUploadBytes   int64
 | 
						MaxUploadBytes   int64
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
	BandwidthLimit         int64
 | 
						BandwidthLimit         int64
 | 
				
			||||||
 | 
						TrustXForwardedFor     bool
 | 
				
			||||||
 | 
						EnableHomepage         bool
 | 
				
			||||||
 | 
						EnableUpload           bool
 | 
				
			||||||
 | 
						MaxConcurrentThumbs    int
 | 
				
			||||||
 | 
						MaxThumbSizeBytes      int64
 | 
				
			||||||
	ServerPublicProperties
 | 
						ServerPublicProperties
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -30,7 +64,10 @@ type Server struct {
 | 
				
			|||||||
	opts           ServerOptions
 | 
						opts           ServerOptions
 | 
				
			||||||
	db             *bolt.DB
 | 
						db             *bolt.DB
 | 
				
			||||||
	startTime      time.Time
 | 
						startTime      time.Time
 | 
				
			||||||
 | 
						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) {
 | 
				
			||||||
@@ -40,6 +77,51 @@ 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
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
@@ -75,15 +157,24 @@ func (this *Server) handleAbout(w http.ResponseWriter) {
 | 
				
			|||||||
	this.serveJsonObject(w, this.opts.ServerPublicProperties)
 | 
						this.serveJsonObject(w, this.opts.ServerPublicProperties)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func remoteIP(r *http.Request) string {
 | 
					func (this *Server) remoteIP(r *http.Request) string {
 | 
				
			||||||
 | 
						if this.opts.TrustXForwardedFor {
 | 
				
			||||||
 | 
							if xff := r.Header.Get("X-Forwarded-For"); len(xff) > 0 {
 | 
				
			||||||
 | 
								return xff
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return strings.TrimRight(strings.TrimRight(r.RemoteAddr, "0123456789"), ":")
 | 
						return strings.TrimRight(strings.TrimRight(r.RemoteAddr, "0123456789"), ":")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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
 | 
				
			||||||
@@ -102,6 +193,13 @@ 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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -112,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 == `/` {
 | 
						} 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" {
 | 
							// 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)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								TODO.txt
									
									
									
									
									
								
							
							
						
						@@ -1,13 +0,0 @@
 | 
				
			|||||||
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
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- Option to disable manual uploading from the landing page
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- Gallery preview (multiple element hashid)
 | 
					 | 
				
			||||||
@@ -1,60 +0,0 @@
 | 
				
			|||||||
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 uploader, or fallback classic uploader, or pastebin-style uploader
 | 
					 | 
				
			||||||
- Multiple files upload
 | 
					 | 
				
			||||||
- Pastebin 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")
 | 
					 | 
				
			||||||
  -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")
 | 
					 | 
				
			||||||
`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
=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. The SDK depends on jQuery.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
`<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
 | 
					 | 
				
			||||||
contented.init("#target", function(/* String[] */ items) {});
 | 
					 | 
				
			||||||
`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
=CHANGELOG=
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
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
 | 
					 | 
				
			||||||
							
								
								
									
										58
									
								
								cmd/contented-multi/main.go
									
									
									
									
									
										Normal 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()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -12,24 +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)")
 | 
				
			||||||
 | 
							maxUploadSpeed         = flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
 | 
				
			||||||
 | 
							trustXForwardedFor     = flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
 | 
				
			||||||
 | 
							enableHomepage         = flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
 | 
				
			||||||
 | 
							enableUpload           = flag.Bool("enableUpload", true, "Enable uploads (disable for read-only mode)")
 | 
				
			||||||
 | 
							diskFilesWorldReadable = flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
 | 
				
			||||||
 | 
							maxConcurrentThumbs    = flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
 | 
				
			||||||
 | 
							s3Host                 = flag.String("s3hostname", "", "S3 Server hostname")
 | 
				
			||||||
 | 
							s3AccessKey            = flag.String("s3access", "", "S3 Access key")
 | 
				
			||||||
 | 
							s3SecretKey            = flag.String("s3secret", "", "S3 Secret key")
 | 
				
			||||||
 | 
							s3Bucket               = flag.String("s3bucket", "", "S3 Bucket")
 | 
				
			||||||
 | 
							s3Prefix               = flag.String("s3prefix", "", "S3 object prefix")
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	flag.Parse()
 | 
						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,
 | 
				
			||||||
 | 
							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,
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	})
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// 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)
 | 
				
			||||||
 
 | 
				
			|||||||
| 
		 Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								doc/image1.thumb.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 5.3 KiB  | 
							
								
								
									
										28
									
								
								download.go
									
									
									
									
									
								
							
							
						
						@@ -4,13 +4,12 @@ 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) {
 | 
				
			||||||
	err := this.handleViewInternal(w, r, r.URL.Path[len(downloadUrlPrefix):])
 | 
						err := this.handleViewInternal(w, r, r.URL.Path[len(downloadUrlPrefix):])
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Printf("%s View failed: %s\n", r.RemoteAddr, err.Error())
 | 
							log.Printf("%s View failed: %s\n", this.remoteIP(r), err.Error())
 | 
				
			||||||
		if os.IsNotExist(err) {
 | 
							if os.IsNotExist(err) {
 | 
				
			||||||
			http.Error(w, "File not found", 404)
 | 
								http.Error(w, "File not found", 404)
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
@@ -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
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -37,12 +36,31 @@ 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)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										34
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					module code.ivysaur.me/contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require (
 | 
				
			||||||
 | 
						code.ivysaur.me/thumbnail v1.0.2
 | 
				
			||||||
 | 
						github.com/minio/minio-go/v7 v7.0.95
 | 
				
			||||||
 | 
						github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
 | 
				
			||||||
 | 
						github.com/speps/go-hashids/v2 v2.0.1
 | 
				
			||||||
 | 
						go.etcd.io/bbolt v1.4.3
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
							
								
								
									
										60
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,60 @@
 | 
				
			|||||||
 | 
					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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
				
			||||||
 | 
					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/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 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/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 | 
				
			||||||
 | 
					github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
 | 
				
			||||||
 | 
					github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 | 
				
			||||||
 | 
					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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
 | 
				
			||||||
 | 
					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.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=
 | 
				
			||||||
							
								
								
									
										215
									
								
								preview.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,215 @@
 | 
				
			|||||||
 | 
					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.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 += `
 | 
				
			||||||
 | 
									<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))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										5
									
								
								static/drawingboard-0.4.6.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										4
									
								
								static/drawingboard-0.4.6.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -7,9 +7,23 @@
 | 
				
			|||||||
		<style type="text/css">
 | 
							<style type="text/css">
 | 
				
			||||||
html, body {
 | 
					html, body {
 | 
				
			||||||
	font-family: sans-serif;
 | 
						font-family: sans-serif;
 | 
				
			||||||
 | 
						margin:0;
 | 
				
			||||||
 | 
						width:100%;
 | 
				
			||||||
 | 
						height:100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					#padder {
 | 
				
			||||||
 | 
						height:100%;
 | 
				
			||||||
 | 
						position:relative;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
#surrogate-area {
 | 
					#surrogate-area {
 | 
				
			||||||
    height:300px;
 | 
					    position:absolute;
 | 
				
			||||||
 | 
					    top:10px;
 | 
				
			||||||
 | 
					    bottom:10px;
 | 
				
			||||||
 | 
					    left:10px;
 | 
				
			||||||
 | 
					    right:10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					button.again {
 | 
				
			||||||
 | 
						margin-top:2em;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
/* hide close button */
 | 
					/* hide close button */
 | 
				
			||||||
.contented-close {
 | 
					.contented-close {
 | 
				
			||||||
@@ -18,11 +32,13 @@ html, body {
 | 
				
			|||||||
		</style>
 | 
							</style>
 | 
				
			||||||
	</head>
 | 
						</head>
 | 
				
			||||||
	<body>
 | 
						<body>
 | 
				
			||||||
 | 
							<div id="padder">
 | 
				
			||||||
			<div id="surrogate-area">
 | 
								<div id="surrogate-area">
 | 
				
			||||||
				Loading...
 | 
									Loading...
 | 
				
			||||||
			</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";
 | 
				
			||||||
@@ -40,17 +56,7 @@ $.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='/get/" + items[i] + "'>get</a>"),
 | 
					 | 
				
			||||||
            $("<td>").html("<a target='_blank' href='/info/" + items[i] + "'>info</a>")
 | 
					 | 
				
			||||||
        ]))
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    $("#surrogate-area").html($table);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
		</script>
 | 
							</script>
 | 
				
			||||||
	</body>
 | 
						</body>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										5
									
								
								static/jquery-1.12.4.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
							
								
								
									
										2
									
								
								static/jquery-3.7.0.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								static/nothumb_1024.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 8.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								static/nothumb_160.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 2.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								static/nothumb_340.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 3.6 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								static/nothumb_640.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 5.3 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								static/nothumb_90.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.5 KiB  | 
							
								
								
									
										561
									
								
								static/sdk.js
									
									
									
									
									
								
							
							
						
						@@ -1,38 +1,227 @@
 | 
				
			|||||||
;
 | 
					;(function() {
 | 
				
			||||||
var contented = (function ($, currentScriptPath) {
 | 
					 | 
				
			||||||
	"use strict";
 | 
						"use strict";
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
    var baseURL = currentScriptPath.replace('sdk.js', '');
 | 
						var getCurrentScriptPath = function () {
 | 
				
			||||||
 | 
							// Determine current script path
 | 
				
			||||||
 | 
							// @ref https://stackoverflow.com/a/26023176
 | 
				
			||||||
 | 
							var scripts = document.querySelectorAll('script[src]');
 | 
				
			||||||
 | 
							var currentScript = scripts[scripts.length - 1].src;
 | 
				
			||||||
 | 
							var currentScriptChunks = currentScript.split('/');
 | 
				
			||||||
 | 
							var currentScriptFile = currentScriptChunks[currentScriptChunks.length - 1];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return currentScript.replace(currentScriptFile, '');
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						var loadScript = function(url) {
 | 
				
			||||||
 | 
							return new Promise(function(resolve, reject) {
 | 
				
			||||||
 | 
								var script = document.createElement('script');
 | 
				
			||||||
 | 
								script.onload = resolve;
 | 
				
			||||||
 | 
								script.onerror = reject;
 | 
				
			||||||
 | 
								script.src = url;
 | 
				
			||||||
 | 
								document.head.appendChild(script);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
    var formatBytes = function(bytes) {
 | 
					    var formatBytes = function(bytes) {
 | 
				
			||||||
        if (bytes < 1024) {
 | 
							var k = 1024, m = (1024*1024), g = (1024*1024*1024);
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
					        if (bytes < k) {
 | 
				
			||||||
            return bytes + " B";
 | 
					            return bytes + " B";
 | 
				
			||||||
        } else if (bytes < (1024*1024)) {
 | 
					        } else if (bytes < m) {
 | 
				
			||||||
            return (bytes / 1024).toFixed(1) + " KiB";
 | 
					            return (bytes / k).toFixed(1) + " KiB";
 | 
				
			||||||
        } else if (bytes < (1024*1024*1024)) {
 | 
					        } else if (bytes < g) {
 | 
				
			||||||
            return (bytes / (1024*1024)).toFixed(1) + " MiB";
 | 
					            return (bytes / m).toFixed(1) + " MiB";
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            return (bytes / (1024*1024*1024)).toFixed(1) + " GiB";
 | 
					            return (bytes / g).toFixed(1) + " GiB";
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
    /**
 | 
						// @ref https://stackoverflow.com/a/2117523
 | 
				
			||||||
     * supportsDrop returns whether drag-and-drop is supported by this browser.
 | 
						var guid = function uuidv4() {
 | 
				
			||||||
     *
 | 
							return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
 | 
				
			||||||
     * @return bool
 | 
								var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
 | 
				
			||||||
     */
 | 
								return v.toString(16);
 | 
				
			||||||
    var supportsDrop = function () {
 | 
							});
 | 
				
			||||||
        return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
 | 
						};
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
						var widgetHtml = `
 | 
				
			||||||
     * initArea shows the contented upload widget over the top of a target DOM element.
 | 
					<style type="text/css">
 | 
				
			||||||
     *
 | 
						.contented {
 | 
				
			||||||
     * @param any        element       Drop target (string selector / DOMElement / jQuery)
 | 
							box-sizing:border-box;
 | 
				
			||||||
     * @param Function   onUploaded    Called with an array of upload IDs
 | 
							text-align: center;
 | 
				
			||||||
     * @param Function   onClose       Called when the widget is being destroyed
 | 
							border: 8px dashed lightgrey;
 | 
				
			||||||
     */
 | 
							padding: 12px;
 | 
				
			||||||
    var initArea = function (elementSelector, onUploaded, onClose) {
 | 
							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 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 »</button>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							<div class="contented-upload-if contented-if-paste">
 | 
				
			||||||
 | 
								<textarea placeholder="Paste content here"></textarea>
 | 
				
			||||||
 | 
								<button class="contented-paste-upload">Upload »</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 »</button>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var initArea = function (aboutInfo, elementSelector, onUploaded, onClose) {
 | 
				
			||||||
		onUploaded = onUploaded || function () { };
 | 
							onUploaded = onUploaded || function () { };
 | 
				
			||||||
		onClose = onClose || function () { };
 | 
							onClose = onClose || function () { };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -44,44 +233,42 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
		// <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(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(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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var ourClose = function () {
 | 
							// Tab buttons
 | 
				
			||||||
                    $f.remove(); // remove from dom
 | 
					 | 
				
			||||||
                    onClose(); // upstream close
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
                $f.find(".contented-close").click(function () {
 | 
					 | 
				
			||||||
                    ourClose();
 | 
					 | 
				
			||||||
                })
 | 
					 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 | 
							var hasSetupDrawingBoardYet = false;
 | 
				
			||||||
		var setType = function (type) {
 | 
							var setType = function (type) {
 | 
				
			||||||
			$f.find(".contented-upload-type").removeClass("contented-upload-type-active");
 | 
								$f.find(".contented-upload-type").removeClass("contented-upload-type-active");
 | 
				
			||||||
			$f.find(".contented-upload-type[data-upload-type=" + type + "]").addClass("contented-upload-type-active");
 | 
								$f.find(".contented-upload-type[data-upload-type=" + type + "]").addClass("contented-upload-type-active");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			$f.find(".contented-upload-if").removeClass("contented-active");
 | 
								$f.find(".contented-upload-if").removeClass("contented-active");
 | 
				
			||||||
			$f.find(".contented-if-" + type).addClass("contented-active");
 | 
								$f.find(".contented-if-" + type).addClass("contented-active");
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								if (type == "drag") {
 | 
				
			||||||
 | 
									enablePasteHandler();
 | 
				
			||||||
 | 
								} else {
 | 
				
			||||||
 | 
									disablePasteHandler();
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								if (type == "drawing" && !hasSetupDrawingBoardYet) {
 | 
				
			||||||
 | 
									setupDrawingBoard();
 | 
				
			||||||
 | 
									hasSetupDrawingBoardYet = true;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$f.find(".contented-upload-type").click(function () {
 | 
							$f.find(".contented-upload-type").click(function () {
 | 
				
			||||||
			setType($(this).attr('data-upload-type'));
 | 
								setType($(this).attr('data-upload-type'));
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if (!supportsDrop()) {
 | 
							// Widget positioning
 | 
				
			||||||
                    // switch default
 | 
					 | 
				
			||||||
                    setType('file');
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                //
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var $element = $(element);
 | 
							var $element = $(element);
 | 
				
			||||||
		var offset = $element.offset();
 | 
							var offset = $element.offset();
 | 
				
			||||||
@@ -98,6 +285,8 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
			'max-height': $element.height() + "px"
 | 
								'max-height': $element.height() + "px"
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Drag and drop support
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		$f.find('.contented').on('dragover dragenter', function (e) {
 | 
							$f.find('.contented').on('dragover dragenter', function (e) {
 | 
				
			||||||
			e.preventDefault();
 | 
								e.preventDefault();
 | 
				
			||||||
			e.stopPropagation();
 | 
								e.stopPropagation();
 | 
				
			||||||
@@ -122,6 +311,8 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
			handleUploadFrom($(".contented-file-selector")[0].files);
 | 
								handleUploadFrom($(".contented-file-selector")[0].files);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Pastebin
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		$f.find('.contented-paste-upload').on('click', function(e) {
 | 
							$f.find('.contented-paste-upload').on('click', function(e) {
 | 
				
			||||||
			e.preventDefault();
 | 
								e.preventDefault();
 | 
				
			||||||
			e.stopPropagation();
 | 
								e.stopPropagation();
 | 
				
			||||||
@@ -129,7 +320,163 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
			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
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var pasteHandler = function(e) {
 | 
				
			||||||
 | 
								e.preventDefault();
 | 
				
			||||||
 | 
								e.stopPropagation();
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								var items = (e.clipboardData || e.originalEvent.clipboardData).items;
 | 
				
			||||||
 | 
								var items_length = items.length;
 | 
				
			||||||
 | 
								var blobs = [];
 | 
				
			||||||
 | 
								var handled = 0;
 | 
				
			||||||
 | 
								var haveHandled = function() {
 | 
				
			||||||
 | 
									handled += 1;
 | 
				
			||||||
 | 
									if (handled == items_length) {
 | 
				
			||||||
 | 
										
 | 
				
			||||||
 | 
										if (blobs.length > 0) {
 | 
				
			||||||
 | 
											handleUploadFrom( blobs );
 | 
				
			||||||
 | 
										} else {
 | 
				
			||||||
 | 
											// alert("Pasted 0 files");
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								};
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								for (var i = 0; i < items.length; ++i) {
 | 
				
			||||||
 | 
									var item = items[i];
 | 
				
			||||||
 | 
									var mimeType = item.type;
 | 
				
			||||||
 | 
									if (item.kind === 'file') {
 | 
				
			||||||
 | 
										blobs.push(item.getAsFile());
 | 
				
			||||||
 | 
										haveHandled();
 | 
				
			||||||
 | 
										
 | 
				
			||||||
 | 
									} else if (item.kind === 'string') {
 | 
				
			||||||
 | 
										item.getAsString(function(s) {
 | 
				
			||||||
 | 
											blobs.push( new Blob([s], {type : mimeType}) );
 | 
				
			||||||
 | 
											haveHandled();
 | 
				
			||||||
 | 
										});
 | 
				
			||||||
 | 
										
 | 
				
			||||||
 | 
									} else {
 | 
				
			||||||
 | 
										// file|string are the only supported types in
 | 
				
			||||||
 | 
										// all browsers at the time of writing
 | 
				
			||||||
 | 
										// Ignore future possibilities
 | 
				
			||||||
 | 
										haveHandled();
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
							var enablePasteHandler = function() {
 | 
				
			||||||
 | 
								document.addEventListener('paste', pasteHandler);
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
							var disablePasteHandler = function() {
 | 
				
			||||||
 | 
								document.removeEventListener('paste', pasteHandler);
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// Embed in DOM, load default area
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		$("body").append($f);
 | 
							$("body").append($f);
 | 
				
			||||||
 | 
							if (!contented.supportsDrop()) {
 | 
				
			||||||
 | 
								setType('file');
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								setType('drag');
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// Drawing board
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							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 = $("<div>").attr('id', db_id);
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								DrawingBoard.Control.ContentedUpload = DrawingBoard.Control.extend({
 | 
				
			||||||
 | 
									name: 'upload',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									initialize: function() {
 | 
				
			||||||
 | 
										var $el = this.$el;
 | 
				
			||||||
 | 
										
 | 
				
			||||||
 | 
										$el.append('<button class="contented-drawingboard-upload">Upload</button>');
 | 
				
			||||||
 | 
										$el.on('click', '.contented-drawingboard-upload', $.proxy(function(e) {
 | 
				
			||||||
 | 
											e.preventDefault();
 | 
				
			||||||
 | 
											e.stopPropagation();
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											$el.prop('disabled', true);
 | 
				
			||||||
 | 
											$el.text('Saving...');
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											$db.find("canvas")[0].toBlob(function(theBlob) {
 | 
				
			||||||
 | 
												handleUploadFrom([ theBlob ]);
 | 
				
			||||||
 | 
											});
 | 
				
			||||||
 | 
										}, this));
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								$db.css({
 | 
				
			||||||
 | 
									//'width': $f.find(".contented-content-area").width(),
 | 
				
			||||||
 | 
									'height': $f.find(".contented-content-area").height(),
 | 
				
			||||||
 | 
									'overflow': 'hidden'
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								$f.find(".contented-drawing-area").append($db);
 | 
				
			||||||
 | 
								var db = new DrawingBoard.Board(db_id, {
 | 
				
			||||||
 | 
									'controls': [
 | 
				
			||||||
 | 
										'Color',
 | 
				
			||||||
 | 
										'Size',
 | 
				
			||||||
 | 
										'DrawingMode',
 | 
				
			||||||
 | 
										'Navigation',
 | 
				
			||||||
 | 
										'ContentedUpload'
 | 
				
			||||||
 | 
									],
 | 
				
			||||||
 | 
									'controlsPosition': 'center',
 | 
				
			||||||
 | 
									'enlargeYourContainer': false,
 | 
				
			||||||
 | 
									'webStorage': false,
 | 
				
			||||||
 | 
									'droppable': false // don't mess with existing drop support
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Close button
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var ourClose = function () {
 | 
				
			||||||
 | 
								$f.remove(); // remove from dom
 | 
				
			||||||
 | 
								disablePasteHandler();
 | 
				
			||||||
 | 
								onClose(); // upstream close
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
							$f.find(".contented-close").click(function () {
 | 
				
			||||||
 | 
								ourClose();
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Progress bar
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		var setProgressCaption = function(message) {
 | 
							var setProgressCaption = function(message) {
 | 
				
			||||||
			$f.find(".contented-if-progress label").text(message);
 | 
								$f.find(".contented-if-progress label").text(message);
 | 
				
			||||||
@@ -138,7 +485,13 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
			$f.find(".contented-progress-element").css('width', (frc * 100) + "%");
 | 
								$f.find(".contented-progress-element").css('width', (frc * 100) + "%");
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var handleUploadFrom = function (files) {
 | 
							// Common upload handler
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							/**
 | 
				
			||||||
 | 
							 * 
 | 
				
			||||||
 | 
							 * @param {File[]|Blob[]} files 
 | 
				
			||||||
 | 
							 */
 | 
				
			||||||
 | 
							var handleUploadFrom = function(files) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			setProgressCaption("Uploading, please wait...");
 | 
								setProgressCaption("Uploading, please wait...");
 | 
				
			||||||
			setProgressPercentage(0);
 | 
								setProgressPercentage(0);
 | 
				
			||||||
@@ -155,7 +508,7 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
			// ajax request
 | 
								// ajax request
 | 
				
			||||||
			$.ajax({
 | 
								$.ajax({
 | 
				
			||||||
                        url: baseURL + "upload",
 | 
									url: contented.baseURL + "upload",
 | 
				
			||||||
				type: "POST",
 | 
									type: "POST",
 | 
				
			||||||
				data: ajaxData,
 | 
									data: ajaxData,
 | 
				
			||||||
				dataType: 'json', // response type
 | 
									dataType: 'json', // response type
 | 
				
			||||||
@@ -167,7 +520,6 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
					xhr.upload.addEventListener(
 | 
										xhr.upload.addEventListener(
 | 
				
			||||||
						'progress',
 | 
											'progress',
 | 
				
			||||||
						function(ev) {
 | 
											function(ev) {
 | 
				
			||||||
                                    console.log([ev.lengthComputable, ev.loaded, ev.total]);
 | 
					 | 
				
			||||||
							if (ev.lengthComputable) {
 | 
												if (ev.lengthComputable) {
 | 
				
			||||||
								setProgressCaption("Uploading (" + formatBytes(ev.loaded) + " / " + formatBytes(ev.total) + ")...");
 | 
													setProgressCaption("Uploading (" + formatBytes(ev.loaded) + " / " + formatBytes(ev.total) + ")...");
 | 
				
			||||||
								setProgressPercentage(ev.total == 0 ? 0 : ev.loaded / ev.total);
 | 
													setProgressPercentage(ev.total == 0 ? 0 : ev.loaded / ev.total);
 | 
				
			||||||
@@ -178,44 +530,111 @@ var contented = (function ($, currentScriptPath) {
 | 
				
			|||||||
					return xhr;
 | 
										return xhr;
 | 
				
			||||||
				},
 | 
									},
 | 
				
			||||||
				complete: function () {
 | 
									complete: function () {
 | 
				
			||||||
                            setProgressCaption("Upload complete.");
 | 
					 | 
				
			||||||
					setProgressPercentage(1);
 | 
										setProgressPercentage(1);
 | 
				
			||||||
				},
 | 
									},
 | 
				
			||||||
				success: function (data) {
 | 
									success: function (data) {
 | 
				
			||||||
 | 
										setProgressCaption("Upload completed successfully.");
 | 
				
			||||||
					onUploaded(data);
 | 
										onUploaded(data);
 | 
				
			||||||
					ourClose();
 | 
										ourClose();
 | 
				
			||||||
				},
 | 
									},
 | 
				
			||||||
				error: function () {
 | 
									error: function () {
 | 
				
			||||||
                            setProgressCaption("Upload failed.");
 | 
										setProgressCaption("Upload failed!");
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    //
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
        'supportsDrop': supportsDrop,
 | 
					 | 
				
			||||||
        'init': initArea
 | 
					 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
})(
 | 
						var init = function() {
 | 
				
			||||||
    jQuery,
 | 
					 | 
				
			||||||
    (function () {
 | 
					 | 
				
			||||||
        "use strict";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Determine current script path
 | 
							var currentScriptPath = getCurrentScriptPath();
 | 
				
			||||||
        // @ref https://stackoverflow.com/a/26023176
 | 
							var baseURL = currentScriptPath.replace('sdk.js', '');
 | 
				
			||||||
        var scripts = document.querySelectorAll('script[src]');
 | 
					 | 
				
			||||||
        var currentScript = scripts[scripts.length - 1].src;
 | 
					 | 
				
			||||||
        var currentScriptChunks = currentScript.split('/');
 | 
					 | 
				
			||||||
        var currentScriptFile = currentScriptChunks[currentScriptChunks.length - 1];
 | 
					 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
        return currentScript.replace(currentScriptFile, '');
 | 
							// 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
 | 
				
			||||||
 | 
								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"
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						init();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					})()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,148 +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;
 | 
					 | 
				
			||||||
    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 {
 | 
					 | 
				
			||||||
    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>
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    <div class="contented-content-area">
 | 
					 | 
				
			||||||
        <div class="contented-upload-if contented-if-drag contented-active">
 | 
					 | 
				
			||||||
            <label>Drop files 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 »</button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div class="contented-upload-if contented-if-paste">
 | 
					 | 
				
			||||||
            <textarea placeholder="Paste content here"></textarea>
 | 
					 | 
				
			||||||
            <button class="contented-paste-upload">Upload »</button>
 | 
					 | 
				
			||||||
        </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>
 | 
					 | 
				
			||||||
							
								
								
									
										209
									
								
								storage.go
									
									
									
									
									
										Normal 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 fmt.Errorf("Reading hot storage files: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cutOff := time.Now().Add(-TierMigrationAfter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, dirent := range dirents {
 | 
				
			||||||
 | 
							fi, err := dirent.Info()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Reading hot storage files: %w", 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.hot.ReadFile(context.Background(), fileHash)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Read %q from hot storage: %w", fileHash, err) // can't cat local file
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							err = ts.cold.SaveFile(context.Background(), fileHash, fi.Size(), rc)
 | 
				
			||||||
 | 
							_ = rc.Close()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Write %q to cold storage: %w", fileHash, 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 fmt.Errorf("Remove %q from hot storage: %w", 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
 | 
				
			||||||
							
								
								
									
										144
									
								
								thumb.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,144 @@
 | 
				
			|||||||
 | 
					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.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)
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										41
									
								
								upload.go
									
									
									
									
									
								
							
							
						
						@@ -1,6 +1,7 @@
 | 
				
			|||||||
package contented
 | 
					package contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
	"crypto/sha512"
 | 
						"crypto/sha512"
 | 
				
			||||||
	"encoding/hex"
 | 
						"encoding/hex"
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
@@ -9,24 +10,29 @@ 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) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	err := r.ParseMultipartForm(this.opts.MaxUploadBytes * 2)
 | 
						if !this.opts.EnableUpload {
 | 
				
			||||||
 | 
							http.Error(w, "Server is read-only", 403)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						remoteIP := this.remoteIP(r)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err := r.ParseMultipartForm(0) // buffer upload in temporary files on disk, not memory
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Printf("%s Invalid request: %s\n", r.RemoteAddr, err.Error())
 | 
							log.Printf("%s Invalid request: %s\n", remoteIP, err.Error())
 | 
				
			||||||
		http.Error(w, "Invalid request", 400)
 | 
							http.Error(w, "Invalid request", 400)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if r.MultipartForm == nil || r.MultipartForm.File == nil || len(r.MultipartForm.File["f"]) < 1 {
 | 
						if r.MultipartForm == nil || r.MultipartForm.File == nil || len(r.MultipartForm.File["f"]) < 1 {
 | 
				
			||||||
		log.Printf("%s Invalid request: no multipart content\n", r.RemoteAddr)
 | 
							log.Printf("%s Invalid request: no multipart content\n", remoteIP)
 | 
				
			||||||
		http.Error(w, "Invalid request", 400)
 | 
							http.Error(w, "Invalid request", 400)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -36,14 +42,14 @@ func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
 | 
				
			|||||||
	for _, fhs := range r.MultipartForm.File["f"] {
 | 
						for _, fhs := range r.MultipartForm.File["f"] {
 | 
				
			||||||
		f, err := fhs.Open()
 | 
							f, err := fhs.Open()
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("%s Internal error: %s\n", r.RemoteAddr, err.Error())
 | 
								log.Printf("%s Internal error: %s\n", remoteIP, err.Error())
 | 
				
			||||||
			http.Error(w, "Internal error", 500)
 | 
								http.Error(w, "Internal error", 500)
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		path, err := this.handleUploadFile(f, fhs, remoteIP(r))
 | 
							path, err := this.handleUploadFile(f, fhs, remoteIP)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("%s Upload failed: %s\n", r.RemoteAddr, err.Error())
 | 
								log.Printf("%s Upload failed: %s\n", remoteIP, err.Error())
 | 
				
			||||||
			http.Error(w, "Upload failed", 500)
 | 
								http.Error(w, "Upload failed", 500)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,7 +58,7 @@ func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	jb, err := json.Marshal(ret)
 | 
						jb, err := json.Marshal(ret)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Printf("%s Internal error: %s\n", r.RemoteAddr, err.Error())
 | 
							log.Printf("%s Internal error: %s\n", remoteIP, err.Error())
 | 
				
			||||||
		http.Error(w, "Internal error", 500)
 | 
							http.Error(w, "Internal error", 500)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -87,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, 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
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	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")
 | 
				
			||||||
 
 | 
				
			|||||||