Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc1f73e52 | |||
| b48d1347c7 | |||
| bed8a88b09 | |||
| 0a70d99af5 | |||
| 6720cbc0d9 | |||
| 8d11b7c434 | |||
| 147608a327 | |||
| 951c87b63f | |||
| 25180afaa2 | |||
| 33ca03b9d7 | |||
| b562ca4bd4 | |||
| a25dc5827b | |||
| 5263f96956 | |||
| 2b2add62b2 | |||
| fdc93e6485 | |||
| b08f1c33d5 | |||
| 524f37d9fe | |||
| c958c57794 | |||
| 8fbad2a1e0 | |||
| 989cd195f8 | |||
| cb4933fa72 | |||
| f504ab5929 | |||
| f8e95a8037 | |||
| feaa51cfcd | |||
| ddf76aacc5 | |||
| ca8a0d55ba | |||
| fe4aace777 | |||
| be864235cb | |||
| dba699524f | |||
| 7950ca3004 | |||
| 6e5ca0c61c | |||
| 7255cd03cb | |||
| 3cf4986418 | |||
| 0a338c4568 | |||
| 0fbf401fd3 | |||
| 48ca68fafd | |||
| f3e594d307 | |||
| 396672b02b | |||
| d25b867e90 | |||
| cd60e4c855 | |||
| ee72f188a2 | |||
| 30f5b40e1d | |||
| 3a103ae484 | |||
| 08321818ff | |||
| 14456e6539 | |||
| f27d14aac4 | |||
| 6ab2b08099 | |||
| 930869759b | |||
| d35c81ed21 | |||
| 139117b4d5 | |||
| b88273ec64 | |||
| 79cb8733e5 | |||
| 366c307e02 | |||
| 23ad509f33 | |||
| a6e495f74d | |||
| b3ec40ae65 | |||
| 87f0cb016d | |||
| 381e67bb39 | |||
| c2f4de822f | |||
| ffd4c03d9c | |||
| 2e08ac06ca | |||
| 6d739972de | |||
| e36fd43f9b | |||
| 35bbc6c61b | |||
| 987c704730 | |||
| 9669f2aa0b | |||
| 99139e360d | |||
| 696a92096d | |||
| f125c23fb9 | |||
| c50d6c4bfe | |||
| 5edea74333 | |||
| 1882a94e65 | |||
| 1309293705 | |||
| d24c3d4895 | |||
| 5e9fcc09c8 | |||
| fb7f1dd3a5 | |||
| 2f4fe0a55f | |||
| a3fc9092e3 | |||
| 6eb48bf72f | |||
| 40aa6c8917 | |||
| 24febefba4 | |||
| 57d9b4d324 | |||
| 3ca73e3221 | |||
| 27cf4cf0c0 | |||
| ef6b680b7e | |||
| 188ca7e679 | |||
| b8f1b26aba | |||
| ac768524ee | |||
| a5008ab455 | |||
| 9436dcd43e | |||
| f543347b1c | |||
| 582854878b | |||
| 642ee5a485 | |||
| 19b20daded | |||
| 270e6a9397 | |||
| ef5351a075 | |||
| 01ac021c7e | |||
| 47aeaff66e | |||
| d18a5fd272 | |||
| 3a0566ca0a | |||
| 36f62dd502 | |||
| 5b1a94c735 | |||
| 75c3a98f33 | |||
| e9fbfd0277 |
2
.hgignore → .gitignore
vendored
@@ -1,5 +1,3 @@
|
||||
syntax: glob
|
||||
|
||||
cmd/contented/contented
|
||||
build/
|
||||
_dist/
|
||||
4
Makefile
@@ -2,7 +2,7 @@
|
||||
# Makefile for contented
|
||||
#
|
||||
|
||||
VERSION:=1.0.0
|
||||
VERSION:=1.2.2
|
||||
|
||||
SOURCES:=Makefile \
|
||||
static \
|
||||
@@ -69,5 +69,5 @@ _dist/contented-$(VERSION)-win32.7z: build/win32/contented.exe
|
||||
)
|
||||
|
||||
_dist/contented-$(VERSION)-src.zip: $(SOURCES)
|
||||
hg archive --type=zip _dist/contented-$(VERSION)-src.zip
|
||||
git archive HEAD -o _dist/contented-$(VERSION)-src.zip
|
||||
|
||||
|
||||
@@ -47,7 +47,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
|
||||
}
|
||||
|
||||
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# contented
|
||||
|
||||
[](doc/image1.png)
|
||||
|
||||
A file / image / paste upload server with a focus on embedding.
|
||||
|
||||
You can use contented as a standalone upload server, or you can use the SDK to embed its upload widget into another website.
|
||||
|
||||
## Features
|
||||
|
||||
- Drag and drop upload
|
||||
- Multiple files upload
|
||||
- Pastebin upload
|
||||
- Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js))
|
||||
- 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 [Hashids](http://hashids.org) 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
|
||||
-concurrentthumbs
|
||||
Simultaneous thumbnail generation (default 16)
|
||||
```
|
||||
|
||||
If you are hosting behind a reverse proxy, remember to set its post body size parameter appropriately (e.g. `client_max_body_size` for nginx).
|
||||
|
||||
## Usage (HTTP)
|
||||
|
||||
The server responds on the following URLs:
|
||||
|
||||
URL |Method |Description
|
||||
---------------------|-------|---
|
||||
`/get/{ID}` |`GET` |Download item content
|
||||
`/info/{ID}` |`GET` |Get item content metadata (JSON)
|
||||
`/thumb/{Type}/{ID}` |`GET` |Get item thumbnail image
|
||||
`/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. Your callback will be passed an array of file IDs of any uploaded items.
|
||||
|
||||
```html
|
||||
<script type="text/javascript" src="SERVER_ADDR/sdk.js"></script>
|
||||
contented.init("#target", function(/* String[] */ items) {});
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
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)*
|
||||
66
Server.go
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,22 +16,38 @@ import (
|
||||
|
||||
var SERVER_HEADER string = `contented/0.0.0-dev`
|
||||
|
||||
const DEFAULT_MAX_CONCURRENT_THUMBS = 16
|
||||
|
||||
type ServerPublicProperties struct {
|
||||
AppTitle string
|
||||
MaxUploadBytes int64
|
||||
AppTitle string
|
||||
MaxUploadBytes int64
|
||||
CanonicalBaseURL string
|
||||
}
|
||||
|
||||
type ServerOptions struct {
|
||||
DataDirectory string
|
||||
DBPath string
|
||||
BandwidthLimit int64
|
||||
DataDirectory string
|
||||
DBPath string
|
||||
DiskFilesWorldReadable bool
|
||||
BandwidthLimit int64
|
||||
TrustXForwardedFor bool
|
||||
EnableHomepage bool
|
||||
MaxConcurrentThumbs int
|
||||
ServerPublicProperties
|
||||
}
|
||||
|
||||
func (this *ServerOptions) FileMode() os.FileMode {
|
||||
if this.DiskFilesWorldReadable {
|
||||
return 0644
|
||||
} else {
|
||||
return 0600
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
opts ServerOptions
|
||||
db *bolt.DB
|
||||
startTime time.Time
|
||||
thumbnailSem chan struct{}
|
||||
metadataBucket []byte
|
||||
}
|
||||
|
||||
@@ -40,6 +58,17 @@ 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)
|
||||
}
|
||||
|
||||
// "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 +104,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 +140,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(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,11 +154,13 @@ 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" {
|
||||
} else if r.Method == "GET" && r.URL.Path == `/` && this.opts.EnableHomepage {
|
||||
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`) {
|
||||
http.ServeContent(w, r, r.URL.Path[1:], this.startTime, bytes.NewReader(static))
|
||||
|
||||
} else {
|
||||
|
||||
13
TODO.txt
@@ -1,13 +0,0 @@
|
||||
TODO
|
||||
|
||||
- View server-wide recent uploads history / all upload history
|
||||
|
||||
- Display 'my uploads' (id + metadata history kept in localstorage)
|
||||
|
||||
- Encrypted at rest (anti- provider snooping)
|
||||
|
||||
- Nicer preview page after uploading
|
||||
|
||||
- Option to disable manual uploading from the landing page
|
||||
|
||||
- Gallery preview (multiple element hashid)
|
||||
@@ -1,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
|
||||
@@ -18,13 +18,21 @@ func main() {
|
||||
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)")
|
||||
diskFilesWorldReadable := flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
|
||||
maxConcurrentThumbs := flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
svr, err := contented.NewServer(&contented.ServerOptions{
|
||||
DataDirectory: *dataDir,
|
||||
DBPath: *dbPath,
|
||||
BandwidthLimit: int64(*maxUploadSpeed),
|
||||
DataDirectory: *dataDir,
|
||||
DBPath: *dbPath,
|
||||
BandwidthLimit: int64(*maxUploadSpeed),
|
||||
TrustXForwardedFor: *trustXForwardedFor,
|
||||
EnableHomepage: *enableHomepage,
|
||||
DiskFilesWorldReadable: *diskFilesWorldReadable,
|
||||
MaxConcurrentThumbs: *maxConcurrentThumbs,
|
||||
ServerPublicProperties: contented.ServerPublicProperties{
|
||||
AppTitle: *appTitle,
|
||||
MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
|
||||
|
||||
BIN
doc/image1.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
doc/image1.thumb.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
14
download.go
@@ -10,7 +10,7 @@ import (
|
||||
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 {
|
||||
@@ -37,9 +37,17 @@ 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)
|
||||
|
||||
11
go.mod
Normal file
@@ -0,0 +1,11 @@
|
||||
module code.ivysaur.me/contented
|
||||
|
||||
require (
|
||||
code.ivysaur.me/thumbnail v1.0.2
|
||||
github.com/boltdb/bolt v1.3.1
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
|
||||
github.com/speps/go-hashids v1.0.0
|
||||
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b // indirect
|
||||
)
|
||||
|
||||
go 1.13
|
||||
17
go.sum
Normal file
@@ -0,0 +1,17 @@
|
||||
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/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/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/speps/go-hashids v1.0.0 h1:jdFC07PrExRM4Og5Ev4411Tox75aFpkC77NlmutadNI=
|
||||
github.com/speps/go-hashids v1.0.0/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
|
||||
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/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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
118
preview.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package contented
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (this *Server) handlePreview(w http.ResponseWriter, fileIDList string) {
|
||||
|
||||
fileIDs := strings.Split(fileIDList, `-`)
|
||||
|
||||
tmpl := `<!DOCTYPE html>
|
||||
<html prefix="og: http://ogp.me/ns#">
|
||||
<head>
|
||||
<title>` + html.EscapeString(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;
|
||||
text-align: center;
|
||||
}
|
||||
.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) {
|
||||
http.Error(w, "Not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println(err.Error())
|
||||
http.Error(w, "Internal error", 500)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl += `
|
||||
<div class="entry">
|
||||
<div class="thumbnail">
|
||||
<a href="` + html.EscapeString(`/get/`+fileID) + `"><img src="` + html.EscapeString(`/thumb/m/`+fileID) + `"></a>
|
||||
</div>
|
||||
<div class="properties">
|
||||
<b>Name:</b> ` + html.EscapeString(m.Filename) + `<br>
|
||||
<b>Hash:</b> <span title="` + html.EscapeString(m.FileHash) + `">hover</span><br>
|
||||
<b>File type:</b> ` + html.EscapeString(m.MimeType) + `<br>
|
||||
<b>Size:</b> ` + html.EscapeString(fmt.Sprintf("%d", m.FileSize)) + `<br>
|
||||
<b>Uploader:</b> ` + html.EscapeString(m.UploadIP) + `<br>
|
||||
<b>Uploaded at:</b> ` + html.EscapeString(m.UploadTime.Format(time.RFC3339)) + `<br>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if this.opts.EnableHomepage {
|
||||
tmpl += `
|
||||
<div class="return">
|
||||
<button onclick="window.location.href='/'">Again...</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
tmpl += `
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
w.Header().Set(`Content-Type`, `text/html; charset=UTF-8`)
|
||||
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(tmpl)))
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(tmpl))
|
||||
}
|
||||
5
static/drawingboard-0.4.6.min.css
vendored
Normal file
4
static/drawingboard-0.4.6.min.js
vendored
Normal file
@@ -7,9 +7,23 @@
|
||||
<style type="text/css">
|
||||
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,9 +32,11 @@ 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="/sdk.js"></script>
|
||||
@@ -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>
|
||||
|
||||
BIN
static/nothumb_1024.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
static/nothumb_160.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/nothumb_340.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
static/nothumb_640.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
static/nothumb_90.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
655
static/sdk.js
@@ -1,214 +1,29 @@
|
||||
;
|
||||
var contented = (function ($, currentScriptPath) {
|
||||
"use strict";
|
||||
|
||||
var baseURL = currentScriptPath.replace('sdk.js', '');
|
||||
//
|
||||
|
||||
var formatBytes = function(bytes) {
|
||||
if (bytes < 1024) {
|
||||
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 {
|
||||
return (bytes / (1024*1024*1024)).toFixed(1) + " GiB";
|
||||
}
|
||||
};
|
||||
var contented = (function() {
|
||||
"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) {
|
||||
|
||||
/**
|
||||
* 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 binStr = atob( this.toDataURL(type, quality).split(',')[1] ),
|
||||
len = binStr.length,
|
||||
arr = new Uint8Array(len);
|
||||
|
||||
/**
|
||||
* 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 () { };
|
||||
for (var i = 0; i < len; i++ ) {
|
||||
arr[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
if ($(elementSelector).length != 1) {
|
||||
return; // should only find one element
|
||||
}
|
||||
var element = $(elementSelector)[0];
|
||||
|
||||
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
|
||||
|
||||
// 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) + ")";
|
||||
}
|
||||
|
||||
$.get(baseURL + "widget.html", function (widgetHtml) {
|
||||
|
||||
var $f = $("<div>").html(widgetHtml);
|
||||
$f.find(".contented-extratext").text(extraText);
|
||||
|
||||
var ourClose = function () {
|
||||
$f.remove(); // remove from dom
|
||||
onClose(); // upstream close
|
||||
};
|
||||
$f.find(".contented-close").click(function () {
|
||||
ourClose();
|
||||
})
|
||||
|
||||
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-upload-if").removeClass("contented-active");
|
||||
$f.find(".contented-if-" + type).addClass("contented-active");
|
||||
};
|
||||
|
||||
$f.find(".contented-upload-type").click(function () {
|
||||
setType($(this).attr('data-upload-type'));
|
||||
});
|
||||
|
||||
if (!supportsDrop()) {
|
||||
// switch default
|
||||
setType('file');
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
var $element = $(element);
|
||||
var offset = $element.offset();
|
||||
|
||||
$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"
|
||||
});
|
||||
|
||||
$f.find('.contented').on('dragover dragenter', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).addClass('is-dragging');
|
||||
});
|
||||
|
||||
$f.find('.contented').on('dragleave dragend', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).removeClass('is-dragging');
|
||||
});
|
||||
|
||||
$f.find('.contented').on('drop', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUploadFrom(e.originalEvent.dataTransfer.files);
|
||||
});
|
||||
|
||||
$f.find('.contented-file-upload').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUploadFrom($(".contented-file-selector")[0].files);
|
||||
});
|
||||
|
||||
$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]);
|
||||
});
|
||||
|
||||
$("body").append($f);
|
||||
|
||||
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) + "%");
|
||||
};
|
||||
|
||||
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: 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.");
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
return {
|
||||
'supportsDrop': supportsDrop,
|
||||
'init': initArea
|
||||
};
|
||||
|
||||
})(
|
||||
jQuery,
|
||||
(function () {
|
||||
"use strict";
|
||||
callback( new Blob( [arr], {type: type || 'image/png'} ) );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var getCurrentScriptPath = function () {
|
||||
// Determine current script path
|
||||
// @ref https://stackoverflow.com/a/26023176
|
||||
var scripts = document.querySelectorAll('script[src]');
|
||||
@@ -217,5 +32,433 @@ var contented = (function ($, currentScriptPath) {
|
||||
var currentScriptFile = currentScriptChunks[currentScriptChunks.length - 1];
|
||||
|
||||
return currentScript.replace(currentScriptFile, '');
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
var currentScriptPath = getCurrentScriptPath();
|
||||
var baseURL = currentScriptPath.replace('sdk.js', '');
|
||||
|
||||
return {
|
||||
"loaded": false,
|
||||
|
||||
"baseURL": baseURL,
|
||||
|
||||
"__preInit": [],
|
||||
|
||||
/**
|
||||
* initArea shows the contented upload widget over the top of a target DOM element.
|
||||
*
|
||||
* @param any element Drop target (string selector / DOMElement / jQuery)
|
||||
* @param Function onUploaded Called with an array of upload IDs
|
||||
* @param Function onClose Called when the widget is being destroyed
|
||||
*/
|
||||
"init": function(elementSelector, onUploaded, onClose) {
|
||||
contented.__preInit.push([elementSelector, onUploaded, onClose]);
|
||||
},
|
||||
|
||||
/**
|
||||
* supportsDrop returns whether drag-and-drop is supported by this browser.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
"supportsDrop": function() {
|
||||
return ('ondrop' in window && 'FormData' in window && 'FileReader' in window);
|
||||
},
|
||||
|
||||
"getPreviewURL": function(id) {
|
||||
return baseURL + "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');
|
||||
script.onload = onLoad;
|
||||
script.src = url;
|
||||
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 k = 1024, m = (1024*1024), g = (1024*1024*1024);
|
||||
|
||||
if (bytes < k) {
|
||||
return bytes + " B";
|
||||
} else if (bytes < m) {
|
||||
return (bytes / k).toFixed(1) + " KiB";
|
||||
} else if (bytes < g) {
|
||||
return (bytes / m).toFixed(1) + " MiB";
|
||||
} else {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
var afterScriptsLoaded = function() {
|
||||
|
||||
var initArea = function (elementSelector, onUploaded, onClose) {
|
||||
onUploaded = onUploaded || function () { };
|
||||
onClose = onClose || function () { };
|
||||
|
||||
if ($(elementSelector).length != 1) {
|
||||
return; // should only find one element
|
||||
}
|
||||
var element = $(elementSelector)[0];
|
||||
|
||||
// <input type="hidden" name="MAX_FILE_SIZE" value="` + ret.MaxUploadBytes + `" />
|
||||
|
||||
// Create a new div for ourselves on top of the existing area
|
||||
$.get(contented.baseURL + "about", function (ret) {
|
||||
|
||||
var extraText = "";
|
||||
if (ret.MaxUploadBytes > 0) {
|
||||
extraText = " (max " + formatBytes(ret.MaxUploadBytes) + ")";
|
||||
}
|
||||
|
||||
$.get(contented.baseURL + "widget.html", function (widgetHtml) {
|
||||
|
||||
var $f = $("<div>").html(widgetHtml);
|
||||
$f.find(".contented-extratext").text(extraText);
|
||||
|
||||
// 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-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-upload-type").click(function () {
|
||||
setType($(this).attr('data-upload-type'));
|
||||
});
|
||||
|
||||
// Widget positioning
|
||||
|
||||
var $element = $(element);
|
||||
var offset = $element.offset();
|
||||
|
||||
$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"
|
||||
});
|
||||
|
||||
// Drag and drop support
|
||||
|
||||
$f.find('.contented').on('dragover dragenter', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).addClass('is-dragging');
|
||||
});
|
||||
|
||||
$f.find('.contented').on('dragleave dragend', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$(this).removeClass('is-dragging');
|
||||
});
|
||||
|
||||
$f.find('.contented').on('drop', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUploadFrom(e.originalEvent.dataTransfer.files);
|
||||
});
|
||||
|
||||
$f.find('.contented-file-upload').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUploadFrom($(".contented-file-selector")[0].files);
|
||||
});
|
||||
|
||||
// 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]);
|
||||
});
|
||||
|
||||
// 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
|
||||
|
||||
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!");
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// .
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Update fields in global variable
|
||||
contented.init = initArea;
|
||||
contented.loaded = true;
|
||||
|
||||
// Call initArea for all pre-initialised elements
|
||||
for (var i = 0; i < contented.__preInit.length; ++i) {
|
||||
initArea(contented.__preInit[i][0], contented.__preInit[i][1], contented.__preInit[i][2]);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
})()
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
.contented .contented-upload-type-selector {
|
||||
display:block;
|
||||
margin-bottom: 1em;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.contented .contented-upload-type {
|
||||
@@ -66,7 +67,7 @@
|
||||
.contented-upload-if {
|
||||
display:none;
|
||||
}
|
||||
.contented-if-paste {
|
||||
.contented-if-paste, .contented-if-drawing {
|
||||
height:100%;
|
||||
}
|
||||
.contented-upload-if.contented-active {
|
||||
@@ -87,12 +88,14 @@
|
||||
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">
|
||||
@@ -122,11 +125,17 @@
|
||||
<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 to upload <span class="contented-extratext"></span></label>
|
||||
<label>Drop files or Ctrl-V to upload <span class="contented-extratext"></span></label>
|
||||
</div>
|
||||
|
||||
<div class="contented-upload-if contented-if-file">
|
||||
@@ -140,9 +149,13 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
104
thumb.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package contented
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"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
|
||||
<-this.thumbnailSem
|
||||
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
|
||||
}
|
||||
|
||||
filePath := filepath.Join(this.opts.DataDirectory, m.FileHash)
|
||||
thumb, err := t.RenderFileAs(filePath, m.MimeType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
18
upload.go
@@ -18,15 +18,17 @@ import (
|
||||
|
||||
func (this *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err := r.ParseMultipartForm(this.opts.MaxUploadBytes * 2)
|
||||
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 +38,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 +54,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
|
||||
}
|
||||
@@ -89,7 +91,7 @@ func (this *Server) handleUploadFile(src multipart.File, hdr *multipart.FileHead
|
||||
|
||||
// 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)
|
||||
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
|
||||
|
||||