Compare commits
70 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
3
.hgignore → .gitignore
vendored
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
|
11
.hgtags
11
.hgtags
@ -1,11 +0,0 @@
|
|||||||
e2250a7fd29052ea767f18e1459cabea4cd7efd3 release-1.0.0
|
|
||||||
b8975b9e75648a7c2a5003c67db92cf2216e01c0 release-1.0.1
|
|
||||||
c7b699105bd166e7a01aa0e678d34624680bf81e release-1.1.0
|
|
||||||
c7b699105bd166e7a01aa0e678d34624680bf81e release-1.1.0
|
|
||||||
0000000000000000000000000000000000000000 release-1.1.0
|
|
||||||
0000000000000000000000000000000000000000 release-1.1.0
|
|
||||||
cfb1e028fd0627614aa01184893f9f29f20a347e release-1.1.0
|
|
||||||
cfb1e028fd0627614aa01184893f9f29f20a347e release-1.1.0
|
|
||||||
0000000000000000000000000000000000000000 release-1.1.0
|
|
||||||
0000000000000000000000000000000000000000 release-1.1.0
|
|
||||||
98da2ebf0d50dffe8b625457a639bc2f15519714 release-1.1.0
|
|
32
Makefile
32
Makefile
@ -2,7 +2,7 @@
|
|||||||
# Makefile for contented
|
# Makefile for contented
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION:=1.2.0
|
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
|
|
||||||
|
|
||||||
|
17
Metadata.go
17
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"
|
"github.com/speps/go-hashids"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -47,7 +48,7 @@ func idToString(v uint64) string {
|
|||||||
hd := hashids.NewData()
|
hd := hashids.NewData()
|
||||||
hd.Salt = hashIdSalt
|
hd.Salt = hashIdSalt
|
||||||
hd.MinLength = hashIdMinLength
|
hd.MinLength = hashIdMinLength
|
||||||
h, _ := hashids.NewWithData(hd)
|
h := hashids.NewWithData(hd)
|
||||||
s, _ := h.EncodeInt64([]int64{int64(v)})
|
s, _ := h.EncodeInt64([]int64{int64(v)})
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@ -62,9 +63,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
|
||||||
|
162
README.md
Normal file
162
README.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# contented
|
||||||
|
|
||||||
|
[![](doc/image1.thumb.png)](doc/image1.png)
|
||||||
|
|
||||||
|
A file / image / paste upload server with a focus on embedding.
|
||||||
|
|
||||||
|
You can use contented as a standalone upload server, or you can use the SDK to embed its upload widget into another website.
|
||||||
|
|
||||||
|
The name is a pun on "content" and the -d suffix for server daemons.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Use local disk or S3-backed storage
|
||||||
|
- Drag and drop upload
|
||||||
|
- Multiple files upload
|
||||||
|
- Pastebin upload
|
||||||
|
- Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js))
|
||||||
|
- Ctrl-V upload
|
||||||
|
- Galleries and nested galleries
|
||||||
|
- SDK-oriented design for embedding, including CORS support
|
||||||
|
- Mobile friendly HTML interface
|
||||||
|
- Preserves uploaded filename and content-type metadata
|
||||||
|
- Hash verification (SHA512/256)
|
||||||
|
- Detect duplicate upload content and reuse storage
|
||||||
|
- Options to limit the upload filesize, upload bandwidth, and maximum source filesize for thumbnailing
|
||||||
|
- Short URLs (using [Hashids](http://hashids.org) algorithm)
|
||||||
|
- Image thumbnailing
|
||||||
|
- Optional multi-tenant binary (`contented-multi`)
|
||||||
|
|
||||||
|
## Usage (Server)
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage of contented:
|
||||||
|
-concurrentthumbs int
|
||||||
|
Simultaneous thumbnail generation (default 16)
|
||||||
|
-data string
|
||||||
|
Directory for stored content (default ".")
|
||||||
|
-db string
|
||||||
|
Path for metadata database (default "contented.db")
|
||||||
|
-diskFilesWorldReadable
|
||||||
|
Save files as 0644 instead of 0600
|
||||||
|
-enableHomepage
|
||||||
|
Enable homepage (disable for embedded use only) (default true)
|
||||||
|
-enableUpload
|
||||||
|
Enable uploads (disable for read-only mode) (default true)
|
||||||
|
-listen string
|
||||||
|
IP/Port to bind server (default "127.0.0.1:80")
|
||||||
|
-max int
|
||||||
|
Maximum size of uploaded files in MiB (set zero for unlimited) (default 8)
|
||||||
|
-speed int
|
||||||
|
Maximum upload speed in bytes/sec (set zero for unlimited)
|
||||||
|
-title string
|
||||||
|
Title used in web interface (default "contented")
|
||||||
|
-trustXForwardedFor
|
||||||
|
Trust X-Forwarded-For reverse proxy headers
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are hosting behind a reverse proxy, remember to set its post body size parameter appropriately (e.g. `client_max_body_size` for nginx).
|
||||||
|
|
||||||
|
## Usage (HTTP)
|
||||||
|
|
||||||
|
The server responds on the following URLs:
|
||||||
|
|
||||||
|
URL |Method |Description
|
||||||
|
---------------------|-------|---
|
||||||
|
`/get/{ID}` |`GET` |Download item content
|
||||||
|
`/p/{ID}` |`GET` |Preview item content (HTML)
|
||||||
|
`/p/{ID}-{ID}-...` |`GET` |Preview multiple item content (HTML)
|
||||||
|
`/info/{ID}` |`GET` |Get item content metadata (JSON)
|
||||||
|
`/thumb/{Type}/{ID}` |`GET` |Get item thumbnail image (JPEG). "Type" should match `[sbtmlh]`.
|
||||||
|
`/about` |`GET` |Get server metadata (JSON)
|
||||||
|
|
||||||
|
## Usage (Embedding for web)
|
||||||
|
|
||||||
|
Your webpage should load the SDK from the contented server, then call the `contented.init` function to display the upload widget over the top of an existing DOM element:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
|
||||||
|
contented.init("#target");
|
||||||
|
```
|
||||||
|
|
||||||
|
You can optionally supply additional ordered parameters to `contented.init`:
|
||||||
|
|
||||||
|
1. A callback, that will be passed an array of file IDs of any uploaded items
|
||||||
|
2. A callback, that will be called if the SDK widget is closed
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
2023-05-20: 1.5.1
|
||||||
|
- Improve support for albums with no images, and for albums with missing interior images
|
||||||
|
|
||||||
|
2023-05-19: 1.5.0
|
||||||
|
- Feature: Support S3-backed storage
|
||||||
|
- Feature: New `contented-multi` binary to host multiple server configurations from a single process
|
||||||
|
- Enhancement: Better client-side caching for thumbnails
|
||||||
|
- Option to cap source filesize for thumbnailing (default 20MiB)
|
||||||
|
|
||||||
|
2023-05-17: 1.4.0
|
||||||
|
- BREAKING: Remove support for some old web browsers (require jQuery 3, ES6 template literals, Promises, Canvas.toBlob)
|
||||||
|
- Feature: Initial album support with custom titles
|
||||||
|
- Feature: Support readonly mode
|
||||||
|
- Enhancement: Use lazy-loading for large image galleries
|
||||||
|
- Enhancement: Better tab titles on preview pages
|
||||||
|
- Fix an issue with continuing server-side thumbnailing work even if the http client has gone away
|
||||||
|
- Fix an issue with not warning on colliding hashIDs
|
||||||
|
- Internal: Refactor the SDK's initialization phase
|
||||||
|
- Internal: Update bolt library dependency
|
||||||
|
|
||||||
|
2020-07-25: 1.3.1
|
||||||
|
- Fix an issue with dependencies causing failure to compile in Modules mode
|
||||||
|
|
||||||
|
2020-07-25: 1.3.0
|
||||||
|
- Feature: Option to limit concurrent thumbnail generation
|
||||||
|
- Enhancement: Set charset=UTF-8 when serving user-submitted text/plain content
|
||||||
|
- Fix an issue with large memory usage for multipart file uploads
|
||||||
|
|
||||||
|
2018-06-09: 1.2.1
|
||||||
|
- Feature: Add OpenGraph tags on preview pages, for rich metadata in chat applications
|
||||||
|
- Update thumbnailing library to improve quality
|
||||||
|
- Use dep for vendoring
|
||||||
|
- [⬇️ contented-1.2.1-win32.7z](https://git.ivysaur.me/attachments/88dea4f7-e314-4325-a957-096dcf8cdecc) *(1.51 MiB)*
|
||||||
|
- [⬇️ contented-1.2.1-src.zip](https://git.ivysaur.me/attachments/6fd2b963-3be4-48a6-a5bf-6f273bcaea24) *(1.49 MiB)*
|
||||||
|
- [⬇️ contented-1.2.1-linux64.tar.gz](https://git.ivysaur.me/attachments/c536f764-0250-4d67-886a-4797946e1124) *(2.21 MiB)*
|
||||||
|
|
||||||
|
2017-11-18: 1.2.0
|
||||||
|
- Feature: Thumbnail support
|
||||||
|
- Feature: File preview page
|
||||||
|
- Feature: Album mode (via URL `/p/{file1}-{file2}-...`)
|
||||||
|
- Feature: New `-diskFilesWorldReadable` option to save files with `0644` mode
|
||||||
|
- [⬇️ contented-1.2.0-win32.7z](https://git.ivysaur.me/attachments/f3453b62-b2a7-4e77-9b04-44c99dec35ba) *(1.36 MiB)*
|
||||||
|
- [⬇️ contented-1.2.0-src.zip](https://git.ivysaur.me/attachments/a6c1ecfb-fd6a-44b5-9dc8-aea7c439d1e6) *(178.94 KiB)*
|
||||||
|
- [⬇️ contented-1.2.0-linux64.tar.gz](https://git.ivysaur.me/attachments/6234754b-af17-4a72-8b66-56a5db21c7c7) *(2.03 MiB)*
|
||||||
|
|
||||||
|
2017-10-15: 1.1.0
|
||||||
|
- Feature: Drawing mode
|
||||||
|
- Feature: Ctrl+V image upload
|
||||||
|
- Feature: Option to trust X-Forwarded-For headers when using a reverse proxy
|
||||||
|
- Feature: Add `getDownloadURL`, `getInfoJSONURL`, `getPreviewURL` SDK methods
|
||||||
|
- Feature: Option to disable uploading via the homepage
|
||||||
|
- Feature: Add button to repeat when uploading from homepage
|
||||||
|
- Enhancement: Automatically load library dependencies
|
||||||
|
- Enhancement: Display homepage widget using the full screen size
|
||||||
|
- Include drawingboard.js 0.4.6 (MIT license)
|
||||||
|
- Fix a cosmetic issue with javascript console output
|
||||||
|
- Fix a cosmetic issue with error messages if an upload failed
|
||||||
|
- [⬇️ contented-1.1.0-win32.7z](https://git.ivysaur.me/attachments/bfb0a7fe-bf95-4d0e-933b-8137bc8071a4) *(1.11 MiB)*
|
||||||
|
- [⬇️ contented-1.1.0-src.zip](https://git.ivysaur.me/attachments/67401341-724f-4ea2-b9c7-44d08ab9d38a) *(142.82 KiB)*
|
||||||
|
- [⬇️ contented-1.1.0-linux64.tar.gz](https://git.ivysaur.me/attachments/a13752dd-5228-4830-b61d-0f7cc568b2ae) *(1.67 MiB)*
|
||||||
|
|
||||||
|
2017-10-08: 1.0.1
|
||||||
|
- Fix an issue with CORS preflight requests
|
||||||
|
- Fix an issue with index URLs
|
||||||
|
- [⬇️ contented-1.0.1-win32.7z](https://git.ivysaur.me/attachments/a873d510-da09-4797-95e9-ffcad690a77b) *(1.10 MiB)*
|
||||||
|
- [⬇️ contented-1.0.1-src.zip](https://git.ivysaur.me/attachments/43ac17d6-b6f1-4da7-98e9-b8af6fb5551a) *(109.08 KiB)*
|
||||||
|
- [⬇️ contented-1.0.1-linux64.tar.gz](https://git.ivysaur.me/attachments/34d74bed-db3f-4cef-a76f-266f0b9e6017) *(1.65 MiB)*
|
||||||
|
|
||||||
|
2017-10-08: 1.0.0
|
||||||
|
- Initial public release
|
||||||
|
- Include jQuery 1.12.4 (MIT license)
|
||||||
|
- [⬇️ contented-1.0.0-win32.7z](https://git.ivysaur.me/attachments/4ef132cf-dac8-4bcf-9da7-14ca1366e815) *(1.10 MiB)*
|
||||||
|
- [⬇️ contented-1.0.0-src.zip](https://git.ivysaur.me/attachments/74d77b3f-557b-44bf-9645-7b3b25ab17c1) *(102.45 KiB)*
|
||||||
|
- [⬇️ contented-1.0.0-linux64.tar.gz](https://git.ivysaur.me/attachments/1c28a913-686b-44cf-b63d-db22968a93b6) *(1.65 MiB)*
|
84
Server.go
84
Server.go
@ -1,8 +1,10 @@
|
|||||||
package contented
|
package contented
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -10,24 +12,52 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boltdb/bolt"
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
"github.com/mxk/go-flowrate/flowrate"
|
"github.com/mxk/go-flowrate/flowrate"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticAssets embed.FS
|
||||||
|
|
||||||
var SERVER_HEADER string = `contented/0.0.0-dev`
|
var SERVER_HEADER string = `contented/0.0.0-dev`
|
||||||
|
|
||||||
|
const (
|
||||||
|
DEFAULT_MAX_CONCURRENT_THUMBS = 16
|
||||||
|
DEFAULT_MAX_THUMBSIZE = 20 * 1024 * 1024 // 20 MiB
|
||||||
|
|
||||||
|
ALBUM_MIMETYPE = `contented/album`
|
||||||
|
|
||||||
|
STORAGE_LOCAL int = 0
|
||||||
|
STORAGE_S3 int = 1
|
||||||
|
)
|
||||||
|
|
||||||
type ServerPublicProperties struct {
|
type ServerPublicProperties struct {
|
||||||
AppTitle string
|
AppTitle string
|
||||||
MaxUploadBytes int64
|
MaxUploadBytes int64
|
||||||
|
CanonicalBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerOptions struct {
|
type ServerOptions struct {
|
||||||
|
StorageType int // STORAGE_xx
|
||||||
DataDirectory string
|
DataDirectory string
|
||||||
|
DataS3Options struct {
|
||||||
|
Hostname string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Bucket string
|
||||||
|
Prefix string
|
||||||
|
}
|
||||||
|
|
||||||
DBPath string
|
DBPath string
|
||||||
DiskFilesWorldReadable bool
|
DiskFilesWorldReadable bool
|
||||||
BandwidthLimit int64
|
BandwidthLimit int64
|
||||||
TrustXForwardedFor bool
|
TrustXForwardedFor bool
|
||||||
EnableHomepage bool
|
EnableHomepage bool
|
||||||
|
EnableUpload bool
|
||||||
|
MaxConcurrentThumbs int
|
||||||
|
MaxThumbSizeBytes int64
|
||||||
ServerPublicProperties
|
ServerPublicProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,9 +71,12 @@ func (this *ServerOptions) FileMode() os.FileMode {
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
opts ServerOptions
|
opts ServerOptions
|
||||||
|
s3client *minio.Client
|
||||||
db *bolt.DB
|
db *bolt.DB
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
|
thumbnailSem chan struct{}
|
||||||
metadataBucket []byte
|
metadataBucket []byte
|
||||||
|
staticDir fs.FS // interface
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(opts *ServerOptions) (*Server, error) {
|
func NewServer(opts *ServerOptions) (*Server, error) {
|
||||||
@ -53,6 +86,37 @@ func NewServer(opts *ServerOptions) (*Server, error) {
|
|||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.opts.MaxConcurrentThumbs <= 0 {
|
||||||
|
s.opts.MaxConcurrentThumbs = DEFAULT_MAX_CONCURRENT_THUMBS // default
|
||||||
|
log.Printf("Allowing %d concurrent thumbnails", s.opts.MaxConcurrentThumbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.opts.MaxThumbSizeBytes <= 0 {
|
||||||
|
s.opts.MaxThumbSizeBytes = DEFAULT_MAX_THUMBSIZE
|
||||||
|
log.Printf("Allowing thumbnails for files up to %d byte(s)", s.opts.MaxThumbSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.staticDir, _ = fs.Sub(staticAssets, `static`) // can't fail
|
||||||
|
|
||||||
|
// Maybe open s3 connection
|
||||||
|
if s.opts.StorageType == STORAGE_S3 {
|
||||||
|
cl, err := minio.New(opts.DataS3Options.Hostname, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(opts.DataS3Options.AccessKey, opts.DataS3Options.SecretKey, ""),
|
||||||
|
Secure: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Connecting to S3 host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.s3client = cl
|
||||||
|
}
|
||||||
|
|
||||||
|
// "fill" the thumbnailer semaphore
|
||||||
|
s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs)
|
||||||
|
for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 {
|
||||||
|
s.thumbnailSem <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
b, err := bolt.Open(opts.DBPath, 0644, bolt.DefaultOptions)
|
b, err := bolt.Open(opts.DBPath, 0644, bolt.DefaultOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -125,7 +189,7 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):])
|
this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):])
|
||||||
|
|
||||||
} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) {
|
} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) {
|
||||||
this.handlePreview(w, r.URL.Path[len(previewUrlPrefix):])
|
this.handlePreview(r.Context(), w, r.URL.Path[len(previewUrlPrefix):])
|
||||||
|
|
||||||
} else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) {
|
} else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) {
|
||||||
parts := rxThumbUrl.FindStringSubmatch(r.URL.Path)
|
parts := rxThumbUrl.FindStringSubmatch(r.URL.Path)
|
||||||
@ -141,11 +205,17 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Blanket allow (headers already set)
|
// Blanket allow (headers already set)
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
|
|
||||||
} else if r.Method == "GET" && r.URL.Path == `/` && this.opts.EnableHomepage {
|
} else if r.Method == "GET" {
|
||||||
http.Redirect(w, r, `/index.html`, http.StatusFound)
|
|
||||||
|
|
||||||
} else if static, err := Asset(r.URL.Path[1:]); err == nil && r.Method == "GET" && (this.opts.EnableHomepage || r.URL.Path != `/index.html`) {
|
// Conditionally block homepage access
|
||||||
http.ServeContent(w, r, r.URL.Path[1:], this.startTime, bytes.NewReader(static))
|
if !this.opts.EnableHomepage && (r.URL.Path == `/index.html` || r.URL.Path == `/`) {
|
||||||
|
http.Error(w, "Not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static html/css/js assets
|
||||||
|
// http.FileServer transparently redirects index.html->/ internally
|
||||||
|
http.FileServer(http.FS(this.staticDir)).ServeHTTP(w, r)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Not found", 404)
|
http.Error(w, "Not found", 404)
|
||||||
|
14
TODO.txt
14
TODO.txt
@ -1,14 +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)
|
|
||||||
|
|
||||||
- Prevent selecting around the toggle buttons (firefox for android)
|
|
||||||
|
|
||||||
- Detect when pasting creates one large file and one ~150 byte file(?? why does this happen?)
|
|
||||||
|
|
||||||
- Detect when pasting an image URL, offer to download it (clientside?)
|
|
||||||
|
|
@ -1,92 +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 upload
|
|
||||||
- Multiple files upload
|
|
||||||
- Pastebin upload
|
|
||||||
- Custom drawing upload ([url=https://github.com/Leimi/drawingboard.js]via[/url])
|
|
||||||
- Ctrl-V upload
|
|
||||||
- SDK-oriented design for embedding, including CORS support
|
|
||||||
- Mobile friendly HTML interface
|
|
||||||
- Preserves uploaded filename and content-type metadata
|
|
||||||
- Hash verification (SHA512/256)
|
|
||||||
- Detect duplicate upload content and reuse storage
|
|
||||||
- Options to limit the upload filesize and the upload bandwidth
|
|
||||||
- Short URLs (using [url=http://hashids.org]Hashids[/url] algorithm)
|
|
||||||
- Image thumbnailing
|
|
||||||
|
|
||||||
=USAGE (SERVER)=
|
|
||||||
|
|
||||||
`Usage of contented:
|
|
||||||
-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)
|
|
||||||
-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:
|
|
||||||
|
|
||||||
- `/get/{ID}`: Download item content
|
|
||||||
- `/info/{ID}`: Get item content metadata (JSON)
|
|
||||||
- `/thumb/{Type}/{ID}`: Get item thumbnail image
|
|
||||||
- `/about`: Get server metadata (JSON)
|
|
||||||
|
|
||||||
=USAGE (EMBEDDING FOR WEB)=
|
|
||||||
|
|
||||||
Your webpage should load the SDK from the contented server, then call the `contented.init` function to display the upload widget over the top of an existing DOM element. Your callback will be passed an array of file IDs of any uploaded items.
|
|
||||||
|
|
||||||
`<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
|
|
||||||
contented.init("#target", function(/* String[] */ items) {});
|
|
||||||
`
|
|
||||||
|
|
||||||
=CHANGELOG=
|
|
||||||
|
|
||||||
2017-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
|
|
||||||
|
|
||||||
2017-10-15: 1.1.0
|
|
||||||
- Feature: Drawing mode
|
|
||||||
- Feature: Ctrl+V image upload
|
|
||||||
- Feature: Option to trust X-Forwarded-For headers when using a reverse proxy
|
|
||||||
- Feature: Add `getDownloadURL`, `getInfoJSONURL`, `getPreviewURL` SDK methods
|
|
||||||
- Feature: Option to disable uploading via the homepage
|
|
||||||
- Feature: Add button to repeat when uploading from homepage
|
|
||||||
- Enhancement: Automatically load library dependencies
|
|
||||||
- Enhancement: Display homepage widget using the full screen size
|
|
||||||
- Include drawingboard.js 0.4.6 (MIT license)
|
|
||||||
- Fix a cosmetic issue with javascript console output
|
|
||||||
- Fix a cosmetic issue with error messages if an upload failed
|
|
||||||
|
|
||||||
2017-10-08: 1.0.1
|
|
||||||
- Fix an issue with CORS preflight requests
|
|
||||||
- Fix an issue with index URLs
|
|
||||||
|
|
||||||
2017-10-08: 1.0.0
|
|
||||||
- Initial public release
|
|
||||||
- Include jQuery 1.12.4 (MIT license)
|
|
58
cmd/contented-multi/main.go
Normal file
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,30 +12,59 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd()
|
||||||
|
|
||||||
listenAddr := flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
|
var (
|
||||||
dataDir := flag.String("data", cwd, "Directory for stored content")
|
listenAddr = flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
|
||||||
dbPath := flag.String("db", "contented.db", "Path for metadata database")
|
dataDir = flag.String("data", cwd, "Directory for stored content")
|
||||||
appTitle := flag.String("title", "contented", "Title used in web interface")
|
dbPath = flag.String("db", "contented.db", "Path for metadata database")
|
||||||
maxUploadMb := flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
|
appTitle = flag.String("title", "contented", "Title used in web interface")
|
||||||
maxUploadSpeed := flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
|
maxUploadMb = flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
|
||||||
trustXForwardedFor := flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
|
maxUploadSpeed = flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
|
||||||
enableHomepage := flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
|
trustXForwardedFor = flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
|
||||||
diskFilesWorldReadable := flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
|
enableHomepage = flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
|
||||||
|
enableUpload = flag.Bool("enableUpload", true, "Enable uploads (disable for read-only mode)")
|
||||||
|
diskFilesWorldReadable = flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
|
||||||
|
maxConcurrentThumbs = flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
|
||||||
|
s3Host = flag.String("s3hostname", "", "S3 Server hostname")
|
||||||
|
s3AccessKey = flag.String("s3access", "", "S3 Access key")
|
||||||
|
s3SecretKey = flag.String("s3secret", "", "S3 Secret key")
|
||||||
|
s3Bucket = flag.String("s3bucket", "", "S3 Bucket")
|
||||||
|
s3Prefix = flag.String("s3prefix", "", "S3 object prefix")
|
||||||
|
)
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
svr, err := contented.NewServer(&contented.ServerOptions{
|
opts := contented.ServerOptions{
|
||||||
DataDirectory: *dataDir,
|
|
||||||
DBPath: *dbPath,
|
DBPath: *dbPath,
|
||||||
BandwidthLimit: int64(*maxUploadSpeed),
|
BandwidthLimit: int64(*maxUploadSpeed),
|
||||||
TrustXForwardedFor: *trustXForwardedFor,
|
TrustXForwardedFor: *trustXForwardedFor,
|
||||||
EnableHomepage: *enableHomepage,
|
EnableHomepage: *enableHomepage,
|
||||||
|
EnableUpload: *enableUpload,
|
||||||
DiskFilesWorldReadable: *diskFilesWorldReadable,
|
DiskFilesWorldReadable: *diskFilesWorldReadable,
|
||||||
|
MaxConcurrentThumbs: *maxConcurrentThumbs,
|
||||||
ServerPublicProperties: contented.ServerPublicProperties{
|
ServerPublicProperties: contented.ServerPublicProperties{
|
||||||
AppTitle: *appTitle,
|
AppTitle: *appTitle,
|
||||||
MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
|
MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if len(*s3AccessKey) > 0 {
|
||||||
|
opts.StorageType = contented.STORAGE_S3
|
||||||
|
opts.DataS3Options.Hostname = *s3Host
|
||||||
|
opts.DataS3Options.AccessKey = *s3AccessKey
|
||||||
|
opts.DataS3Options.SecretKey = *s3SecretKey
|
||||||
|
opts.DataS3Options.Bucket = *s3Bucket
|
||||||
|
opts.DataS3Options.Prefix = *s3Prefix
|
||||||
|
|
||||||
|
} else if len(*dataDir) > 0 {
|
||||||
|
opts.StorageType = contented.STORAGE_LOCAL
|
||||||
|
opts.DataDirectory = *dataDir
|
||||||
|
|
||||||
|
} else {
|
||||||
|
log.Println("Please specify either the -data or -s3__ options.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
svr, err := contented.NewServer(&opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err.Error())
|
log.Println(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
BIN
doc/image1.thumb.png
Normal file
BIN
doc/image1.thumb.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
26
download.go
26
download.go
@ -4,7 +4,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) {
|
func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) {
|
||||||
@ -28,7 +27,7 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load file
|
// Load file
|
||||||
f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
|
f, err := this.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if _, ok := f.(io.ReadSeeker); ! ok {
|
||||||
|
// Stream directly, no support for bytes/etag
|
||||||
|
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", m.FileSize))
|
||||||
|
_, err := io.Copy(w, f)
|
||||||
|
return err
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Allow range requests, if-modified-since, and so on
|
||||||
http.ServeContent(w, r, "", m.UploadTime, f)
|
http.ServeContent(w, r, "", m.UploadTime, f)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
33
go.mod
Normal file
33
go.mod
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
module code.ivysaur.me/contented
|
||||||
|
|
||||||
|
require (
|
||||||
|
code.ivysaur.me/thumbnail v1.0.2
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
|
||||||
|
github.com/speps/go-hashids v1.0.0
|
||||||
|
go.etcd.io/bbolt v1.3.7
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
code.ivysaur.me/imagequant/v2 v2.12.6 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.16.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
|
github.com/minio/minio-go/v7 v7.0.52 // indirect
|
||||||
|
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/rs/xid v1.4.0 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.0 // indirect
|
||||||
|
golang.org/x/crypto v0.6.0 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20200618115811-c13761719519 // indirect
|
||||||
|
golang.org/x/net v0.7.0 // indirect
|
||||||
|
golang.org/x/sys v0.5.0 // indirect
|
||||||
|
golang.org/x/text v0.7.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
go 1.19
|
69
go.sum
Normal file
69
go.sum
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
code.ivysaur.me/imagequant/v2 v2.12.6 h1:xYrGj6GOdAcutmzqBxG7bDZ70r4jYHADOCZ+ktyMU3Y=
|
||||||
|
code.ivysaur.me/imagequant/v2 v2.12.6/go.mod h1:seCAm0sP2IBsb1YNBj4D+EZovIuGe16+6Xo0aiGyhDU=
|
||||||
|
code.ivysaur.me/thumbnail v1.0.2 h1:vQaRPbBZOUGpr4b5rrUOHiZv08XSRJ83uu64WXFx7mo=
|
||||||
|
code.ivysaur.me/thumbnail v1.0.2/go.mod h1:sXeHBfmPfiSe5ZBKsbGSES13C9OSZq0WmT4yZ/XBeeE=
|
||||||
|
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
|
||||||
|
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||||
|
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.52 h1:8XhG36F6oKQUDDSuz6dY3rioMzovKjW40W6ANuN0Dps=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.52/go.mod h1:IbbodHyjUAguneyucUaahv+VMNs/EOTV9du7A7/Z3HU=
|
||||||
|
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
|
||||||
|
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||||
|
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/speps/go-hashids v1.0.0 h1:jdFC07PrExRM4Og5Ev4411Tox75aFpkC77NlmutadNI=
|
||||||
|
github.com/speps/go-hashids v1.0.0/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||||
|
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||||
|
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||||
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
|
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
|
||||||
|
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b h1:5rOiLYVqtE+JehJPVJTXQJaP8aT3cpJC1Iy22+5WLFU=
|
||||||
|
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||||
|
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
128
preview.go
128
preview.go
@ -1,6 +1,8 @@
|
|||||||
package contented
|
package contented
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"log"
|
"log"
|
||||||
@ -10,15 +12,56 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (this *Server) handlePreview(w http.ResponseWriter, fileIDList string) {
|
func (this *Server) handlePreview(ctx context.Context, w http.ResponseWriter, fileIDList string) {
|
||||||
|
|
||||||
fileIDs := strings.Split(fileIDList, `-`)
|
fileIDs := strings.Split(fileIDList, `-`)
|
||||||
|
|
||||||
|
// Early get metadata for the first listed element
|
||||||
|
specialTitle := ""
|
||||||
|
if len(fileIDs) == 1 {
|
||||||
|
mFirst, err := this.Metadata(fileIDs[0])
|
||||||
|
if err != nil { // Same error handling as below -
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.Error(w, "Not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println(err.Error())
|
||||||
|
http.Error(w, "Internal error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
specialTitle = mFirst.Filename + " (" + fileIDs[0] + ")"
|
||||||
|
} else {
|
||||||
|
specialTitle = fmt.Sprintf("%d images", len(fileIDs))
|
||||||
|
}
|
||||||
|
|
||||||
tmpl := `<!DOCTYPE html>
|
tmpl := `<!DOCTYPE html>
|
||||||
<html>
|
<html prefix="og: http://ogp.me/ns#">
|
||||||
<head>
|
<head>
|
||||||
<title>` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `</title>
|
<title>` + html.EscapeString(specialTitle+" | "+this.opts.ServerPublicProperties.AppTitle) + `</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta property="og:title" content="` + html.EscapeString(this.opts.ServerPublicProperties.AppTitle) + `" />
|
||||||
|
<meta property="og: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">
|
<style type="text/css">
|
||||||
html, body {
|
html, body {
|
||||||
background: #333;
|
background: #333;
|
||||||
@ -34,7 +77,21 @@ html, body {
|
|||||||
.thumbnail {
|
.thumbnail {
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
|
height: 340px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.thumbnail-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
line-height: 1.5em;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
background: red;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
.properties {
|
.properties {
|
||||||
background: #000;
|
background: #000;
|
||||||
@ -52,19 +109,79 @@ html, body {
|
|||||||
m, err := this.Metadata(fileID)
|
m, err := this.Metadata(fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|
||||||
|
// If this is just one image out of many, show a 404 box and continue to show the other entries
|
||||||
|
// But if this is only a single image requested, abandon the whole pageload
|
||||||
|
|
||||||
|
if len(fileIDs) == 1 {
|
||||||
http.Error(w, "Not found", 404)
|
http.Error(w, "Not found", 404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tmpl += `
|
||||||
|
<div class="entry">
|
||||||
|
<div class="thumbnail">
|
||||||
|
<img loading="lazy" src="/nothumb_340.png"></a>
|
||||||
|
</div>
|
||||||
|
<div class="properties">
|
||||||
|
Requested ID ` + html.EscapeString(fileID) + ` not found in storage (404)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
log.Println(err.Error())
|
log.Println(err.Error())
|
||||||
http.Error(w, "Internal error", 500)
|
http.Error(w, "Internal error", 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.MimeType == ALBUM_MIMETYPE {
|
||||||
|
// Special handling for albums
|
||||||
|
f, err := this.ReadFile(ctx, m.FileHash)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Opening file '%s' for preview of album '%s': %s", m.FileHash, fileID, err.Error())
|
||||||
|
http.Error(w, "Internal error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var childIDs []string
|
||||||
|
err = json.NewDecoder(f).Decode(&childIDs)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse album '%s': %s", fileID, err)
|
||||||
|
http.Error(w, "Internal error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumThumb := `/nothumb_340.png`
|
||||||
|
if len(childIDs) > 0 {
|
||||||
|
albumThumb = `/thumb/m/` + childIDs[0]
|
||||||
|
}
|
||||||
|
|
||||||
tmpl += `
|
tmpl += `
|
||||||
<div class="entry">
|
<div class="entry">
|
||||||
<div class="thumbnail">
|
<div class="thumbnail">
|
||||||
<a href="` + html.EscapeString(`/get/`+fileID) + `"><img src="` + html.EscapeString(`/thumb/m/`+fileID) + `"></a>
|
<a href="` + html.EscapeString(`/p/`+strings.Join(childIDs, `-`)) + `"><img loading="lazy" src="` + html.EscapeString(albumThumb) + `"></a>
|
||||||
|
<div class="thumbnail-overlay">` + fmt.Sprintf("%d", len(childIDs)) + ` image(s)</div>
|
||||||
|
</div>
|
||||||
|
<div class="properties">
|
||||||
|
<b>Name:</b> ` + html.EscapeString(m.Filename) + `<br>
|
||||||
|
<b>Hash:</b> <span title="` + html.EscapeString(m.FileHash) + `">hover</span><br>
|
||||||
|
<b>File type:</b> Album<br>
|
||||||
|
<b>Size:</b> ` + fmt.Sprintf("%d", len(childIDs)) + ` image(s)<br>
|
||||||
|
<b>Uploader:</b> ` + html.EscapeString(m.UploadIP) + `<br>
|
||||||
|
<b>Uploaded at:</b> ` + html.EscapeString(m.UploadTime.Format(time.RFC3339)) + `<br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
} else {
|
||||||
|
tmpl += `
|
||||||
|
<div class="entry">
|
||||||
|
<div class="thumbnail">
|
||||||
|
<a href="` + html.EscapeString(`/get/`+fileID) + `"><img loading="lazy" src="` + html.EscapeString(`/thumb/m/`+fileID) + `"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="properties">
|
<div class="properties">
|
||||||
<b>Name:</b> ` + html.EscapeString(m.Filename) + `<br>
|
<b>Name:</b> ` + html.EscapeString(m.Filename) + `<br>
|
||||||
@ -77,6 +194,7 @@ html, body {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if this.opts.EnableHomepage {
|
if this.opts.EnableHomepage {
|
||||||
tmpl += `
|
tmpl += `
|
||||||
|
@ -38,7 +38,7 @@ button.again {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript" src="/jquery-1.12.4.min.js"></script>
|
<script type="text/javascript" src="/jquery-3.7.0.min.js"></script>
|
||||||
<script type="text/javascript" src="/sdk.js"></script>
|
<script type="text/javascript" src="/sdk.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
"use strict";
|
"use strict";
|
||||||
|
5
static/jquery-1.12.4.min.js
vendored
5
static/jquery-1.12.4.min.js
vendored
File diff suppressed because one or more lines are too long
2
static/jquery-3.7.0.min.js
vendored
Normal file
2
static/jquery-3.7.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
428
static/sdk.js
428
static/sdk.js
@ -1,28 +1,6 @@
|
|||||||
;
|
;(function() {
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
var contented = (function() {
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// @ref https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
|
|
||||||
if (!HTMLCanvasElement.prototype.toBlob) {
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
|
|
||||||
value: function (callback, type, quality) {
|
|
||||||
|
|
||||||
var binStr = atob( this.toDataURL(type, quality).split(',')[1] ),
|
|
||||||
len = binStr.length,
|
|
||||||
arr = new Uint8Array(len);
|
|
||||||
|
|
||||||
for (var i = 0; i < len; i++ ) {
|
|
||||||
arr[i] = binStr.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
callback( new Blob( [arr], {type: type || 'image/png'} ) );
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var getCurrentScriptPath = function () {
|
var getCurrentScriptPath = function () {
|
||||||
// Determine current script path
|
// Determine current script path
|
||||||
// @ref https://stackoverflow.com/a/26023176
|
// @ref https://stackoverflow.com/a/26023176
|
||||||
@ -34,87 +12,14 @@ var contented = (function() {
|
|||||||
return currentScript.replace(currentScriptFile, '');
|
return currentScript.replace(currentScriptFile, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
var currentScriptPath = getCurrentScriptPath();
|
var loadScript = function(url) {
|
||||||
var baseURL = currentScriptPath.replace('sdk.js', '');
|
return new Promise(function(resolve, reject) {
|
||||||
|
|
||||||
return {
|
|
||||||
"loaded": false,
|
|
||||||
|
|
||||||
"baseURL": baseURL,
|
|
||||||
|
|
||||||
"__preInit": [],
|
|
||||||
|
|
||||||
/**
|
|
||||||
* initArea shows the contented upload widget over the top of a target DOM element.
|
|
||||||
*
|
|
||||||
* @param any element Drop target (string selector / DOMElement / jQuery)
|
|
||||||
* @param Function onUploaded Called with an array of upload IDs
|
|
||||||
* @param Function onClose Called when the widget is being destroyed
|
|
||||||
*/
|
|
||||||
"init": function(elementSelector, onUploaded, onClose) {
|
|
||||||
contented.__preInit.push([elementSelector, onUploaded, onClose]);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* supportsDrop returns whether drag-and-drop is supported by this browser.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
"supportsDrop": function() {
|
|
||||||
return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
|
|
||||||
},
|
|
||||||
|
|
||||||
"getPreviewURL": function(id) {
|
|
||||||
return baseURL + "p/" + encodeURIComponent(id);
|
|
||||||
},
|
|
||||||
"getMultiPreviewURL": function(items) {
|
|
||||||
return baseURL + "p/" + encodeURIComponent(items.join("-"));
|
|
||||||
},
|
|
||||||
"getDownloadURL": function(id) {
|
|
||||||
return baseURL + "get/" + encodeURIComponent(id);
|
|
||||||
},
|
|
||||||
"getInfoJSONURL": function(id) {
|
|
||||||
return baseURL + "info/" + encodeURIComponent(id);
|
|
||||||
},
|
|
||||||
"getThumbnailURL": function(thumbnailType, id) {
|
|
||||||
return baseURL + "thumb/" + encodeURIComponent(thumbnailType) + "/" + encodeURIComponent(id);
|
|
||||||
},
|
|
||||||
"thumbnail": {
|
|
||||||
"small_square": "s",
|
|
||||||
"medium_square": "b",
|
|
||||||
"medium": "t",
|
|
||||||
"large": "m",
|
|
||||||
"xlarge": "l",
|
|
||||||
"xxlarge": "h"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
||||||
;(function() {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
var loadScript = function(url, onLoad) {
|
|
||||||
var script = document.createElement('script');
|
var script = document.createElement('script');
|
||||||
script.onload = onLoad;
|
script.onload = resolve;
|
||||||
|
script.onerror = reject;
|
||||||
script.src = url;
|
script.src = url;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
};
|
});
|
||||||
|
|
||||||
var loadScripts = function(urls, onLoad) {
|
|
||||||
// load sequentially
|
|
||||||
var i = 0;
|
|
||||||
var loadNext = function() {
|
|
||||||
if (i === urls.length) {
|
|
||||||
onLoad();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = urls[i];
|
|
||||||
i += 1;
|
|
||||||
loadScript(url, loadNext);
|
|
||||||
};
|
|
||||||
loadNext();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var formatBytes = function(bytes) {
|
var formatBytes = function(bytes) {
|
||||||
@ -139,9 +44,184 @@ var contented = (function() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
var afterScriptsLoaded = function() {
|
var widgetHtml = `
|
||||||
|
<style type="text/css">
|
||||||
|
.contented {
|
||||||
|
box-sizing:border-box;
|
||||||
|
text-align: center;
|
||||||
|
border: 8px dashed lightgrey;
|
||||||
|
padding: 12px;
|
||||||
|
background:white; /* not transparent */
|
||||||
|
|
||||||
var initArea = function (elementSelector, onUploaded, onClose) {
|
text-overflow:hidden;
|
||||||
|
overflow:auto;
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
|
||||||
|
position:relative;
|
||||||
|
}
|
||||||
|
.contented .contented-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.contented .contented-upload-type-selector {
|
||||||
|
display:block;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.contented .contented-upload-type {
|
||||||
|
display:inline-block;
|
||||||
|
opacity:0.2;
|
||||||
|
transition:opacity linear 0.1s;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.contented .contented-upload-type:hover {
|
||||||
|
opacity:0.5;
|
||||||
|
transition:opacity linear 0s;
|
||||||
|
}
|
||||||
|
.contented .contented-upload-type svg {
|
||||||
|
width:36px;
|
||||||
|
height:36px;
|
||||||
|
}
|
||||||
|
.contented .contented-upload-type.contented-upload-type-active {
|
||||||
|
opacity:1;
|
||||||
|
}
|
||||||
|
.contented.is-dragging {
|
||||||
|
background: lightblue;
|
||||||
|
}
|
||||||
|
.contented-content-area {
|
||||||
|
position:absolute;
|
||||||
|
top: 60px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
|
||||||
|
/* Prevent blur under translateY */
|
||||||
|
-webkit-transform-style: preserve-3d;
|
||||||
|
-moz-transform-style: preserve-3d;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
.contented-content-area > div {
|
||||||
|
position: relative;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.contented-upload-if {
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
.contented-if-paste, .contented-if-drawing {
|
||||||
|
height:100%;
|
||||||
|
}
|
||||||
|
.contented-upload-if.contented-active {
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
.contented textarea {
|
||||||
|
resize: none;
|
||||||
|
width:100%;
|
||||||
|
height:calc(100% - 1em - 15px);
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
.contented-progress-bar {
|
||||||
|
display: block;
|
||||||
|
width:90%;
|
||||||
|
margin:0.5em auto 0 auto;
|
||||||
|
|
||||||
|
height:16px;
|
||||||
|
border-radius:8px;
|
||||||
|
background:lightgrey;
|
||||||
|
position:relative;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.contented-progress-element {
|
||||||
|
position:absolute;
|
||||||
|
background:darkgreen;
|
||||||
|
left:0;
|
||||||
|
width:0%;
|
||||||
|
height:100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="contented">
|
||||||
|
|
||||||
|
<div class="contented-close">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-upload-type-selector">
|
||||||
|
|
||||||
|
<div class="contented-upload-type contented-upload-type-active" data-upload-type="drag" title="Drag and drop">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-upload-type" data-upload-type="file" title="Multiple files">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-upload-type" data-upload-type="paste" title="Paste">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M19,20H5V4H7V7H17V4H19M12,2A1,1 0 0,1 13,3A1,1 0 0,1 12,4A1,1 0 0,1 11,3A1,1 0 0,1 12,2M19,2H14.82C14.4,0.84 13.3,0 12,0C10.7,0 9.6,0.84 9.18,2H5A2,2 0 0,0 3,4V20A2,2 0 0,0 5,22H19A2,2 0 0,0 21,20V4A2,2 0 0,0 19,2Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-upload-type" data-upload-type="drawing" title="Drawing">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M16.84,2.73C16.45,2.73 16.07,2.88 15.77,3.17L13.65,5.29L18.95,10.6L21.07,8.5C21.67,7.89 21.67,6.94 21.07,6.36L17.9,3.17C17.6,2.88 17.22,2.73 16.84,2.73M12.94,6L4.84,14.11L7.4,14.39L7.58,16.68L9.86,16.85L10.15,19.41L18.25,11.3M4.25,15.04L2.5,21.73L9.2,19.94L8.96,17.78L6.65,17.61L6.47,15.29" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-upload-type" data-upload-type="album" title="Album">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M3,3H21V7H3V3M4,8H20V21H4V8M9.5,11A0.5,0.5 0 0,0 9,11.5V13H15V11.5A0.5,0.5 0 0,0 14.5,11H9.5Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-content-area">
|
||||||
|
<div class="contented-upload-if contented-if-drag contented-active">
|
||||||
|
<label>Drop files or Ctrl-V to upload <span class="contented-extratext"></span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contented-upload-if contented-if-file">
|
||||||
|
<label>Select files to upload <span class="contented-extratext"></span></label><br>
|
||||||
|
<input class="contented-file-selector" type="file" multiple>
|
||||||
|
<button class="contented-file-upload">Upload »</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 () { };
|
||||||
|
|
||||||
@ -153,15 +233,12 @@ var contented = (function() {
|
|||||||
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
|
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
|
||||||
|
|
||||||
// Create a new div for ourselves on top of the existing area
|
// Create a new div for ourselves on top of the existing area
|
||||||
$.get(contented.baseURL + "about", function (ret) {
|
|
||||||
|
|
||||||
var extraText = "";
|
var extraText = "";
|
||||||
if (ret.MaxUploadBytes > 0) {
|
if (aboutInfo.MaxUploadBytes > 0) {
|
||||||
extraText = " (max " + formatBytes(ret.MaxUploadBytes) + ")";
|
extraText = " (max " + formatBytes(aboutInfo.MaxUploadBytes) + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
$.get(contented.baseURL + "widget.html", function (widgetHtml) {
|
|
||||||
|
|
||||||
var $f = $("<div>").html(widgetHtml);
|
var $f = $("<div>").html(widgetHtml);
|
||||||
$f.find(".contented-extratext").text(extraText);
|
$f.find(".contented-extratext").text(extraText);
|
||||||
|
|
||||||
@ -243,6 +320,37 @@ var contented = (function() {
|
|||||||
handleUploadFrom([blob]);
|
handleUploadFrom([blob]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Album
|
||||||
|
|
||||||
|
$f.find(".contented-album-upload").on("click", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var title = $(".contented-album-title").val();
|
||||||
|
if (title === "") {
|
||||||
|
title = "Untitled album";
|
||||||
|
}
|
||||||
|
|
||||||
|
var childIDs = $(".contented-album-items").val().split(",");
|
||||||
|
|
||||||
|
for (var i = 0; i < childIDs.length; ++i) {
|
||||||
|
childIDs[i] = childIDs[i].trim(); // Basic validation - can't really perform full validation here
|
||||||
|
|
||||||
|
if (childIDs[i].length == 0) {
|
||||||
|
alert("Entry " + (i+1) + " is too short, expected non-zero length");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! childIDs[i].match(/^[a-zA-Z0-9]+$/)) {
|
||||||
|
alert("Entry " + (i+1) + " contains unexpected character");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var blob = new Blob([JSON.stringify(childIDs)], {type : 'contented/album'});
|
||||||
|
handleUploadFrom([new File([blob], title, {type: 'contented/album'})]);
|
||||||
|
});
|
||||||
|
|
||||||
// Ctrl+V uploads
|
// Ctrl+V uploads
|
||||||
|
|
||||||
var pasteHandler = function(e) {
|
var pasteHandler = function(e) {
|
||||||
@ -379,6 +487,10 @@ var contented = (function() {
|
|||||||
|
|
||||||
// Common upload handler
|
// Common upload handler
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {File[]|Blob[]} files
|
||||||
|
*/
|
||||||
var handleUploadFrom = function(files) {
|
var handleUploadFrom = function(files) {
|
||||||
|
|
||||||
setProgressCaption("Uploading, please wait...");
|
setProgressCaption("Uploading, please wait...");
|
||||||
@ -432,33 +544,97 @@ var contented = (function() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// .
|
};
|
||||||
|
|
||||||
});
|
var init = function() {
|
||||||
|
|
||||||
});
|
var currentScriptPath = getCurrentScriptPath();
|
||||||
|
var baseURL = currentScriptPath.replace('sdk.js', '');
|
||||||
|
|
||||||
|
// Kick off background promises
|
||||||
|
|
||||||
|
var loader = new Promise(function(resolve, reject) {
|
||||||
|
if (typeof jQuery === "undefined") {
|
||||||
|
loadScript(contented.baseURL + "jquery-3.7.0.min.js").then(resolve);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update fields in global variable
|
}).then(function() { return new Promise(function(resolve, reject) {
|
||||||
contented.init = initArea;
|
if (typeof DrawingBoard === "undefined") {
|
||||||
contented.loaded = true;
|
loadScript(contented.baseURL + "drawingboard-0.4.6.min.js").then(resolve);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
|
||||||
// Call initArea for all pre-initialised elements
|
})}).then(function() { return new Promise(function(resolve, reject) {
|
||||||
for (var i = 0; i < contented.__preInit.length; ++i) {
|
$.get(contented.baseURL + "about", function (aboutInfo) {
|
||||||
initArea(contented.__preInit[i][0], contented.__preInit[i][1], contented.__preInit[i][2]);
|
resolve(aboutInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
})}).then(function(aboutInfo) {
|
||||||
|
// Update fields in global variable
|
||||||
|
window.contented.loaded = true;
|
||||||
|
|
||||||
|
return aboutInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.contented = {
|
||||||
|
"loaded": false,
|
||||||
|
|
||||||
|
"baseURL": baseURL,
|
||||||
|
|
||||||
|
"__preInit": [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* initArea shows the contented upload widget over the top of a target DOM element.
|
||||||
|
*
|
||||||
|
* @param any element Drop target (string selector / DOMElement / jQuery)
|
||||||
|
* @param Function onUploaded Called with an array of upload IDs
|
||||||
|
* @param Function onClose Called when the widget is being destroyed
|
||||||
|
*/
|
||||||
|
"init": function(elementSelector, onUploaded, onClose) {
|
||||||
|
loader.then(function(aboutInfo) {
|
||||||
|
initArea(aboutInfo, elementSelector, onUploaded, onClose);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* supportsDrop returns whether drag-and-drop is supported by this browser.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
"supportsDrop": function() {
|
||||||
|
return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
|
||||||
|
},
|
||||||
|
|
||||||
|
"getPreviewURL": function(id) {
|
||||||
|
return baseURL + "p/" + encodeURIComponent(id);
|
||||||
|
},
|
||||||
|
"getMultiPreviewURL": function(items) {
|
||||||
|
return baseURL + "p/" + encodeURIComponent(items.join("-"));
|
||||||
|
},
|
||||||
|
"getDownloadURL": function(id) {
|
||||||
|
return baseURL + "get/" + encodeURIComponent(id);
|
||||||
|
},
|
||||||
|
"getInfoJSONURL": function(id) {
|
||||||
|
return baseURL + "info/" + encodeURIComponent(id);
|
||||||
|
},
|
||||||
|
"getThumbnailURL": function(thumbnailType, id) {
|
||||||
|
return baseURL + "thumb/" + encodeURIComponent(thumbnailType) + "/" + encodeURIComponent(id);
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"small_square": "s",
|
||||||
|
"medium_square": "b",
|
||||||
|
"medium": "t",
|
||||||
|
"large": "m",
|
||||||
|
"xlarge": "l",
|
||||||
|
"xxlarge": "h"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load scripts
|
};
|
||||||
var needScripts = [];
|
|
||||||
if (typeof jQuery === "undefined") {
|
|
||||||
needScripts.push(contented.baseURL + "jquery-1.12.4.min.js");
|
|
||||||
}
|
|
||||||
if (typeof DrawingBoard === "undefined") {
|
|
||||||
needScripts.push(contented.baseURL + "drawingboard-0.4.6.min.js");
|
|
||||||
}
|
|
||||||
|
|
||||||
loadScripts(needScripts, afterScriptsLoaded);
|
init();
|
||||||
|
|
||||||
})()
|
})()
|
||||||
|
@ -1,161 +0,0 @@
|
|||||||
<style type="text/css">
|
|
||||||
.contented {
|
|
||||||
box-sizing:border-box;
|
|
||||||
text-align: center;
|
|
||||||
border: 8px dashed lightgrey;
|
|
||||||
padding: 12px;
|
|
||||||
background:white; /* not transparent */
|
|
||||||
|
|
||||||
text-overflow:hidden;
|
|
||||||
overflow:auto;
|
|
||||||
width:100%;
|
|
||||||
height:100%;
|
|
||||||
|
|
||||||
position:relative;
|
|
||||||
}
|
|
||||||
.contented .contented-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.contented .contented-upload-type-selector {
|
|
||||||
display:block;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.contented .contented-upload-type {
|
|
||||||
display:inline-block;
|
|
||||||
opacity:0.2;
|
|
||||||
transition:opacity linear 0.1s;
|
|
||||||
cursor:pointer;
|
|
||||||
}
|
|
||||||
.contented .contented-upload-type:hover {
|
|
||||||
opacity:0.5;
|
|
||||||
transition:opacity linear 0s;
|
|
||||||
}
|
|
||||||
.contented .contented-upload-type svg {
|
|
||||||
width:36px;
|
|
||||||
height:36px;
|
|
||||||
}
|
|
||||||
.contented .contented-upload-type.contented-upload-type-active {
|
|
||||||
opacity:1;
|
|
||||||
}
|
|
||||||
.contented.is-dragging {
|
|
||||||
background: lightblue;
|
|
||||||
}
|
|
||||||
.contented-content-area {
|
|
||||||
position:absolute;
|
|
||||||
top: 60px;
|
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
|
|
||||||
/* Prevent blur under translateY */
|
|
||||||
-webkit-transform-style: preserve-3d;
|
|
||||||
-moz-transform-style: preserve-3d;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
|
||||||
.contented-content-area > div {
|
|
||||||
position: relative;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
.contented-upload-if {
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
.contented-if-paste, .contented-if-drawing {
|
|
||||||
height:100%;
|
|
||||||
}
|
|
||||||
.contented-upload-if.contented-active {
|
|
||||||
display:block;
|
|
||||||
}
|
|
||||||
.contented textarea {
|
|
||||||
resize: none;
|
|
||||||
width:100%;
|
|
||||||
height:calc(100% - 1em - 15px);
|
|
||||||
box-sizing:border-box;
|
|
||||||
}
|
|
||||||
.contented-progress-bar {
|
|
||||||
display: block;
|
|
||||||
width:90%;
|
|
||||||
margin:0.5em auto 0 auto;
|
|
||||||
|
|
||||||
height:16px;
|
|
||||||
border-radius:8px;
|
|
||||||
background:lightgrey;
|
|
||||||
position:relative;
|
|
||||||
overflow:hidden;
|
|
||||||
}
|
|
||||||
.contented-progress-element {
|
|
||||||
position:absolute;
|
|
||||||
background:darkgreen;
|
|
||||||
left:0;
|
|
||||||
width:0%;
|
|
||||||
height:100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="contented">
|
|
||||||
|
|
||||||
<div class="contented-close">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<path fill="#000000" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contented-upload-type-selector">
|
|
||||||
|
|
||||||
<div class="contented-upload-type contented-upload-type-active" data-upload-type="drag" title="Drag and drop">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<path fill="#000000" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contented-upload-type" data-upload-type="file" title="Multiple files">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<path fill="#000000" d="M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contented-upload-type" data-upload-type="paste" title="Paste">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<path fill="#000000" d="M19,20H5V4H7V7H17V4H19M12,2A1,1 0 0,1 13,3A1,1 0 0,1 12,4A1,1 0 0,1 11,3A1,1 0 0,1 12,2M19,2H14.82C14.4,0.84 13.3,0 12,0C10.7,0 9.6,0.84 9.18,2H5A2,2 0 0,0 3,4V20A2,2 0 0,0 5,22H19A2,2 0 0,0 21,20V4A2,2 0 0,0 19,2Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contented-upload-type" data-upload-type="drawing" title="Drawing">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<path fill="#000000" d="M16.84,2.73C16.45,2.73 16.07,2.88 15.77,3.17L13.65,5.29L18.95,10.6L21.07,8.5C21.67,7.89 21.67,6.94 21.07,6.36L17.9,3.17C17.6,2.88 17.22,2.73 16.84,2.73M12.94,6L4.84,14.11L7.4,14.39L7.58,16.68L9.86,16.85L10.15,19.41L18.25,11.3M4.25,15.04L2.5,21.73L9.2,19.94L8.96,17.78L6.65,17.61L6.47,15.29" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contented-content-area">
|
|
||||||
<div class="contented-upload-if contented-if-drag contented-active">
|
|
||||||
<label>Drop files or Ctrl-V to upload <span class="contented-extratext"></span></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contented-upload-if contented-if-file">
|
|
||||||
<label>Select files to upload <span class="contented-extratext"></span></label><br>
|
|
||||||
<input class="contented-file-selector" type="file" multiple>
|
|
||||||
<button class="contented-file-upload">Upload »</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>
|
|
||||||
</div>
|
|
File diff suppressed because one or more lines are too long
57
storage.go
Normal file
57
storage.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package contented
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (this *Server) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
|
||||||
|
if this.opts.StorageType == STORAGE_LOCAL {
|
||||||
|
fh, err := os.Open(filepath.Join(this.opts.DataDirectory, fileHash))
|
||||||
|
return fh, err
|
||||||
|
|
||||||
|
} else if this.opts.StorageType == STORAGE_S3 {
|
||||||
|
obj, err := this.s3client.GetObject(ctx, this.opts.DataS3Options.Bucket, this.opts.DataS3Options.Prefix+fileHash, minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
panic("bad StorageType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Server) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
|
||||||
|
if this.opts.StorageType == STORAGE_LOCAL {
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
dest, err := os.OpenFile(filepath.Join(this.opts.DataDirectory, fileHash), os.O_CREATE|os.O_WRONLY, this.opts.FileMode())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsExist(err) {
|
||||||
|
return nil // hash matches existing upload
|
||||||
|
}
|
||||||
|
|
||||||
|
return err // Real error
|
||||||
|
}
|
||||||
|
defer dest.Close()
|
||||||
|
|
||||||
|
_, err = io.CopyN(dest, src, int64(srcLen))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if this.opts.StorageType == STORAGE_S3 {
|
||||||
|
_, err := this.s3client.PutObject(ctx, this.opts.DataS3Options.Bucket, this.opts.DataS3Options.Prefix+fileHash, src, srcLen, minio.PutObjectOptions{})
|
||||||
|
return err
|
||||||
|
|
||||||
|
} else {
|
||||||
|
panic("bad StorageType")
|
||||||
|
}
|
||||||
|
}
|
102
thumb.go
102
thumb.go
@ -1,61 +1,94 @@
|
|||||||
package contented
|
package contented
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"code.ivysaur.me/thumbnail"
|
"code.ivysaur.me/thumbnail"
|
||||||
)
|
)
|
||||||
|
|
||||||
func thumbnailer(t byte) (*thumbnail.Thumbnailer, error) {
|
func getThumbnailerConfig(t byte) (*thumbnail.Config, error) {
|
||||||
// Modelled on what imgur.com offers
|
// Modelled on what imgur.com offers
|
||||||
// @ref https://api.imgur.com/models/image#thumbs
|
// @ref https://api.imgur.com/models/image#thumbs
|
||||||
|
|
||||||
const (
|
opts := thumbnail.Config{
|
||||||
cacheSize = 1
|
Aspect: thumbnail.FitOutside,
|
||||||
outputFmt = thumbnail.OUTPUT_JPG
|
Output: thumbnail.Jpeg,
|
||||||
scaleFmt = thumbnail.SCALEFMT_BILINEAR
|
Scale: thumbnail.Bicubic,
|
||||||
)
|
}
|
||||||
|
|
||||||
switch t {
|
switch t {
|
||||||
case 's':
|
case 's':
|
||||||
return thumbnail.NewThumbnailerEx(90, 90, cacheSize, outputFmt, thumbnail.ASPECT_CROP_TO_DIMENSIONS, scaleFmt), nil
|
opts.Width = 90
|
||||||
|
opts.Height = 90
|
||||||
case 'b':
|
case 'b':
|
||||||
return thumbnail.NewThumbnailerEx(160, 160, cacheSize, outputFmt, thumbnail.ASPECT_CROP_TO_DIMENSIONS, scaleFmt), nil
|
opts.Width = 160
|
||||||
|
opts.Height = 160
|
||||||
case 't':
|
case 't':
|
||||||
return thumbnail.NewThumbnailerEx(160, 160, cacheSize, outputFmt, thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY, scaleFmt), nil
|
opts.Width = 160
|
||||||
|
opts.Height = 160
|
||||||
|
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
|
||||||
case 'm':
|
case 'm':
|
||||||
return thumbnail.NewThumbnailerEx(340, 340, cacheSize, outputFmt, thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY, scaleFmt), nil
|
opts.Width = 340
|
||||||
|
opts.Height = 340
|
||||||
|
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
|
||||||
case 'l':
|
case 'l':
|
||||||
return thumbnail.NewThumbnailerEx(640, 640, cacheSize, outputFmt, thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY, scaleFmt), nil
|
opts.Width = 640
|
||||||
|
opts.Height = 640
|
||||||
|
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
|
||||||
case 'h':
|
case 'h':
|
||||||
return thumbnail.NewThumbnailerEx(1024, 1024, cacheSize, outputFmt, thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY, scaleFmt), nil
|
opts.Width = 1024
|
||||||
|
opts.Height = 1024
|
||||||
|
// thumbnail.ASPECT_RESPECT_MAX_DIMENSION_ONLY
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("Unsupported thumbnail type (should be s/b/t/m/l/h)")
|
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) {
|
func (this *Server) handleThumb(w http.ResponseWriter, r *http.Request, thumbnailType byte, fileId string) {
|
||||||
t, err := thumbnailer(thumbnailType)
|
ctx := r.Context()
|
||||||
|
|
||||||
|
opts, err := getThumbnailerConfig(thumbnailType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("%s Thumbnail failed: %s\n", this.remoteIP(r), err.Error())
|
log.Printf("%s Thumbnail failed: %s\n", this.remoteIP(r), err.Error())
|
||||||
http.Error(w, err.Error(), 400)
|
http.Error(w, err.Error(), 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = this.handleThumbInternal(w, t, fileId)
|
// 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 {
|
if err != nil {
|
||||||
log.Printf("%s Thumbnail failed: %s\n", this.remoteIP(r), err.Error())
|
log.Printf("%s Thumbnail failed: %s\n", this.remoteIP(r), err.Error())
|
||||||
|
|
||||||
w.Header().Set(`Location`, fmt.Sprintf(`/nothumb_%d.png`, t.Height()))
|
w.Header().Set(`Location`, fmt.Sprintf(`/nothumb_%d.png`, opts.Height))
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (this *Server) handleThumbInternal(w http.ResponseWriter, t *thumbnail.Thumbnailer, fileId string) error {
|
func (this *Server) handleThumbInternal(ctx context.Context, w http.ResponseWriter, t thumbnail.Thumbnailer, fileId string) error {
|
||||||
|
|
||||||
// Load metadata
|
// Load metadata
|
||||||
m, err := this.Metadata(fileId)
|
m, err := this.Metadata(fileId)
|
||||||
@ -63,12 +96,45 @@ func (this *Server) handleThumbInternal(w http.ResponseWriter, t *thumbnail.Thum
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := filepath.Join(this.opts.DataDirectory, m.FileHash)
|
if m.FileSize > this.opts.MaxThumbSizeBytes {
|
||||||
thumb, err := t.RenderFile_NoCache_MimeType(filePath, m.MimeType)
|
return errors.New("Don't want to thumbnail very large files, sorry")
|
||||||
|
}
|
||||||
|
|
||||||
|
var filePath string
|
||||||
|
|
||||||
|
if this.opts.StorageType == STORAGE_LOCAL {
|
||||||
|
filePath = filepath.Join(this.opts.DataDirectory, m.FileHash)
|
||||||
|
|
||||||
|
} else if this.opts.StorageType == STORAGE_S3 {
|
||||||
|
// Need to temporarily download it for thumbnailing (slow and costs money)
|
||||||
|
|
||||||
|
destFh, err := os.CreateTemp("", "contented-thumbcache-*")
|
||||||
|
defer os.Remove(destFh.Name())
|
||||||
|
|
||||||
|
srcFh, err := this.ReadFile(ctx, m.FileHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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-Length`, fmt.Sprintf("%d", len(thumb)))
|
||||||
w.Header().Set(`Content-Type`, `image/jpeg`)
|
w.Header().Set(`Content-Type`, `image/jpeg`)
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
|
27
upload.go
27
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,18 +10,21 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if !this.opts.EnableUpload {
|
||||||
|
http.Error(w, "Server is read-only", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
remoteIP := this.remoteIP(r)
|
remoteIP := this.remoteIP(r)
|
||||||
|
|
||||||
err := r.ParseMultipartForm(this.opts.MaxUploadBytes * 2)
|
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", remoteIP, err.Error())
|
log.Printf("%s Invalid request: %s\n", remoteIP, err.Error())
|
||||||
http.Error(w, "Invalid request", 400)
|
http.Error(w, "Invalid request", 400)
|
||||||
@ -89,26 +93,13 @@ func (this *Server) handleUploadFile(src multipart.File, hdr *multipart.FileHead
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file to disk
|
|
||||||
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
dest, err := os.OpenFile(filepath.Join(this.opts.DataDirectory, fileHash), os.O_CREATE|os.O_WRONLY, this.opts.FileMode())
|
|
||||||
shouldSave := true
|
|
||||||
if err != nil && os.IsExist(err) {
|
|
||||||
// hash matches existing upload
|
|
||||||
// That's fine - but still persist the metadata separately
|
|
||||||
shouldSave = false
|
|
||||||
} else if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldSave {
|
// Save file to disk/s3
|
||||||
defer dest.Close()
|
err = this.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")
|
||||||
|
Loading…
Reference in New Issue
Block a user