Compare commits
	
		
			22 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2434cccf59 | |||
| 426d5738a0 | |||
| 76c2e552ab | |||
| ea9ef2e466 | |||
| 6b5b11fb5f | |||
| 156115d5f9 | |||
| 44af6efc87 | |||
| 9bd6e881b2 | |||
| bfd669bc96 | |||
| ea1309eb75 | |||
| b146db9d0a | |||
| 0a22d1ca8a | |||
| bc911327cf | |||
| e04900f672 | |||
| d10026ae82 | |||
| 36b4b124f7 | |||
| 8d40031edc | |||
| 15c02efe96 | |||
| 01ff8f69aa | |||
| 3e5fb091c9 | |||
| caf521c318 | |||
| 77a4061cdd | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,5 @@
 | 
				
			|||||||
cmd/contented/contented
 | 
					cmd/contented/contented
 | 
				
			||||||
 | 
					cmd/contented-multi/contented-multi
 | 
				
			||||||
build/
 | 
					build/
 | 
				
			||||||
_dist/
 | 
					_dist/
 | 
				
			||||||
contented.db
 | 
					contented.db
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@ import (
 | 
				
			|||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/speps/go-hashids"
 | 
						"github.com/speps/go-hashids/v2"
 | 
				
			||||||
	bolt "go.etcd.io/bbolt"
 | 
						bolt "go.etcd.io/bbolt"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -48,7 +48,11 @@ func idToString(v uint64) string {
 | 
				
			|||||||
	hd := hashids.NewData()
 | 
						hd := hashids.NewData()
 | 
				
			||||||
	hd.Salt = hashIdSalt
 | 
						hd.Salt = hashIdSalt
 | 
				
			||||||
	hd.MinLength = hashIdMinLength
 | 
						hd.MinLength = hashIdMinLength
 | 
				
			||||||
	h := hashids.NewWithData(hd)
 | 
						h, err := hashids.NewWithData(hd)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							panic(err) // developer error
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	s, _ := h.EncodeInt64([]int64{int64(v)})
 | 
						s, _ := h.EncodeInt64([]int64{int64(v)})
 | 
				
			||||||
	return s
 | 
						return s
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										23
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								README.md
									
									
									
									
									
								
							@@ -10,20 +10,23 @@ The name is a pun on "content" and the -d suffix for server daemons.
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Features
 | 
					## Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Use local disk or S3-backed storage
 | 
				
			||||||
 | 
					- Optional hot/cold storage tiering
 | 
				
			||||||
- Drag and drop upload
 | 
					- Drag and drop upload
 | 
				
			||||||
- Multiple files upload
 | 
					- Multiple files upload
 | 
				
			||||||
- Pastebin upload
 | 
					- Pastebin upload
 | 
				
			||||||
- Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js))
 | 
					- Custom drawing upload ([via drawingboard.js](https://github.com/Leimi/drawingboard.js))
 | 
				
			||||||
- Ctrl-V upload
 | 
					- Ctrl-V upload
 | 
				
			||||||
- Galleries
 | 
					- Galleries and nested galleries
 | 
				
			||||||
- SDK-oriented design for embedding, including CORS support
 | 
					- SDK-oriented design for embedding, including CORS support
 | 
				
			||||||
- Mobile friendly HTML interface
 | 
					- Mobile friendly HTML interface
 | 
				
			||||||
- Preserves uploaded filename and content-type metadata
 | 
					- Preserves uploaded filename and content-type metadata
 | 
				
			||||||
- Hash verification (SHA512/256)
 | 
					- Hash verification (SHA512/256)
 | 
				
			||||||
- Detect duplicate upload content and reuse storage
 | 
					- Detect duplicate upload content and reuse storage
 | 
				
			||||||
- Options to limit the upload filesize and the upload bandwidth
 | 
					- Options to limit the upload filesize, upload bandwidth, and maximum source filesize for thumbnailing
 | 
				
			||||||
- Short URLs (using [Hashids](http://hashids.org) algorithm)
 | 
					- Short URLs (using [Hashids](http://hashids.org) algorithm)
 | 
				
			||||||
- Image thumbnailing
 | 
					- Image thumbnailing
 | 
				
			||||||
 | 
					- Optional multi-tenant binary (`contented-multi`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Usage (Server)
 | 
					## Usage (Server)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -84,6 +87,22 @@ You can optionally supply additional ordered parameters to `contented.init`:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Changelog
 | 
					## Changelog
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2025-08-20: v1.6.1
 | 
				
			||||||
 | 
					- Expanded error logging for tiered storage migrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2025-08-20: v1.6.0
 | 
				
			||||||
 | 
					- Support hot/cold tiered storage to move files between local path and S3 bucket
 | 
				
			||||||
 | 
					- Upgrade all dependencies
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2023-05-20: 1.5.1
 | 
				
			||||||
 | 
					- Improve support for albums with no images, and for albums with missing interior images
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2023-05-19: 1.5.0
 | 
				
			||||||
 | 
					- Feature: Support S3-backed storage
 | 
				
			||||||
 | 
					- Feature: New `contented-multi` binary to host multiple server configurations from a single process
 | 
				
			||||||
 | 
					- Enhancement: Better client-side caching for thumbnails
 | 
				
			||||||
 | 
					- Option to cap source filesize for thumbnailing (default 20MiB)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
2023-05-17: 1.4.0
 | 
					2023-05-17: 1.4.0
 | 
				
			||||||
- BREAKING: Remove support for some old web browsers (require jQuery 3, ES6 template literals, Promises, Canvas.toBlob)
 | 
					- 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: Initial album support with custom titles
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										67
									
								
								Server.go
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								Server.go
									
									
									
									
									
								
							@@ -3,10 +3,10 @@ package contented
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"embed"
 | 
						"embed"
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
	"io/fs"
 | 
						"io/fs"
 | 
				
			||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"os"
 | 
					 | 
				
			||||||
	"regexp"
 | 
						"regexp"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
@@ -20,9 +20,16 @@ var staticAssets embed.FS
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
var SERVER_HEADER string = `contented/0.0.0-dev`
 | 
					var SERVER_HEADER string = `contented/0.0.0-dev`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const DEFAULT_MAX_CONCURRENT_THUMBS = 16
 | 
					const (
 | 
				
			||||||
 | 
						DEFAULT_MAX_CONCURRENT_THUMBS = 16
 | 
				
			||||||
 | 
						DEFAULT_MAX_THUMBSIZE         = 20 * 1024 * 1024 // 20 MiB
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ALBUM_MIMETYPE = `contented/album`
 | 
						ALBUM_MIMETYPE = `contented/album`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						STORAGE_LOCAL  int = 0
 | 
				
			||||||
 | 
						STORAGE_S3     int = 1
 | 
				
			||||||
 | 
						STORAGE_TIERED int = 2
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ServerPublicProperties struct {
 | 
					type ServerPublicProperties struct {
 | 
				
			||||||
	AppTitle         string
 | 
						AppTitle         string
 | 
				
			||||||
@@ -30,8 +37,18 @@ type ServerPublicProperties struct {
 | 
				
			|||||||
	CanonicalBaseURL string
 | 
						CanonicalBaseURL string
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ServerS3StorageOptions struct {
 | 
				
			||||||
 | 
						Hostname  string
 | 
				
			||||||
 | 
						AccessKey string
 | 
				
			||||||
 | 
						SecretKey string
 | 
				
			||||||
 | 
						Bucket    string
 | 
				
			||||||
 | 
						Prefix    string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ServerOptions struct {
 | 
					type ServerOptions struct {
 | 
				
			||||||
 | 
						StorageType            int // STORAGE_xx
 | 
				
			||||||
	DataDirectory          string
 | 
						DataDirectory          string
 | 
				
			||||||
 | 
						DataS3Options          ServerS3StorageOptions
 | 
				
			||||||
	DBPath                 string
 | 
						DBPath                 string
 | 
				
			||||||
	DiskFilesWorldReadable bool
 | 
						DiskFilesWorldReadable bool
 | 
				
			||||||
	BandwidthLimit         int64
 | 
						BandwidthLimit         int64
 | 
				
			||||||
@@ -39,17 +56,10 @@ type ServerOptions struct {
 | 
				
			|||||||
	EnableHomepage         bool
 | 
						EnableHomepage         bool
 | 
				
			||||||
	EnableUpload           bool
 | 
						EnableUpload           bool
 | 
				
			||||||
	MaxConcurrentThumbs    int
 | 
						MaxConcurrentThumbs    int
 | 
				
			||||||
 | 
						MaxThumbSizeBytes      int64
 | 
				
			||||||
	ServerPublicProperties
 | 
						ServerPublicProperties
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (this *ServerOptions) FileMode() os.FileMode {
 | 
					 | 
				
			||||||
	if this.DiskFilesWorldReadable {
 | 
					 | 
				
			||||||
		return 0644
 | 
					 | 
				
			||||||
	} else {
 | 
					 | 
				
			||||||
		return 0600
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Server struct {
 | 
					type Server struct {
 | 
				
			||||||
	opts           ServerOptions
 | 
						opts           ServerOptions
 | 
				
			||||||
	db             *bolt.DB
 | 
						db             *bolt.DB
 | 
				
			||||||
@@ -57,6 +67,7 @@ type Server struct {
 | 
				
			|||||||
	thumbnailSem   chan struct{}
 | 
						thumbnailSem   chan struct{}
 | 
				
			||||||
	metadataBucket []byte
 | 
						metadataBucket []byte
 | 
				
			||||||
	staticDir      fs.FS // interface
 | 
						staticDir      fs.FS // interface
 | 
				
			||||||
 | 
						store          Storage
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewServer(opts *ServerOptions) (*Server, error) {
 | 
					func NewServer(opts *ServerOptions) (*Server, error) {
 | 
				
			||||||
@@ -71,8 +82,40 @@ func NewServer(opts *ServerOptions) (*Server, error) {
 | 
				
			|||||||
		log.Printf("Allowing %d concurrent thumbnails", s.opts.MaxConcurrentThumbs)
 | 
							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
 | 
						s.staticDir, _ = fs.Sub(staticAssets, `static`) // can't fail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Maybe open s3 connection
 | 
				
			||||||
 | 
						var err error = nil
 | 
				
			||||||
 | 
						switch s.opts.StorageType {
 | 
				
			||||||
 | 
						case STORAGE_S3:
 | 
				
			||||||
 | 
							s.store, err = NewS3Storage(s.opts.DataS3Options)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return nil, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						case STORAGE_LOCAL:
 | 
				
			||||||
 | 
							s.store = NewLocalStorage(s.opts.DataDirectory, s.opts.DiskFilesWorldReadable)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						case STORAGE_TIERED:
 | 
				
			||||||
 | 
							coldStore, err := NewS3Storage(s.opts.DataS3Options)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return nil, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							s.store = NewTieredStorage(
 | 
				
			||||||
 | 
								NewLocalStorage(s.opts.DataDirectory, s.opts.DiskFilesWorldReadable),
 | 
				
			||||||
 | 
								coldStore,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("Invalid storage type %d", s.opts.StorageType)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// "fill" the thumbnailer semaphore
 | 
						// "fill" the thumbnailer semaphore
 | 
				
			||||||
	s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs)
 | 
						s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs)
 | 
				
			||||||
	for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 {
 | 
						for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 {
 | 
				
			||||||
@@ -151,7 +194,7 @@ func (this *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 | 
				
			|||||||
		this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):])
 | 
							this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) {
 | 
						} else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) {
 | 
				
			||||||
		this.handlePreview(w, r.URL.Path[len(previewUrlPrefix):])
 | 
							this.handlePreview(r.Context(), w, r.URL.Path[len(previewUrlPrefix):])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	} else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) {
 | 
						} else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) {
 | 
				
			||||||
		parts := rxThumbUrl.FindStringSubmatch(r.URL.Path)
 | 
							parts := rxThumbUrl.FindStringSubmatch(r.URL.Path)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										58
									
								
								cmd/contented-multi/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								cmd/contented-multi/main.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"flag"
 | 
				
			||||||
 | 
						"log"
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"sync"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"code.ivysaur.me/contented"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ContentedMultiCfg struct {
 | 
				
			||||||
 | 
						Servers []struct {
 | 
				
			||||||
 | 
							ListenAddr string
 | 
				
			||||||
 | 
							Options    contented.ServerOptions
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func main() {
 | 
				
			||||||
 | 
						configFile := flag.String("config", "contented-multi.cfg", "Path to configuration file")
 | 
				
			||||||
 | 
						flag.Parse()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						fh, err := os.Open(*configFile)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							panic(err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var cfg ContentedMultiCfg
 | 
				
			||||||
 | 
						err = json.NewDecoder(fh).Decode(&cfg)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							panic(err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						fh.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						wg := sync.WaitGroup{}
 | 
				
			||||||
 | 
						wg.Add(len(cfg.Servers))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for i, _ := range cfg.Servers {
 | 
				
			||||||
 | 
							go (func(i int) {
 | 
				
			||||||
 | 
								defer wg.Done()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								s, err := contented.NewServer(&cfg.Servers[i].Options)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									log.Printf("Failed to create server %d/%d: %s", i+1, len(cfg.Servers), err.Error())
 | 
				
			||||||
 | 
									return
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								err = http.ListenAndServe(cfg.Servers[i].ListenAddr, s)
 | 
				
			||||||
 | 
								log.Printf("Server %d/%d shutting down: %s", i+1, len(cfg.Servers), err.Error())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							})(i)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						wg.Wait()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -12,22 +12,28 @@ import (
 | 
				
			|||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
	cwd, _ := os.Getwd()
 | 
						cwd, _ := os.Getwd()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	listenAddr := flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
 | 
						var (
 | 
				
			||||||
	dataDir := flag.String("data", cwd, "Directory for stored content")
 | 
							listenAddr             = flag.String("listen", "127.0.0.1:80", "IP/Port to bind server")
 | 
				
			||||||
	dbPath := flag.String("db", "contented.db", "Path for metadata database")
 | 
							dataDir                = flag.String("data", cwd, "Directory for stored content")
 | 
				
			||||||
	appTitle := flag.String("title", "contented", "Title used in web interface")
 | 
							dbPath                 = flag.String("db", "contented.db", "Path for metadata database")
 | 
				
			||||||
	maxUploadMb := flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
 | 
							appTitle               = flag.String("title", "contented", "Title used in web interface")
 | 
				
			||||||
	maxUploadSpeed := flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
 | 
							maxUploadMb            = flag.Int("max", 8, "Maximum size of uploaded files in MiB (set zero for unlimited)")
 | 
				
			||||||
	trustXForwardedFor := flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
 | 
							maxUploadSpeed         = flag.Int("speed", 0, "Maximum upload speed in bytes/sec (set zero for unlimited)")
 | 
				
			||||||
	enableHomepage := flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
 | 
							trustXForwardedFor     = flag.Bool("trustXForwardedFor", false, "Trust X-Forwarded-For reverse proxy headers")
 | 
				
			||||||
	enableUpload := flag.Bool("enableUpload", true, "Enable uploads (disable for read-only mode)")
 | 
							enableHomepage         = flag.Bool("enableHomepage", true, "Enable homepage (disable for embedded use only)")
 | 
				
			||||||
	diskFilesWorldReadable := flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
 | 
							enableUpload           = flag.Bool("enableUpload", true, "Enable uploads (disable for read-only mode)")
 | 
				
			||||||
	maxConcurrentThumbs := flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
 | 
							diskFilesWorldReadable = flag.Bool("diskFilesWorldReadable", false, "Save files as 0644 instead of 0600")
 | 
				
			||||||
 | 
							maxConcurrentThumbs    = flag.Int("concurrentthumbs", contented.DEFAULT_MAX_CONCURRENT_THUMBS, "Simultaneous thumbnail generation")
 | 
				
			||||||
 | 
							s3Host                 = flag.String("s3hostname", "", "S3 Server hostname")
 | 
				
			||||||
 | 
							s3AccessKey            = flag.String("s3access", "", "S3 Access key")
 | 
				
			||||||
 | 
							s3SecretKey            = flag.String("s3secret", "", "S3 Secret key")
 | 
				
			||||||
 | 
							s3Bucket               = flag.String("s3bucket", "", "S3 Bucket")
 | 
				
			||||||
 | 
							s3Prefix               = flag.String("s3prefix", "", "S3 object prefix")
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	flag.Parse()
 | 
						flag.Parse()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	svr, err := contented.NewServer(&contented.ServerOptions{
 | 
						opts := contented.ServerOptions{
 | 
				
			||||||
		DataDirectory:          *dataDir,
 | 
					 | 
				
			||||||
		DBPath:                 *dbPath,
 | 
							DBPath:                 *dbPath,
 | 
				
			||||||
		BandwidthLimit:         int64(*maxUploadSpeed),
 | 
							BandwidthLimit:         int64(*maxUploadSpeed),
 | 
				
			||||||
		TrustXForwardedFor:     *trustXForwardedFor,
 | 
							TrustXForwardedFor:     *trustXForwardedFor,
 | 
				
			||||||
@@ -39,7 +45,33 @@ func main() {
 | 
				
			|||||||
			AppTitle:       *appTitle,
 | 
								AppTitle:       *appTitle,
 | 
				
			||||||
			MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
 | 
								MaxUploadBytes: int64(*maxUploadMb) * 1024 * 1024,
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	})
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// s3 or tiered storage
 | 
				
			||||||
 | 
						opts.DataS3Options.Hostname = *s3Host
 | 
				
			||||||
 | 
						opts.DataS3Options.AccessKey = *s3AccessKey
 | 
				
			||||||
 | 
						opts.DataS3Options.SecretKey = *s3SecretKey
 | 
				
			||||||
 | 
						opts.DataS3Options.Bucket = *s3Bucket
 | 
				
			||||||
 | 
						opts.DataS3Options.Prefix = *s3Prefix
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// local or tiered storage
 | 
				
			||||||
 | 
						opts.DataDirectory = *dataDir
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if len(*dataDir) > 0 && len(*s3AccessKey) > 0 {
 | 
				
			||||||
 | 
							opts.StorageType = contented.STORAGE_TIERED
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						} else if len(*s3AccessKey) > 0 {
 | 
				
			||||||
 | 
							opts.StorageType = contented.STORAGE_S3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						} else if len(*dataDir) > 0 {
 | 
				
			||||||
 | 
							opts.StorageType = contented.STORAGE_LOCAL
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							log.Println("Please specify either the -data or -s3__ options.")
 | 
				
			||||||
 | 
							os.Exit(1)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						svr, err := contented.NewServer(&opts)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Println(err.Error())
 | 
							log.Println(err.Error())
 | 
				
			||||||
		os.Exit(1)
 | 
							os.Exit(1)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								download.go
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								download.go
									
									
									
									
									
								
							@@ -4,7 +4,6 @@ import (
 | 
				
			|||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"path/filepath"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) {
 | 
					func (this *Server) handleView(w http.ResponseWriter, r *http.Request, fileID string) {
 | 
				
			||||||
@@ -28,7 +27,7 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Load file
 | 
						// Load file
 | 
				
			||||||
	f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
 | 
						f, err := this.store.ReadFile(r.Context(), m.FileHash)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -50,7 +49,18 @@ func (this *Server) handleViewInternal(w http.ResponseWriter, r *http.Request, f
 | 
				
			|||||||
		w.Header().Set(`Content-Type`, m.MimeType)
 | 
							w.Header().Set(`Content-Type`, m.MimeType)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	http.ServeContent(w, r, "", m.UploadTime, f)
 | 
						/*
 | 
				
			||||||
 | 
							if _, ok := f.(io.ReadSeeker); ! ok {
 | 
				
			||||||
 | 
								// Stream directly, no support for bytes/etag
 | 
				
			||||||
 | 
								w.Header().Set(`Content-Length`, fmt.Sprintf("%d", m.FileSize))
 | 
				
			||||||
 | 
								_, err := io.Copy(w, f)
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Allow range requests, if-modified-since, and so on
 | 
				
			||||||
 | 
						http.ServeContent(w, r, "", m.UploadTime, f)
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										29
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								go.mod
									
									
									
									
									
								
							@@ -2,16 +2,33 @@ module code.ivysaur.me/contented
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
require (
 | 
					require (
 | 
				
			||||||
	code.ivysaur.me/thumbnail v1.0.2
 | 
						code.ivysaur.me/thumbnail v1.0.2
 | 
				
			||||||
 | 
						github.com/minio/minio-go/v7 v7.0.95
 | 
				
			||||||
	github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
 | 
						github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f
 | 
				
			||||||
	github.com/speps/go-hashids v1.0.0
 | 
						github.com/speps/go-hashids/v2 v2.0.1
 | 
				
			||||||
	go.etcd.io/bbolt v1.3.7
 | 
						go.etcd.io/bbolt v1.4.3
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
require (
 | 
					require (
 | 
				
			||||||
	code.ivysaur.me/imagequant/v2 v2.12.6 // indirect
 | 
						code.ivysaur.me/imagequant/v2 v2.12.6 // indirect
 | 
				
			||||||
	github.com/hashicorp/golang-lru v0.5.4 // indirect
 | 
						github.com/dustin/go-humanize v1.0.1 // indirect
 | 
				
			||||||
	golang.org/x/image v0.0.0-20200618115811-c13761719519 // indirect
 | 
						github.com/go-ini/ini v1.67.0 // indirect
 | 
				
			||||||
	golang.org/x/sys v0.4.0 // indirect
 | 
						github.com/goccy/go-json v0.10.5 // indirect
 | 
				
			||||||
 | 
						github.com/google/uuid v1.6.0 // indirect
 | 
				
			||||||
 | 
						github.com/hashicorp/golang-lru v1.0.2 // indirect
 | 
				
			||||||
 | 
						github.com/klauspost/compress v1.18.0 // indirect
 | 
				
			||||||
 | 
						github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 | 
				
			||||||
 | 
						github.com/minio/crc64nvme v1.1.1 // indirect
 | 
				
			||||||
 | 
						github.com/minio/md5-simd v1.1.2 // indirect
 | 
				
			||||||
 | 
						github.com/philhofer/fwd v1.2.0 // indirect
 | 
				
			||||||
 | 
						github.com/rs/xid v1.6.0 // indirect
 | 
				
			||||||
 | 
						github.com/tinylib/msgp v1.3.0 // indirect
 | 
				
			||||||
 | 
						golang.org/x/crypto v0.41.0 // indirect
 | 
				
			||||||
 | 
						golang.org/x/image v0.30.0 // indirect
 | 
				
			||||||
 | 
						golang.org/x/net v0.43.0 // indirect
 | 
				
			||||||
 | 
						golang.org/x/sys v0.35.0 // indirect
 | 
				
			||||||
 | 
						golang.org/x/text v0.28.0 // indirect
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
go 1.19
 | 
					go 1.23.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					toolchain go1.24.4
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										63
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								go.sum
									
									
									
									
									
								
							@@ -2,20 +2,59 @@ code.ivysaur.me/imagequant/v2 v2.12.6 h1:xYrGj6GOdAcutmzqBxG7bDZ70r4jYHADOCZ+kty
 | 
				
			|||||||
code.ivysaur.me/imagequant/v2 v2.12.6/go.mod h1:seCAm0sP2IBsb1YNBj4D+EZovIuGe16+6Xo0aiGyhDU=
 | 
					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 h1:vQaRPbBZOUGpr4b5rrUOHiZv08XSRJ83uu64WXFx7mo=
 | 
				
			||||||
code.ivysaur.me/thumbnail v1.0.2/go.mod h1:sXeHBfmPfiSe5ZBKsbGSES13C9OSZq0WmT4yZ/XBeeE=
 | 
					code.ivysaur.me/thumbnail v1.0.2/go.mod h1:sXeHBfmPfiSe5ZBKsbGSES13C9OSZq0WmT4yZ/XBeeE=
 | 
				
			||||||
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
 | 
					github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
				
			||||||
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
 | 
					github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
				
			||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
 | 
					github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 | 
				
			||||||
 | 
					github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 | 
				
			||||||
 | 
					github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
 | 
				
			||||||
 | 
					github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
 | 
				
			||||||
 | 
					github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 | 
				
			||||||
 | 
					github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 | 
				
			||||||
 | 
					github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 | 
				
			||||||
 | 
					github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 | 
				
			||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 | 
					github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 | 
				
			||||||
 | 
					github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
 | 
				
			||||||
 | 
					github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 | 
				
			||||||
 | 
					github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 | 
				
			||||||
 | 
					github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 | 
				
			||||||
 | 
					github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 | 
				
			||||||
 | 
					github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 | 
				
			||||||
 | 
					github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 | 
				
			||||||
 | 
					github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
 | 
				
			||||||
 | 
					github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
 | 
				
			||||||
 | 
					github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
 | 
				
			||||||
 | 
					github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
 | 
				
			||||||
 | 
					github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
 | 
				
			||||||
 | 
					github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
 | 
				
			||||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
 | 
					github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
 | 
				
			||||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 | 
					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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
 | 
				
			||||||
github.com/speps/go-hashids v1.0.0/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
 | 
					github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 | 
				
			||||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
 | 
					github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 | 
				
			||||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
 | 
					github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
				
			||||||
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
 | 
					github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
 | 
				
			||||||
 | 
					github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
 | 
				
			||||||
 | 
					github.com/speps/go-hashids/v2 v2.0.1 h1:ViWOEqWES/pdOSq+C1SLVa8/Tnsd52XC34RY7lt7m4g=
 | 
				
			||||||
 | 
					github.com/speps/go-hashids/v2 v2.0.1/go.mod h1:47LKunwvDZki/uRVD6NImtyk712yFzIs3UF3KlHohGw=
 | 
				
			||||||
 | 
					github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
				
			||||||
 | 
					github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
				
			||||||
 | 
					github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
 | 
				
			||||||
 | 
					github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
 | 
				
			||||||
 | 
					go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
 | 
				
			||||||
 | 
					go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
 | 
				
			||||||
 | 
					golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
 | 
				
			||||||
 | 
					golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
 | 
				
			||||||
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 | 
					golang.org/x/image v0.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/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
 | 
				
			||||||
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
					golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
 | 
				
			||||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
 | 
					golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
 | 
				
			||||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
					golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
 | 
				
			||||||
 | 
					golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
 | 
				
			||||||
 | 
					golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 | 
				
			||||||
 | 
					golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
 | 
				
			||||||
 | 
					golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
				
			||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
					golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
				
			||||||
 | 
					golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
 | 
				
			||||||
 | 
					golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
 | 
				
			||||||
 | 
					gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
				
			||||||
 | 
					gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										39
									
								
								preview.go
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								preview.go
									
									
									
									
									
								
							@@ -1,18 +1,18 @@
 | 
				
			|||||||
package contented
 | 
					package contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"html"
 | 
						"html"
 | 
				
			||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"path/filepath"
 | 
					 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (this *Server) handlePreview(w http.ResponseWriter, fileIDList string) {
 | 
					func (this *Server) handlePreview(ctx context.Context, w http.ResponseWriter, fileIDList string) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fileIDs := strings.Split(fileIDList, `-`)
 | 
						fileIDs := strings.Split(fileIDList, `-`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -77,6 +77,7 @@ html, body {
 | 
				
			|||||||
.thumbnail {
 | 
					.thumbnail {
 | 
				
			||||||
	line-height: 0;
 | 
						line-height: 0;
 | 
				
			||||||
	width: 340px;
 | 
						width: 340px;
 | 
				
			||||||
 | 
						height: 340px;
 | 
				
			||||||
	text-align: center;
 | 
						text-align: center;
 | 
				
			||||||
	position: relative;
 | 
						position: relative;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -108,8 +109,27 @@ html, body {
 | 
				
			|||||||
		m, err := this.Metadata(fileID)
 | 
							m, err := this.Metadata(fileID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			if os.IsNotExist(err) {
 | 
								if os.IsNotExist(err) {
 | 
				
			||||||
				http.Error(w, "Not found", 404)
 | 
					
 | 
				
			||||||
				return
 | 
									// 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())
 | 
								log.Println(err.Error())
 | 
				
			||||||
@@ -119,7 +139,7 @@ html, body {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		if m.MimeType == ALBUM_MIMETYPE {
 | 
							if m.MimeType == ALBUM_MIMETYPE {
 | 
				
			||||||
			// Special handling for albums
 | 
								// Special handling for albums
 | 
				
			||||||
			f, err := os.Open(filepath.Join(this.opts.DataDirectory, m.FileHash))
 | 
								f, err := this.store.ReadFile(ctx, m.FileHash)
 | 
				
			||||||
			if err != nil {
 | 
								if err != nil {
 | 
				
			||||||
				log.Printf("Opening file '%s' for preview of album '%s': %s", m.FileHash, fileID, err.Error())
 | 
									log.Printf("Opening file '%s' for preview of album '%s': %s", m.FileHash, fileID, err.Error())
 | 
				
			||||||
				http.Error(w, "Internal error", 500)
 | 
									http.Error(w, "Internal error", 500)
 | 
				
			||||||
@@ -135,16 +155,15 @@ html, body {
 | 
				
			|||||||
				return
 | 
									return
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if len(childIDs) == 0 {
 | 
								albumThumb := `/nothumb_340.png`
 | 
				
			||||||
				log.Printf("Failed to parse album '%s': no entries in album", fileID)
 | 
								if len(childIDs) > 0 {
 | 
				
			||||||
				http.Error(w, "Internal error", 500)
 | 
									albumThumb = `/thumb/m/` + childIDs[0]
 | 
				
			||||||
				return
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			tmpl += `
 | 
								tmpl += `
 | 
				
			||||||
				<div class="entry">			
 | 
									<div class="entry">			
 | 
				
			||||||
					<div class="thumbnail">
 | 
										<div class="thumbnail">
 | 
				
			||||||
						<a href="` + html.EscapeString(`/p/`+strings.Join(childIDs, `-`)) + `"><img loading="lazy" src="` + html.EscapeString(`/thumb/m/`+childIDs[0]) + `"></a>
 | 
											<a href="` + html.EscapeString(`/p/`+strings.Join(childIDs, `-`)) + `"><img loading="lazy" src="` + html.EscapeString(albumThumb) + `"></a>
 | 
				
			||||||
						<div class="thumbnail-overlay">` + fmt.Sprintf("%d", len(childIDs)) + ` image(s)</div>
 | 
											<div class="thumbnail-overlay">` + fmt.Sprintf("%d", len(childIDs)) + ` image(s)</div>
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
					<div class="properties">
 | 
										<div class="properties">
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										209
									
								
								storage.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								storage.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,209 @@
 | 
				
			|||||||
 | 
					package contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"io"
 | 
				
			||||||
 | 
						"log"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"path/filepath"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/minio/minio-go/v7"
 | 
				
			||||||
 | 
						"github.com/minio/minio-go/v7/pkg/credentials"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Storage interface {
 | 
				
			||||||
 | 
						ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error)
 | 
				
			||||||
 | 
						SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type localStorage struct {
 | 
				
			||||||
 | 
						dataDir  string
 | 
				
			||||||
 | 
						fileMode os.FileMode
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func NewLocalStorage(dataDir string, worldReadable bool) *localStorage {
 | 
				
			||||||
 | 
						ls := &localStorage{
 | 
				
			||||||
 | 
							dataDir:  dataDir,
 | 
				
			||||||
 | 
							fileMode: 0600,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if worldReadable {
 | 
				
			||||||
 | 
							ls.fileMode = 0644
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return ls
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ls *localStorage) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
 | 
				
			||||||
 | 
						fh, err := os.Open(filepath.Join(ls.dataDir, fileHash))
 | 
				
			||||||
 | 
						return fh, err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ls *localStorage) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Save file to disk
 | 
				
			||||||
 | 
						dest, err := os.OpenFile(filepath.Join(ls.dataDir, fileHash), os.O_CREATE|os.O_WRONLY, ls.fileMode)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							if os.IsExist(err) {
 | 
				
			||||||
 | 
								return nil // hash matches existing upload
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return err // Real error
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer dest.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						_, err = io.CopyN(dest, src, int64(srcLen))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var _ Storage = &localStorage{} // interface assertion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type s3Storage struct {
 | 
				
			||||||
 | 
						s3client *minio.Client
 | 
				
			||||||
 | 
						ServerS3StorageOptions
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func NewS3Storage(opts ServerS3StorageOptions) (*s3Storage, error) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cl, err := minio.New(opts.Hostname, &minio.Options{
 | 
				
			||||||
 | 
							Creds:  credentials.NewStaticV4(opts.AccessKey, opts.SecretKey, ""),
 | 
				
			||||||
 | 
							Secure: true,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("Connecting to S3 host: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return &s3Storage{
 | 
				
			||||||
 | 
							s3client:               cl,
 | 
				
			||||||
 | 
							ServerS3StorageOptions: opts,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ss *s3Storage) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
 | 
				
			||||||
 | 
						obj, err := ss.s3client.GetObject(ctx, ss.Bucket, ss.Prefix+fileHash, minio.GetObjectOptions{})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return obj, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ss *s3Storage) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
 | 
				
			||||||
 | 
						_, err := ss.s3client.PutObject(ctx, ss.Bucket, ss.Prefix+fileHash, src, srcLen, minio.PutObjectOptions{})
 | 
				
			||||||
 | 
						return err
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var _ Storage = &s3Storage{} // interface assertion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						TierMigrationAfter        = 14 * 24 * time.Hour // 14 days
 | 
				
			||||||
 | 
						TierMigrationEvery        = 4 * time.Hour       // 4 hours
 | 
				
			||||||
 | 
						TierMigrationDelayStartup = 60 * time.Second
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type tieredStorage struct {
 | 
				
			||||||
 | 
						hot  *localStorage
 | 
				
			||||||
 | 
						cold *s3Storage
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func NewTieredStorage(hot *localStorage, cold *s3Storage) *tieredStorage {
 | 
				
			||||||
 | 
						ts := &tieredStorage{
 | 
				
			||||||
 | 
							hot:  hot,
 | 
				
			||||||
 | 
							cold: cold,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						go ts.migrationWorker()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return ts
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// migrationWorker is a background goroutine to trigger tier migrations.
 | 
				
			||||||
 | 
					func (ts *tieredStorage) migrationWorker() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Startup delay
 | 
				
			||||||
 | 
						time.Sleep(TierMigrationDelayStartup)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Worker loop
 | 
				
			||||||
 | 
						for {
 | 
				
			||||||
 | 
							err := ts.migrateNow()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								log.Printf("tier-migration: %v", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							time.Sleep(TierMigrationEvery)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// migrateNow performs a tier migration for old files.
 | 
				
			||||||
 | 
					func (ts *tieredStorage) migrateNow() error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// List local files
 | 
				
			||||||
 | 
						dirents, err := os.ReadDir(ts.hot.dataDir)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("Reading hot storage files: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cutOff := time.Now().Add(-TierMigrationAfter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, dirent := range dirents {
 | 
				
			||||||
 | 
							fi, err := dirent.Info()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Reading hot storage files: %w", err) // local files can't be stat'd = important error
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if !fi.ModTime().After(cutOff) {
 | 
				
			||||||
 | 
								continue // not eligible
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							fileHash := dirent.Name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Copy to cold storage
 | 
				
			||||||
 | 
							// Any concurrent reads will be serviced from the hot storage, so this
 | 
				
			||||||
 | 
							// is a safe operation
 | 
				
			||||||
 | 
							rc, err := ts.hot.ReadFile(context.Background(), fileHash)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Read %q from hot storage: %w", fileHash, err) // can't cat local file
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							err = ts.cold.SaveFile(context.Background(), fileHash, fi.Size(), rc)
 | 
				
			||||||
 | 
							_ = rc.Close()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Write %q to cold storage: %w", fileHash, err) // can't save local file
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Copy was successful. Delete local file
 | 
				
			||||||
 | 
							err = os.Remove(filepath.Join(ts.hot.dataDir, fileHash))
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("Remove %q from hot storage: %w", err) // can't rm local file
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Migrated everything we can for now
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ts *tieredStorage) ReadFile(ctx context.Context, fileHash string) (io.ReadSeekCloser, error) {
 | 
				
			||||||
 | 
						if rc, err := ts.hot.ReadFile(ctx, fileHash); err == nil {
 | 
				
			||||||
 | 
							return rc, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return ts.cold.ReadFile(ctx, fileHash)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ts *tieredStorage) SaveFile(ctx context.Context, fileHash string, srcLen int64, src io.Reader) error {
 | 
				
			||||||
 | 
						return ts.hot.SaveFile(ctx, fileHash, srcLen, src)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var _ Storage = &tieredStorage{} // interface assertion
 | 
				
			||||||
							
								
								
									
										37
									
								
								thumb.go
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								thumb.go
									
									
									
									
									
								
							@@ -4,8 +4,10 @@ import (
 | 
				
			|||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
						"io"
 | 
				
			||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.ivysaur.me/thumbnail"
 | 
						"code.ivysaur.me/thumbnail"
 | 
				
			||||||
@@ -94,12 +96,45 @@ func (this *Server) handleThumbInternal(ctx context.Context, w http.ResponseWrit
 | 
				
			|||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	filePath := filepath.Join(this.opts.DataDirectory, m.FileHash)
 | 
						if m.FileSize > this.opts.MaxThumbSizeBytes {
 | 
				
			||||||
 | 
							return errors.New("Don't want to thumbnail very large files, sorry")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var filePath string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if this.opts.StorageType == STORAGE_LOCAL {
 | 
				
			||||||
 | 
							filePath = filepath.Join(this.opts.DataDirectory, m.FileHash)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						} else if this.opts.StorageType == STORAGE_S3 {
 | 
				
			||||||
 | 
							// Need to temporarily download it for thumbnailing (slow and costs money)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							destFh, err := os.CreateTemp("", "contented-thumbcache-*")
 | 
				
			||||||
 | 
							defer os.Remove(destFh.Name())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							srcFh, err := this.store.ReadFile(ctx, m.FileHash)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							_, err = io.CopyN(destFh, srcFh, m.FileSize)
 | 
				
			||||||
 | 
							srcFh.Close()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							destFh.Seek(0, io.SeekStart)
 | 
				
			||||||
 | 
							filePath = destFh.Name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							panic("bad StorageType")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	thumb, err := t.RenderFileAs(filePath, m.MimeType)
 | 
						thumb, err := t.RenderFileAs(filePath, m.MimeType)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						w.Header().Set(`Cache-Control`, `max-age=31536000, immutable`)
 | 
				
			||||||
	w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(thumb)))
 | 
						w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(thumb)))
 | 
				
			||||||
	w.Header().Set(`Content-Type`, `image/jpeg`)
 | 
						w.Header().Set(`Content-Type`, `image/jpeg`)
 | 
				
			||||||
	w.WriteHeader(200)
 | 
						w.WriteHeader(200)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										24
									
								
								upload.go
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								upload.go
									
									
									
									
									
								
							@@ -1,6 +1,7 @@
 | 
				
			|||||||
package contented
 | 
					package contented
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
	"crypto/sha512"
 | 
						"crypto/sha512"
 | 
				
			||||||
	"encoding/hex"
 | 
						"encoding/hex"
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
@@ -9,9 +10,7 @@ import (
 | 
				
			|||||||
	"mime"
 | 
						"mime"
 | 
				
			||||||
	"mime/multipart"
 | 
						"mime/multipart"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"os"
 | 
					 | 
				
			||||||
	"path"
 | 
						"path"
 | 
				
			||||||
	"path/filepath"
 | 
					 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -94,27 +93,14 @@ func (this *Server) handleUploadFile(src multipart.File, hdr *multipart.FileHead
 | 
				
			|||||||
		return "", err
 | 
							return "", err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Save file to disk
 | 
					 | 
				
			||||||
	fileHash := hex.EncodeToString(hasher.Sum(nil))
 | 
						fileHash := hex.EncodeToString(hasher.Sum(nil))
 | 
				
			||||||
	dest, err := os.OpenFile(filepath.Join(this.opts.DataDirectory, fileHash), os.O_CREATE|os.O_WRONLY, this.opts.FileMode())
 | 
					
 | 
				
			||||||
	shouldSave := true
 | 
						// Save file to disk/s3
 | 
				
			||||||
	if err != nil && os.IsExist(err) {
 | 
						err = this.store.SaveFile(context.Background(), fileHash, srcLen, src)
 | 
				
			||||||
		// hash matches existing upload
 | 
						if err != nil {
 | 
				
			||||||
		// That's fine - but still persist the metadata separately
 | 
					 | 
				
			||||||
		shouldSave = false
 | 
					 | 
				
			||||||
	} else if err != nil {
 | 
					 | 
				
			||||||
		return "", err
 | 
							return "", err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if shouldSave {
 | 
					 | 
				
			||||||
		defer dest.Close()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		_, err = io.CopyN(dest, src, int64(srcLen))
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return "", err
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Determine mime type
 | 
						// Determine mime type
 | 
				
			||||||
	ctype := hdr.Header.Get("Content-Type")
 | 
						ctype := hdr.Header.Get("Content-Type")
 | 
				
			||||||
	if ctype == "" {
 | 
						if ctype == "" {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user