package contented import ( "embed" "encoding/json" "fmt" "io/fs" "log" "net/http" "os" "regexp" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/mxk/go-flowrate/flowrate" bolt "go.etcd.io/bbolt" ) //go:embed static var staticAssets embed.FS var SERVER_HEADER string = `contented/0.0.0-dev` const ( DEFAULT_MAX_CONCURRENT_THUMBS = 16 DEFAULT_MAX_THUMBSIZE = 20 * 1024 * 1024 // 20 MiB ALBUM_MIMETYPE = `contented/album` STORAGE_LOCAL int = 0 STORAGE_S3 int = 1 ) type ServerPublicProperties struct { AppTitle string MaxUploadBytes int64 CanonicalBaseURL string } type ServerOptions struct { StorageType int // STORAGE_xx DataDirectory string DataS3Options struct { Hostname string AccessKey string SecretKey string Bucket string Prefix string } DBPath string DiskFilesWorldReadable bool BandwidthLimit int64 TrustXForwardedFor bool EnableHomepage bool EnableUpload bool MaxConcurrentThumbs int MaxThumbSizeBytes int64 ServerPublicProperties } func (this *ServerOptions) FileMode() os.FileMode { if this.DiskFilesWorldReadable { return 0644 } else { return 0600 } } type Server struct { opts ServerOptions s3client *minio.Client db *bolt.DB startTime time.Time thumbnailSem chan struct{} metadataBucket []byte staticDir fs.FS // interface } func NewServer(opts *ServerOptions) (*Server, error) { s := &Server{ opts: *opts, metadataBucket: []byte(`METADATA`), startTime: time.Now(), } if s.opts.MaxConcurrentThumbs <= 0 { s.opts.MaxConcurrentThumbs = DEFAULT_MAX_CONCURRENT_THUMBS // default log.Printf("Allowing %d concurrent thumbnails", s.opts.MaxConcurrentThumbs) } if s.opts.MaxThumbSizeBytes <= 0 { s.opts.MaxThumbSizeBytes = DEFAULT_MAX_THUMBSIZE log.Printf("Allowing thumbnails for files up to %d byte(s)", s.opts.MaxThumbSizeBytes) } s.staticDir, _ = fs.Sub(staticAssets, `static`) // can't fail // Maybe open s3 connection if s.opts.StorageType == STORAGE_S3 { cl, err := minio.New(opts.DataS3Options.Hostname, &minio.Options{ Creds: credentials.NewStaticV4(opts.DataS3Options.AccessKey, opts.DataS3Options.SecretKey, ""), Secure: true, }) if err != nil { return nil, fmt.Errorf("Connecting to S3 host: %w", err) } s.s3client = cl } // "fill" the thumbnailer semaphore s.thumbnailSem = make(chan struct{}, s.opts.MaxConcurrentThumbs) for i := 0; i < s.opts.MaxConcurrentThumbs; i += 1 { s.thumbnailSem <- struct{}{} } b, err := bolt.Open(opts.DBPath, 0644, bolt.DefaultOptions) if err != nil { return nil, err } err = b.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists(s.metadataBucket) return err }) if err != nil { return nil, err } s.db = b return s, nil } func (this *Server) serveJsonObject(w http.ResponseWriter, o interface{}) { jb, err := json.Marshal(o) if err != nil { log.Println(err.Error()) http.Error(w, "Internal error", 500) return } w.Header().Set(`Content-Type`, `application/json`) w.WriteHeader(200) w.Write(jb) } func (this *Server) handleAbout(w http.ResponseWriter) { this.serveJsonObject(w, this.opts.ServerPublicProperties) } 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) } if this.opts.BandwidthLimit > 0 { r.Body = flowrate.NewReader(r.Body, this.opts.BandwidthLimit) } // if r.Method == "GET" && strings.HasPrefix(r.URL.Path, downloadUrlPrefix) { this.handleView(w, r, r.URL.Path[len(downloadUrlPrefix):]) } else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, metadataUrlPrefix) { this.handleInformation(w, r.URL.Path[len(metadataUrlPrefix):]) } else if r.Method == "GET" && strings.HasPrefix(r.URL.Path, previewUrlPrefix) { this.handlePreview(r.Context(), w, r.URL.Path[len(previewUrlPrefix):]) } else if r.Method == "GET" && rxThumbUrl.MatchString(r.URL.Path) { parts := rxThumbUrl.FindStringSubmatch(r.URL.Path) this.handleThumb(w, r, parts[1][0], parts[2]) } else if r.Method == "GET" && r.URL.Path == `/about` { this.handleAbout(w) } else if r.Method == "POST" && r.URL.Path == `/upload` { this.handleUpload(w, r) } else if r.Method == "OPTIONS" { // Blanket allow (headers already set) w.WriteHeader(200) } else if r.Method == "GET" { // Conditionally block homepage access if !this.opts.EnableHomepage && (r.URL.Path == `/index.html` || r.URL.Path == `/`) { http.Error(w, "Not found", 404) return } // Serve static html/css/js assets // http.FileServer transparently redirects index.html->/ internally http.FileServer(http.FS(this.staticDir)).ServeHTTP(w, r) } else { http.Error(w, "Not found", 404) } }