Compare commits

...

137 Commits

Author SHA1 Message Date
mappu bfd669bc96 doc: update changelog for v1.5.1 2023-05-20 14:33:21 +12:00
mappu ea1309eb75 preview: support skipping over single missing images inside large albums 2023-05-20 13:50:46 +12:00
mappu b146db9d0a preview: add minimal preview for albums with no images 2023-05-20 13:43:27 +12:00
mappu 0a22d1ca8a doc/README: update feature list 2023-05-19 19:39:58 +12:00
mappu bc911327cf doc: update changelog for v1.5.0 2023-05-19 19:17:04 +12:00
mappu e04900f672 gitignore: exclude for go build in contented-multi directory 2023-05-19 19:15:15 +12:00
mappu d10026ae82 contented-multi: initial commit 2023-05-19 19:15:15 +12:00
mappu 36b4b124f7 s3 backend support 2023-05-19 19:15:15 +12:00
mappu 8d40031edc go: import minio-go library (Apache-2.0 license) 2023-05-19 19:15:15 +12:00
mappu 15c02efe96 preview: accept context parameter 2023-05-19 19:15:15 +12:00
mappu 01ff8f69aa preview: enable client-side caching for up to 1 year 2023-05-19 19:12:26 +12:00
mappu 3e5fb091c9 preview: add default block height for slow-loading thumbnails 2023-05-19 19:12:05 +12:00
mappu caf521c318 contented: new option to cap the filesize for thumbnailing (default 20MiB) 2023-05-19 19:06:57 +12:00
mappu 77a4061cdd contented: move flag parsing, consts into block declaration 2023-05-19 19:06:07 +12:00
mappu 5ce06d6e6b doc/CHANGELOG: changelog for v1.4.0 2023-05-17 19:27:32 +12:00
mappu 0044d7dc77 doc/README: update for latest feature changes 2023-05-17 19:27:21 +12:00
mappu 6453700648 preview: better title element 2023-05-17 19:25:45 +12:00
mappu 7407353dfb albums: support custom album titles 2023-05-17 19:21:48 +12:00
mappu 660038b897 doc/README: update list of endpoints, SDK usage 2023-05-17 19:09:36 +12:00
mappu 6cef907cf9 doc/README: alphabetical ordering of options 2023-05-17 19:08:58 +12:00
mappu b959f30882 preview: use browser-side lazy loading for very large galleries 2023-05-17 19:08:36 +12:00
mappu 64b900d90c server: add readonly mode to block uploads 2023-05-17 19:08:24 +12:00
mappu e26a3b58b0 upload: return error if the short IDs collide for whatever reason 2023-05-17 18:54:30 +12:00
mappu 4294738337 albums: basic support (json array using contented/album mime-type) 2023-05-17 18:52:13 +12:00
mappu 156e2ab540 thumbnail: if the request is cancelled, don't wait for the semaphore 2023-05-17 18:18:02 +12:00
mappu ad56309cb0 sdk: replace all the early-load logic with promises 2023-05-17 18:09:28 +12:00
mappu f13618fef1 sdk: inline the widget html definition 2023-05-17 18:09:15 +12:00
mappu e8dd95a830 sdk: drop polyfills, use jquery 3 2023-05-17 17:38:32 +12:00
mappu bf3339fec7 sdk: flatten out some nested callbacks 2023-05-17 17:34:22 +12:00
mappu cb29cf83ac server: replace go-bindata with stdlib embed.FS 2023-05-17 17:24:47 +12:00
mappu ad95ac219d mod: declare go1.19 (debian bookworm) 2023-05-17 17:24:23 +12:00
mappu 11003e010d vendor: update bolt v1.3.1 -> etcd.io/bbolt v1.3.7 2023-05-17 17:24:00 +12:00
mappu 6b4c2cc208 makefile: remove win32 and src targets 2023-05-17 17:22:47 +12:00
mappu ccc1f73e52 doc: update README for 1.3.1 2020-07-25 13:17:02 +12:00
mappu b48d1347c7 vendor: update package versions (fixes FTBFS) 2020-07-25 13:15:50 +12:00
mappu bed8a88b09 vendor: delete vendor directory, rely fully on Go modules 2020-07-25 13:14:44 +12:00
mappu 0a70d99af5 changelog for 1.3.0 2020-07-25 12:35:26 +12:00
mappu 6720cbc0d9 upload: buffer MIME parsing on disk, not in memory 2020-07-25 12:25:44 +12:00
mappu 8d11b7c434 doc/README: add links for archived releases 2020-05-06 18:05:58 +12:00
mappu 147608a327 hg2git: fix source tarball generation in Makefile 2018-12-31 19:02:58 +13:00
mappu 951c87b63f convert to Go Modules 2018-12-31 19:02:50 +13:00
mappu 25180afaa2 doc: fix markdown syntax 2018-10-06 14:04:13 +13:00
mappu 33ca03b9d7 doc: update README for GFM syntax 2018-10-06 13:49:20 +13:00
mappu b562ca4bd4 doc: move marketing images to /doc/ subdir 2018-10-06 13:49:00 +13:00
mappu a25dc5827b doc: add image1.png thumbnail 2018-10-06 13:43:02 +13:00
mappu 5263f96956 doc: update readme 2018-09-09 19:03:29 +12:00
mappu 2b2add62b2 vcs: remove old hgtags 2018-09-09 18:45:14 +12:00
mappu fdc93e6485 vcs: migrate hgignore->gitignore 2018-09-09 18:45:07 +12:00
mappu b08f1c33d5 thumbs: allow configuring limit on simultanous thumbs (default 16) 2018-09-09 18:41:37 +12:00
mappu 524f37d9fe serve text/plain content with charset=utf-8 header 2018-09-09 18:33:33 +12:00
mappu c958c57794 thumb: only generate one thumbnail concurrently 2018-09-09 18:31:25 +12:00
mappu 8fbad2a1e0 doc: move README to top-level, for web viewing 2018-07-21 13:38:46 +12:00
mappu 989cd195f8 doc: remove TODO file
Issues are now tracked at git.ivysaur.me.
2018-07-21 13:36:14 +12:00
mappu cb4933fa72 doc: update TODO 2018-07-10 17:45:58 +12:00
mappu f504ab5929 bump version to 1.2.2 2018-06-09 18:13:22 +12:00
mappu f8e95a8037 add go-get tags to readme 2018-06-09 18:12:50 +12:00
mappu feaa51cfcd Added tag v1.2.1 for changeset 7c3807929e7a 2018-06-09 18:11:39 +12:00
mappu ddf76aacc5 doc: update readme 2018-06-09 18:10:54 +12:00
mappu ca8a0d55ba compatibility fixes for hashids library 2018-06-09 18:10:49 +12:00
mappu fe4aace777 thumb: compatibility fixes for thumbnail library 2018-06-09 18:09:57 +12:00
mappu be864235cb vendor: update thumbnail from 1.0.0 -> master 2018-06-09 18:09:49 +12:00
mappu dba699524f build: remove disableimagecrush tag, as it's now the default 2018-06-09 17:55:25 +12:00
mappu 7950ca3004 use dep to manage vendor directory 2018-06-09 17:54:49 +12:00
mappu 6e5ca0c61c retag releases to semver format 2018-06-04 18:30:42 +12:00
mappu 7255cd03cb makefile: build without imagecrush support 2018-06-04 18:28:58 +12:00
mappu 3cf4986418 fix typo in previous 2018-06-04 18:28:50 +12:00
mappu 0a338c4568 add OpenGraph meta tags, for image preview inside chat apps like Telegram 2018-06-04 17:22:28 +12:00
mappu 0fbf401fd3 doc: update TODO 2018-03-01 18:38:42 +13:00
mappu 48ca68fafd Added tag release-1.2.0 for changeset 0f021da52854 2017-11-18 14:30:26 +13:00
mappu f3e594d307 bump all versions to 1.2.1 2017-11-18 14:30:21 +13:00
mappu 396672b02b 1.2.0 release makefile 2017-11-18 14:30:13 +13:00
mappu d25b867e90 doc: changelog 2017-11-18 14:29:02 +13:00
mappu cd60e4c855 add diskFilesWorldReadable option to control 0644/0600 choice for new files 2017-11-18 14:15:31 +13:00
mappu ee72f188a2 doc: update TODO 2017-11-18 14:12:55 +13:00
mappu 30f5b40e1d staticResources: rebuild 2017-11-18 14:11:44 +13:00
mappu 3a103ae484 index: replace our custom result listing page with an album preview 2017-11-18 14:11:40 +13:00
mappu 08321818ff sdk: add getMultiPreviewURL() function 2017-11-18 14:11:27 +13:00
mappu 14456e6539 preview: support multi-image albums 2017-11-18 14:11:20 +13:00
mappu f27d14aac4 preview: add page title, viewport, add 'again...' button 2017-11-18 13:53:46 +13:00
mappu 6ab2b08099 sdk: getPreviewURL() now uses the real preview page 2017-11-18 13:53:33 +13:00
mappu 930869759b add a preview page 2017-11-18 13:48:34 +13:00
mappu d35c81ed21 doc: preliminary changelog update 2017-11-18 13:34:49 +13:00
mappu 139117b4d5 staticResources: rebuild 2017-11-18 13:33:15 +13:00
mappu b88273ec64 index: display thumbnail after upload 2017-11-18 13:32:54 +13:00
mappu 79cb8733e5 sdk: add encodeURIComponent() to getters, add getThumbnailURL(), add thumbnail.* constants 2017-11-18 13:32:47 +13:00
mappu 366c307e02 staticResources: rebuild 2017-11-18 13:27:11 +13:00
mappu 23ad509f33 thumbnailer: display a static error image on failure 2017-11-18 13:27:07 +13:00
mappu a6e495f74d initial thumbnailing support 2017-11-18 13:11:39 +13:00
mappu b3ec40ae65 doc: update TODO 2017-11-16 19:53:37 +13:00
mappu 87f0cb016d bump version to 1.1.1 2017-10-15 22:04:30 +13:00
mappu 381e67bb39 Added tag release-1.1.0 for changeset 98da2ebf0d50 2017-10-15 22:04:16 +13:00
mappu c2f4de822f Removed tag release-1.1.0 2017-10-15 22:03:52 +13:00
mappu ffd4c03d9c Backed out changeset: fa32e83c5a38 2017-10-15 22:03:47 +13:00
mappu 2e08ac06ca rebuild staticResources.go 2017-10-15 22:02:41 +13:00
mappu 6d739972de fix border radius display for progress bar 2017-10-15 22:02:31 +13:00
mappu e36fd43f9b fix missing green part of progress bar 2017-10-15 22:02:19 +13:00
mappu 35bbc6c61b fix loading drawingboard css from relative URL 2017-10-15 22:02:08 +13:00
mappu 987c704730 load dependent scripts sequentially 2017-10-15 21:58:22 +13:00
mappu 9669f2aa0b bump all versions to 1.1.1 2017-10-15 20:54:41 +13:00
mappu 99139e360d Added tag release-1.1.0 for changeset cfb1e028fd06 2017-10-15 20:54:29 +13:00
mappu 696a92096d Backed out changeset: 77530eea6f02 2017-10-15 20:53:59 +13:00
mappu f125c23fb9 Removed tag release-1.1.0 2017-10-15 20:53:35 +13:00
mappu c50d6c4bfe rebuild staticResources.go for previous 2017-10-15 20:53:06 +13:00
mappu 5edea74333 fix missing baseURLs on remote script loads 2017-10-15 20:49:07 +13:00
mappu 1882a94e65 Added tag release-1.1.0 for changeset c7b699105bd1 2017-10-15 19:52:15 +13:00
mappu 1309293705 bump all versions to 1.1.1 2017-10-15 19:52:09 +13:00
mappu d24c3d4895 doc: changelog 2017-10-15 19:51:36 +13:00
mappu 5e9fcc09c8 bump versions to 1.1.0 2017-10-15 19:51:17 +13:00
mappu fb7f1dd3a5 doc: update changelog 2017-10-15 19:50:48 +13:00
mappu 2f4fe0a55f enable ctrl-V support 2017-10-15 19:49:46 +13:00
mappu a3fc9092e3 doc: no need to point out the jquery dependency 2017-10-15 19:47:35 +13:00
mappu 6eb48bf72f doc: update changelog 2017-10-15 19:16:26 +13:00
mappu 40aa6c8917 option to disable the homepage 2017-10-15 19:16:22 +13:00
mappu 24febefba4 doc: update changelog 2017-10-15 19:12:25 +13:00
mappu 57d9b4d324 display xff IPs in log output 2017-10-15 19:11:13 +13:00
mappu 3ca73e3221 option for trustXForwardedFor 2017-10-15 19:09:14 +13:00
mappu 27cf4cf0c0 fix content selection in chrome prior to 54, et al 2017-10-15 19:00:49 +13:00
mappu ef6b680b7e doc: update changelog 2017-10-15 18:59:00 +13:00
mappu 188ca7e679 homepage: add "again" button 2017-10-15 18:54:14 +13:00
mappu b8f1b26aba sdk auto provide libraries, handle preInit calls, add get*URL methods, drawing canvas integration 2017-10-15 18:50:55 +13:00
mappu ac768524ee homepage: display widget in full size 2017-10-15 18:49:46 +13:00
mappu a5008ab455 vendor: track drawingboard 0.4.6 (MIT license) 2017-10-15 18:49:09 +13:00
mappu 9436dcd43e tweak progress captions in error cases 2017-10-08 19:14:11 +13:00
mappu f543347b1c remove a console.log call 2017-10-08 19:07:06 +13:00
mappu 582854878b doc: mention client body size for nginx 2017-10-08 19:06:42 +13:00
mappu 642ee5a485 doc: todo (2) 2017-10-08 18:42:47 +13:00
mappu 19b20daded doc: todo 2017-10-08 18:40:43 +13:00
mappu 270e6a9397 bump all versions to 1.0.2 2017-10-08 17:16:37 +13:00
mappu ef5351a075 Added tag release-1.0.1 for changeset b8975b9e7564 2017-10-08 17:16:27 +13:00
mappu 01ac021c7e doc: todo 2017-10-08 17:16:21 +13:00
mappu 47aeaff66e doc: changelog 2017-10-08 17:12:56 +13:00
mappu d18a5fd272 bump all versions to 1.0.1 2017-10-08 17:12:49 +13:00
mappu 3a0566ca0a doc: tweak SDK information 2017-10-08 17:12:26 +13:00
mappu 36f62dd502 doc: add image 2017-10-08 17:12:17 +13:00
mappu 5b1a94c735 fix CORS 2017-10-08 17:06:24 +13:00
mappu 75c3a98f33 fix no default index page 2017-10-08 16:54:26 +13:00
mappu e9fbfd0277 Added tag release-1.0.0 for changeset e2250a7fd290 2017-10-08 16:43:50 +13:00
31 changed files with 1588 additions and 819 deletions

View File

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

View File

@ -2,7 +2,7 @@
# Makefile for contented
#
VERSION:=1.0.0
VERSION:=1.2.2
SOURCES:=Makefile \
static \
@ -21,22 +21,12 @@ all: build/linux64/contented build/win32/contented.exe
dist: \
_dist/contented-$(VERSION)-linux64.tar.gz \
_dist/contented-$(VERSION)-win32.7z \
_dist/contented-$(VERSION)-src.zip
clean:
if [ -f ./staticResources.go ] ; then rm ./staticResources.go ; fi
if [ -d ./build ] ; then rm -r ./build ; 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
#
@ -48,26 +38,6 @@ build/linux64/contented: $(SOURCES) staticResources.go
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
mkdir -p _dist
tar caf _dist/contented-$(VERSION)-linux64.tar.gz -C build/linux64 contented --owner=0 --group=0
_dist/contented-$(VERSION)-win32.7z: build/win32/contented.exe
mkdir -p _dist
( cd build/win32 ; \
if [ -f dist.7z ] ; then rm dist.7z ; fi ; \
7z a dist.7z contented.exe ; \
mv dist.7z ../../_dist/contented-$(VERSION)-win32.7z \
)
_dist/contented-$(VERSION)-src.zip: $(SOURCES)
hg archive --type=zip _dist/contented-$(VERSION)-src.zip

View File

@ -3,13 +3,14 @@ package contented
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/boltdb/bolt"
"github.com/speps/go-hashids"
bolt "go.etcd.io/bbolt"
)
const (
@ -47,7 +48,7 @@ func idToString(v uint64) string {
hd := hashids.NewData()
hd.Salt = hashIdSalt
hd.MinLength = hashIdMinLength
h, _ := hashids.NewWithData(hd)
h := hashids.NewWithData(hd)
s, _ := h.EncodeInt64([]int64{int64(v)})
return s
}
@ -62,9 +63,17 @@ func (this *Server) AddMetadata(m Metadata) (string, error) {
err = this.db.Update(func(tx *bolt.Tx) error {
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)
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 {
return "", err

162
README.md Normal file
View 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)*

126
Server.go
View File

@ -1,36 +1,82 @@
package contented
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"regexp"
"strings"
"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"
bolt "go.etcd.io/bbolt"
)
//go:embed static
var staticAssets embed.FS
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 {
AppTitle string
MaxUploadBytes int64
AppTitle string
MaxUploadBytes int64
CanonicalBaseURL string
}
type ServerOptions struct {
DataDirectory string
DBPath string
BandwidthLimit int64
StorageType int // STORAGE_xx
DataDirectory string
DataS3Options struct {
Hostname string
AccessKey string
SecretKey string
Bucket string
Prefix string
}
DBPath string
DiskFilesWorldReadable bool
BandwidthLimit int64
TrustXForwardedFor bool
EnableHomepage bool
EnableUpload bool
MaxConcurrentThumbs int
MaxThumbSizeBytes int64
ServerPublicProperties
}
func (this *ServerOptions) FileMode() os.FileMode {
if this.DiskFilesWorldReadable {
return 0644
} else {
return 0600
}
}
type Server struct {
opts ServerOptions
s3client *minio.Client
db *bolt.DB
startTime time.Time
thumbnailSem chan struct{}
metadataBucket []byte
staticDir fs.FS // interface
}
func NewServer(opts *ServerOptions) (*Server, error) {
@ -40,6 +86,37 @@ func NewServer(opts *ServerOptions) (*Server, error) {
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)
if err != nil {
return nil, err
@ -75,17 +152,27 @@ func (this *Server) handleAbout(w http.ResponseWriter) {
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"), ":")
}
const (
downloadUrlPrefix = `/get/`
metadataUrlPrefix = `/info/`
previewUrlPrefix = `/p/`
)
var rxThumbUrl = regexp.MustCompile(`^/thumb/(.)/(.*)$`)
func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set(`Server`, SERVER_HEADER)
w.Header().Set(`Access-Control-Allow-Origin`, `*`) // Blanket allow CORS
if this.opts.MaxUploadBytes > 0 {
r.Body = http.MaxBytesReader(w, r.Body, this.opts.MaxUploadBytes)
}
@ -101,6 +188,13 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, 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` {
this.handleAbout(w)
@ -108,12 +202,20 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
this.handleUpload(w, r)
} else if r.Method == "OPTIONS" {
// Blanket allow
w.Header().Set(`Access-Control-Allow-Origin`, `*`)
// Blanket allow (headers already set)
w.WriteHeader(200)
} else if static, err := Asset(r.URL.Path[1:]); err == nil && r.Method == "GET" {
http.ServeContent(w, r, r.URL.Path[1:], this.startTime, bytes.NewReader(static))
} else if r.Method == "GET" {
// Conditionally block homepage access
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 {
http.Error(w, "Not found", 404)

View File

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

View File

@ -1,61 +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.
The SDK will run your callback, passing it the 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.0
- Initial public release

View File

@ -0,0 +1,58 @@
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"sync"
"code.ivysaur.me/contented"
)
type ContentedMultiCfg struct {
Servers []struct {
ListenAddr string
Options contented.ServerOptions
}
}
func main() {
configFile := flag.String("config", "contented-multi.cfg", "Path to configuration file")
flag.Parse()
fh, err := os.Open(*configFile)
if err != nil {
panic(err)
}
var cfg ContentedMultiCfg
err = json.NewDecoder(fh).Decode(&cfg)
if err != nil {
panic(err)
}
fh.Close()
wg := sync.WaitGroup{}
wg.Add(len(cfg.Servers))
for i, _ := range cfg.Servers {
go (func(i int) {
defer wg.Done()
s, err := contented.NewServer(&cfg.Servers[i].Options)
if err != nil {
log.Printf("Failed to create server %d/%d: %s", i+1, len(cfg.Servers), err.Error())
return
}
err = http.ListenAndServe(cfg.Servers[i].ListenAddr, s)
log.Printf("Server %d/%d shutting down: %s", i+1, len(cfg.Servers), err.Error())
})(i)
}
wg.Wait()
}

View File

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

BIN
doc/image1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
doc/image1.thumb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -4,13 +4,12 @@ import (
"log"
"net/http"
"os"
"path/filepath"
)
func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) {
err := this.handleViewInternal(w, r, r.URL.Path[len(downloadUrlPrefix):])
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) {
http.Error(w, "File not found", 404)
} else {
@ -28,7 +27,7 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
}
// Load file
f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
f, err := this.ReadFile(r.Context(), m.FileHash)
if err != nil {
return err
}
@ -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
// set accurately (including blacklist)
w.Header().Set(`Content-Type`, m.MimeType)
if m.MimeType == `application/octet-stream` {
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-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
}

33
go.mod Normal file
View 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
View 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=

215
preview.go Normal file
View 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.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

File diff suppressed because one or more lines are too long

4
static/drawingboard-0.4.6.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -7,9 +7,23 @@
<style type="text/css">
html, body {
font-family: sans-serif;
margin:0;
width:100%;
height:100%;
}
#padder {
height:100%;
position:relative;
}
#surrogate-area {
height:300px;
position:absolute;
top:10px;
bottom:10px;
left:10px;
right:10px;
}
button.again {
margin-top:2em;
}
/* hide close button */
.contented-close {
@ -18,11 +32,13 @@ html, body {
</style>
</head>
<body>
<div id="surrogate-area">
Loading...
</div>
<div id="padder">
<div id="surrogate-area">
Loading...
</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">
"use strict";
@ -40,17 +56,7 @@ $.get("/about", function(ret) {
// Load upload widget
contented.init("#surrogate-area", function(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);
window.location.href = contented.getMultiPreviewURL(items);
});
</script>
</body>

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

BIN
static/nothumb_1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
static/nothumb_160.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
static/nothumb_340.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
static/nothumb_640.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
static/nothumb_90.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,221 +1,640 @@
;
var contented = (function ($, currentScriptPath) {
"use strict";
var baseURL = currentScriptPath.replace('sdk.js', '');
;(function() {
"use strict";
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) {
if (bytes < 1024) {
var k = 1024, m = (1024*1024), g = (1024*1024*1024);
if (bytes < k) {
return bytes + " B";
} else if (bytes < (1024*1024)) {
return (bytes / 1024).toFixed(1) + " KiB";
} else if (bytes < (1024*1024*1024)) {
return (bytes / (1024*1024)).toFixed(1) + " MiB";
} else if (bytes < m) {
return (bytes / k).toFixed(1) + " KiB";
} else if (bytes < g) {
return (bytes / m).toFixed(1) + " MiB";
} else {
return (bytes / (1024*1024*1024)).toFixed(1) + " GiB";
return (bytes / g).toFixed(1) + " GiB";
}
};
// @ref https://stackoverflow.com/a/2117523
var guid = function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
/**
* supportsDrop returns whether drag-and-drop is supported by this browser.
*
* @return bool
*/
var supportsDrop = function () {
return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
}
var widgetHtml = `
<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">
/**
* 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
*/
var initArea = function (elementSelector, onUploaded, onClose) {
onUploaded = onUploaded || function () { };
onClose = onClose || function () { };
<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>
if ($(elementSelector).length != 1) {
return; // should only find one element
}
var element = $(elementSelector)[0];
<div class="contented-upload-type-selector">
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
<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>
// Create a new div for ourselves on top of the existing area
$.get(baseURL + "about", function (ret) {
var extraText = "";
if (ret.MaxUploadBytes > 0) {
extraText = " (max " + formatBytes(ret.MaxUploadBytes) + ")";
}
<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>
$.get(baseURL + "widget.html", function (widgetHtml) {
<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>
var $f = $("<div>").html(widgetHtml);
$f.find(".contented-extratext").text(extraText);
<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>
var ourClose = function () {
$f.remove(); // remove from dom
onClose(); // upstream close
};
$f.find(".contented-close").click(function () {
ourClose();
})
<div class="contented-upload-if contented-if-file">
<label>Select files to upload <span class="contented-extratext"></span></label><br>
<input class="contented-file-selector" type="file" multiple>
<button class="contented-file-upload">Upload &raquo;</button>
</div>
var setType = function (type) {
$f.find(".contented-upload-type").removeClass("contented-upload-type-active");
$f.find(".contented-upload-type[data-upload-type=" + type + "]").addClass("contented-upload-type-active");
<div class="contented-upload-if contented-if-paste">
<textarea placeholder="Paste content here"></textarea>
<button class="contented-paste-upload">Upload &raquo;</button>
</div>
$f.find(".contented-upload-if").removeClass("contented-active");
$f.find(".contented-if-" + type).addClass("contented-active");
};
<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>
$f.find(".contented-upload-type").click(function () {
setType($(this).attr('data-upload-type'));
});
<div class="contented-upload-if contented-if-album">
<input type="text" class="contented-album-title" placeholder="Album name"><br>
<input type="text" class="contented-album-items" placeholder="Image IDs, separated with commas">
<br><br>
<button class="contented-album-upload">Make album &raquo;</button>
</div>
</div>
</div>
`;
if (!supportsDrop()) {
// switch default
setType('file');
}
var initArea = function (aboutInfo, elementSelector, onUploaded, onClose) {
onUploaded = onUploaded || function () { };
onClose = onClose || function () { };
//
if ($(elementSelector).length != 1) {
return; // should only find one element
}
var element = $(elementSelector)[0];
var $element = $(element);
var offset = $element.offset();
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
$f.css({
'position': 'absolute',
'left': offset.left + "px",
'top': offset.top + "px",
'width': $element.width() + "px",
'min-width': $element.width() + "px",
'max-width': $element.width() + "px",
'height': $element.height() + "px",
'min-height': $element.height() + "px",
'max-height': $element.height() + "px"
});
// Create a new div for ourselves on top of the existing area
var extraText = "";
if (aboutInfo.MaxUploadBytes > 0) {
extraText = " (max " + formatBytes(aboutInfo.MaxUploadBytes) + ")";
}
$f.find('.contented').on('dragover dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
$(this).addClass('is-dragging');
});
var $f = $("<div>").html(widgetHtml);
$f.find(".contented-extratext").text(extraText);
$f.find('.contented').on('dragleave dragend', function (e) {
e.preventDefault();
e.stopPropagation();
$(this).removeClass('is-dragging');
});
// Tab buttons
var hasSetupDrawingBoardYet = false;
var setType = function (type) {
$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').on('drop', function (e) {
e.preventDefault();
e.stopPropagation();
handleUploadFrom(e.originalEvent.dataTransfer.files);
});
$f.find(".contented-upload-if").removeClass("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-file-upload').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
handleUploadFrom($(".contented-file-selector")[0].files);
});
$f.find(".contented-upload-type").click(function () {
setType($(this).attr('data-upload-type'));
});
$f.find('.contented-paste-upload').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
var blob = new Blob([$(".contented-if-paste textarea").val()], {type : 'text/plain'});
handleUploadFrom([blob]);
});
// Widget positioning
$("body").append($f);
var $element = $(element);
var offset = $element.offset();
var setProgressCaption = function(message) {
$f.find(".contented-if-progress label").text(message);
};
var setProgressPercentage = function(frc) {
$f.find(".contented-progress-element").css('width', (frc * 100) + "%");
};
$f.css({
'position': 'absolute',
'left': offset.left + "px",
'top': offset.top + "px",
'width': $element.width() + "px",
'min-width': $element.width() + "px",
'max-width': $element.width() + "px",
'height': $element.height() + "px",
'min-height': $element.height() + "px",
'max-height': $element.height() + "px"
});
var handleUploadFrom = function (files) {
// Drag and drop support
$f.find('.contented').on('dragover dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
$(this).addClass('is-dragging');
});
setProgressCaption("Uploading, please wait...");
setProgressPercentage(0);
setType("progress");
$f.find('.contented').on('dragleave dragend', function (e) {
e.preventDefault();
e.stopPropagation();
$(this).removeClass('is-dragging');
});
$f.find(".contented-upload-type-selector").hide();
$f.find(".contented").removeClass('is-dragging');
$f.find('.contented').on('drop', function (e) {
e.preventDefault();
e.stopPropagation();
handleUploadFrom(e.originalEvent.dataTransfer.files);
});
// Ajax uploader
var ajaxData = new FormData();
for (var i = 0; i < files.length; ++i) {
ajaxData.append("f", files[i]);
}
$f.find('.contented-file-upload').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
handleUploadFrom($(".contented-file-selector")[0].files);
});
// ajax request
$.ajax({
url: baseURL + "upload",
type: "POST",
data: ajaxData,
dataType: 'json', // response type
cache: false,
contentType: false,
processData: false,
xhr: function() {
var xhr = $.ajaxSettings.xhr();
xhr.upload.addEventListener(
'progress',
function(ev) {
console.log([ev.lengthComputable, ev.loaded, ev.total]);
if (ev.lengthComputable) {
setProgressCaption("Uploading (" + formatBytes(ev.loaded) + " / " + formatBytes(ev.total) + ")...");
setProgressPercentage(ev.total == 0 ? 0 : ev.loaded / ev.total);
}
},
false
);
return xhr;
},
complete: function () {
setProgressCaption("Upload complete.");
setProgressPercentage(1);
},
success: function (data) {
onUploaded(data);
ourClose();
},
error: function () {
setProgressCaption("Upload failed.");
}
});
// Pastebin
$f.find('.contented-paste-upload').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
var blob = new Blob([$(".contented-if-paste textarea").val()], {type : 'text/plain'});
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(",");
//
return {
'supportsDrop': supportsDrop,
'init': initArea
};
for (var i = 0; i < childIDs.length; ++i) {
childIDs[i] = childIDs[i].trim(); // Basic validation - can't really perform full validation here
})(
jQuery,
(function () {
"use strict";
if (childIDs[i].length == 0) {
alert("Entry " + (i+1) + " is too short, expected non-zero length");
return;
}
// 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];
if (! childIDs[i].match(/^[a-zA-Z0-9]+$/)) {
alert("Entry " + (i+1) + " contains unexpected character");
return;
}
}
return currentScript.replace(currentScriptFile, '');
})()
);
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);
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) {
$f.find(".contented-if-progress label").text(message);
};
var setProgressPercentage = function(frc) {
$f.find(".contented-progress-element").css('width', (frc * 100) + "%");
};
// Common upload handler
/**
*
* @param {File[]|Blob[]} files
*/
var handleUploadFrom = function(files) {
setProgressCaption("Uploading, please wait...");
setProgressPercentage(0);
setType("progress");
$f.find(".contented-upload-type-selector").hide();
$f.find(".contented").removeClass('is-dragging');
// Ajax uploader
var ajaxData = new FormData();
for (var i = 0; i < files.length; ++i) {
ajaxData.append("f", files[i]);
}
// ajax request
$.ajax({
url: contented.baseURL + "upload",
type: "POST",
data: ajaxData,
dataType: 'json', // response type
cache: false,
contentType: false,
processData: false,
xhr: function() {
var xhr = $.ajaxSettings.xhr();
xhr.upload.addEventListener(
'progress',
function(ev) {
if (ev.lengthComputable) {
setProgressCaption("Uploading (" + formatBytes(ev.loaded) + " / " + formatBytes(ev.total) + ")...");
setProgressPercentage(ev.total == 0 ? 0 : ev.loaded / ev.total);
}
},
false
);
return xhr;
},
complete: function () {
setProgressPercentage(1);
},
success: function (data) {
setProgressCaption("Upload completed successfully.");
onUploaded(data);
ourClose();
},
error: function () {
setProgressCaption("Upload failed!");
}
});
}
};
var init = function() {
var currentScriptPath = getCurrentScriptPath();
var baseURL = currentScriptPath.replace('sdk.js', '');
// Kick off background promises
var loader = new Promise(function(resolve, reject) {
if (typeof jQuery === "undefined") {
loadScript(contented.baseURL + "jquery-3.7.0.min.js").then(resolve);
} else {
resolve();
}
}).then(function() { return new Promise(function(resolve, reject) {
if (typeof DrawingBoard === "undefined") {
loadScript(contented.baseURL + "drawingboard-0.4.6.min.js").then(resolve);
} else {
resolve();
}
})}).then(function() { return new Promise(function(resolve, reject) {
$.get(contented.baseURL + "about", function (aboutInfo) {
resolve(aboutInfo);
});
})}).then(function(aboutInfo) {
// Update fields in global variable
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();
})()

View File

@ -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 &raquo;</button>
</div>
<div class="contented-upload-if contented-if-paste">
<textarea placeholder="Paste content here"></textarea>
<button class="contented-paste-upload">Upload &raquo;</button>
</div>
<div class="contented-upload-if contented-if-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
View 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")
}
}

144
thumb.go Normal file
View 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.ReadFile(ctx, m.FileHash)
if err != nil {
return err
}
_, err = io.CopyN(destFh, srcFh, m.FileSize)
srcFh.Close()
if err != nil {
return err
}
destFh.Seek(0, io.SeekStart)
filePath = destFh.Name()
} else {
panic("bad StorageType")
}
thumb, err := t.RenderFileAs(filePath, m.MimeType)
if err != nil {
return err
}
w.Header().Set(`Cache-Control`, `max-age=31536000, immutable`)
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(thumb)))
w.Header().Set(`Content-Type`, `image/jpeg`)
w.WriteHeader(200)
w.Write(thumb)
return nil
}

View File

@ -1,6 +1,7 @@
package contented
import (
"context"
"crypto/sha512"
"encoding/hex"
"encoding/json"
@ -9,24 +10,29 @@ import (
"mime"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
)
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 {
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)
return
}
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)
return
}
@ -36,14 +42,14 @@ func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
for _, fhs := range r.MultipartForm.File["f"] {
f, err := fhs.Open()
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)
return
}
path, err := this.handleUploadFile(f, fhs, remoteIP(r))
path, err := this.handleUploadFile(f, fhs, remoteIP)
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)
}
@ -52,7 +58,7 @@ func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
jb, err := json.Marshal(ret)
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)
return
}
@ -87,27 +93,14 @@ func (this *Server) handleUploadFile(src multipart.File, hdr *multipart.FileHead
return "", err
}
// Save file to disk
fileHash := hex.EncodeToString(hasher.Sum(nil))
dest, err := os.OpenFile(filepath.Join(this.opts.DataDirectory, fileHash), os.O_CREATE|os.O_WRONLY, 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 {
// Save file to disk/s3
err = this.SaveFile(context.Background(), fileHash, srcLen, src)
if err != nil {
return "", err
}
if shouldSave {
defer dest.Close()
_, err = io.CopyN(dest, src, int64(srcLen))
if err != nil {
return "", err
}
}
// Determine mime type
ctype := hdr.Header.Get("Content-Type")
if ctype == "" {