diff --git a/README.md b/README.md index 8564e01..84da53d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ You can see what it looks like using the demo: [https://demo.linx-server.net/](h - Display syntax-highlighted code with in-place editing - Documented API with keys if need to restrict uploads (can use [linx-client](https://github.com/andreimarcu/linx-client) for uploading through command-line) - Torrent download of files using web seeding -- File expiry, deletion key, and random filename options +- File expiry, deletion key, file access key, and random filename options ### Screenshots diff --git a/access.go b/access.go new file mode 100644 index 0000000..bf32013 --- /dev/null +++ b/access.go @@ -0,0 +1,147 @@ +package main + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "net/url" + "path" + "regexp" + "strings" + "time" + + "github.com/andreimarcu/linx-server/backends" + "github.com/flosch/pongo2" + "github.com/zenazn/goji/web" +) + +type accessKeySource int + +const ( + accessKeySourceNone accessKeySource = iota + accessKeySourceCookie + accessKeySourceHeader + accessKeySourceForm + accessKeySourceQuery +) + +const accessKeyHeaderName = "Linx-Access-Key" +const accessKeyParamName = "access_key" + +var ( + errInvalidAccessKey = errors.New("invalid access key") + + cliUserAgentRe = regexp.MustCompile("(?i)(lib)?curl|wget") +) + +func checkAccessKey(r *http.Request, metadata *backends.Metadata) (accessKeySource, error) { + key := metadata.AccessKey + if key == "" { + return accessKeySourceNone, nil + } + + cookieKey, err := r.Cookie(accessKeyHeaderName) + if err == nil { + if cookieKey.Value == key { + return accessKeySourceCookie, nil + } + return accessKeySourceCookie, errInvalidAccessKey + } + + headerKey := r.Header.Get(accessKeyHeaderName) + if headerKey == key { + return accessKeySourceHeader, nil + } else if headerKey != "" { + return accessKeySourceHeader, errInvalidAccessKey + } + + formKey := r.PostFormValue(accessKeyParamName) + if formKey == key { + return accessKeySourceForm, nil + } else if formKey != "" { + return accessKeySourceForm, errInvalidAccessKey + } + + queryKey := r.URL.Query().Get(accessKeyParamName) + if queryKey == key { + return accessKeySourceQuery, nil + } else if formKey != "" { + return accessKeySourceQuery, errInvalidAccessKey + } + + return accessKeySourceNone, errInvalidAccessKey +} + +func setAccessKeyCookies(w http.ResponseWriter, siteURL, fileName, value string, expires time.Time) { + u, err := url.Parse(siteURL) + if err != nil { + log.Printf("cant parse siteURL (%v): %v", siteURL, err) + return + } + + cookie := http.Cookie{ + Name: accessKeyHeaderName, + Value: value, + HttpOnly: true, + Domain: u.Hostname(), + Expires: expires, + } + + cookie.Path = path.Join(u.Path, fileName) + http.SetCookie(w, &cookie) + + cookie.Path = path.Join(u.Path, Config.selifPath, fileName) + http.SetCookie(w, &cookie) +} + +func fileAccessHandler(c web.C, w http.ResponseWriter, r *http.Request) { + if !Config.noDirectAgents && cliUserAgentRe.MatchString(r.Header.Get("User-Agent")) && !strings.EqualFold("application/json", r.Header.Get("Accept")) { + fileServeHandler(c, w, r) + return + } + + fileName := c.URLParams["name"] + + metadata, err := checkFile(fileName) + if err == backends.NotFoundErr { + notFoundHandler(c, w, r) + return + } else if err != nil { + oopsHandler(c, w, r, RespAUTO, "Corrupt metadata.") + return + } + + if src, err := checkAccessKey(r, &metadata); err != nil { + // remove invalid cookie + if src == accessKeySourceCookie { + setAccessKeyCookies(w, getSiteURL(r), fileName, "", time.Unix(0, 0)) + } + + if strings.EqualFold("application/json", r.Header.Get("Accept")) { + dec := json.NewEncoder(w) + _ = dec.Encode(map[string]string{ + "error": errInvalidAccessKey.Error(), + }) + + return + } + + _ = renderTemplate(Templates["access.html"], pongo2.Context{ + "filename": fileName, + "accesspath": fileName, + }, r, w) + + return + } + + if metadata.AccessKey != "" { + var expiry time.Time + if Config.accessKeyCookieExpiry != 0 { + expiry = time.Now().Add(time.Duration(Config.accessKeyCookieExpiry) * time.Second) + } + setAccessKeyCookies(w, getSiteURL(r), fileName, metadata.AccessKey, expiry) + } + + fileDisplayHandler(c, w, r, fileName, metadata) +} diff --git a/backends/localfs/localfs.go b/backends/localfs/localfs.go index 47187b6..42e32b8 100644 --- a/backends/localfs/localfs.go +++ b/backends/localfs/localfs.go @@ -19,6 +19,7 @@ type LocalfsBackend struct { type MetadataJSON struct { DeleteKey string `json:"delete_key"` + AccessKey string `json:"access_key,omitempty"` Sha256sum string `json:"sha256sum"` Mimetype string `json:"mimetype"` Size int64 `json:"size"` @@ -57,6 +58,7 @@ func (b LocalfsBackend) Head(key string) (metadata backends.Metadata, err error) } metadata.DeleteKey = mjson.DeleteKey + metadata.AccessKey = mjson.AccessKey metadata.Mimetype = mjson.Mimetype metadata.ArchiveFiles = mjson.ArchiveFiles metadata.Sha256sum = mjson.Sha256sum @@ -84,12 +86,13 @@ func (b LocalfsBackend) writeMetadata(key string, metadata backends.Metadata) er metaPath := path.Join(b.metaPath, key) mjson := MetadataJSON{ - DeleteKey: metadata.DeleteKey, - Mimetype: metadata.Mimetype, + DeleteKey: metadata.DeleteKey, + AccessKey: metadata.AccessKey, + Mimetype: metadata.Mimetype, ArchiveFiles: metadata.ArchiveFiles, - Sha256sum: metadata.Sha256sum, - Expiry: metadata.Expiry.Unix(), - Size: metadata.Size, + Sha256sum: metadata.Sha256sum, + Expiry: metadata.Expiry.Unix(), + Size: metadata.Size, } dst, err := os.Create(metaPath) @@ -108,7 +111,7 @@ func (b LocalfsBackend) writeMetadata(key string, metadata backends.Metadata) er return nil } -func (b LocalfsBackend) Put(key string, r io.Reader, expiry time.Time, deleteKey string) (m backends.Metadata, err error) { +func (b LocalfsBackend) Put(key string, r io.Reader, expiry time.Time, deleteKey, accessKey string) (m backends.Metadata, err error) { filePath := path.Join(b.filesPath, key) dst, err := os.Create(filePath) @@ -126,16 +129,17 @@ func (b LocalfsBackend) Put(key string, r io.Reader, expiry time.Time, deleteKey return m, err } - dst.Seek(0 ,0) + dst.Seek(0, 0) m, err = helpers.GenerateMetadata(dst) if err != nil { os.Remove(filePath) return } - dst.Seek(0 ,0) + dst.Seek(0, 0) m.Expiry = expiry m.DeleteKey = deleteKey + m.AccessKey = accessKey m.ArchiveFiles, _ = helpers.ListArchiveFiles(m.Mimetype, m.Size, dst) err = b.writeMetadata(key, m) diff --git a/backends/meta.go b/backends/meta.go index 7ba522d..b22276e 100644 --- a/backends/meta.go +++ b/backends/meta.go @@ -7,6 +7,7 @@ import ( type Metadata struct { DeleteKey string + AccessKey string Sha256sum string Mimetype string Size int64 diff --git a/backends/s3/s3.go b/backends/s3/s3.go index f229176..bfc6e1c 100644 --- a/backends/s3/s3.go +++ b/backends/s3/s3.go @@ -86,6 +86,7 @@ func mapMetadata(m backends.Metadata) map[string]*string { "Size": aws.String(strconv.FormatInt(m.Size, 10)), "Mimetype": aws.String(m.Mimetype), "Sha256sum": aws.String(m.Sha256sum), + "AccessKey": aws.String(m.AccessKey), } } @@ -108,10 +109,15 @@ func unmapMetadata(input map[string]*string) (m backends.Metadata, err error) { m.Mimetype = aws.StringValue(input["Mimetype"]) m.Sha256sum = aws.StringValue(input["Sha256sum"]) + + if key, ok := input["AccessKey"]; ok { + m.AccessKey = aws.StringValue(key) + } + return } -func (b S3Backend) Put(key string, r io.Reader, expiry time.Time, deleteKey string) (m backends.Metadata, err error) { +func (b S3Backend) Put(key string, r io.Reader, expiry time.Time, deleteKey, accessKey string) (m backends.Metadata, err error) { tmpDst, err := ioutil.TempFile("", "linx-server-upload") if err != nil { return m, err @@ -137,6 +143,7 @@ func (b S3Backend) Put(key string, r io.Reader, expiry time.Time, deleteKey stri } m.Expiry = expiry m.DeleteKey = deleteKey + m.AccessKey = accessKey // XXX: we may not be able to write this to AWS easily //m.ArchiveFiles, _ = helpers.ListArchiveFiles(m.Mimetype, m.Size, tmpDst) diff --git a/backends/storage.go b/backends/storage.go index fdd8cd6..5d973c4 100644 --- a/backends/storage.go +++ b/backends/storage.go @@ -11,7 +11,7 @@ type StorageBackend interface { Exists(key string) (bool, error) Head(key string) (Metadata, error) Get(key string) (Metadata, io.ReadCloser, error) - Put(key string, r io.Reader, expiry time.Time, deleteKey string) (Metadata, error) + Put(key string, r io.Reader, expiry time.Time, deleteKey, accessKey string) (Metadata, error) PutMetadata(key string, m Metadata) error Size(key string) (int64, error) } diff --git a/display.go b/display.go index feb16da..e15b1b6 100644 --- a/display.go +++ b/display.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "net/http" "path/filepath" - "regexp" "strconv" "strings" "time" @@ -21,24 +20,7 @@ import ( const maxDisplayFileSizeBytes = 1024 * 512 -var cliUserAgentRe = regexp.MustCompile("(?i)(lib)?curl|wget") - -func fileDisplayHandler(c web.C, w http.ResponseWriter, r *http.Request) { - if !Config.noDirectAgents && cliUserAgentRe.MatchString(r.Header.Get("User-Agent")) && !strings.EqualFold("application/json", r.Header.Get("Accept")) { - fileServeHandler(c, w, r) - return - } - - fileName := c.URLParams["name"] - - metadata, err := checkFile(fileName) - if err == backends.NotFoundErr { - notFoundHandler(c, w, r) - return - } else if err != nil { - oopsHandler(c, w, r, RespAUTO, "Corrupt metadata.") - return - } +func fileDisplayHandler(c web.C, w http.ResponseWriter, r *http.Request, fileName string, metadata backends.Metadata) { var expiryHuman string if metadata.Expiry != expiry.NeverExpire { expiryHuman = humanize.RelTime(time.Now(), metadata.Expiry, "", "") @@ -130,7 +112,7 @@ func fileDisplayHandler(c web.C, w http.ResponseWriter, r *http.Request) { tpl = Templates["display/file.html"] } - err = renderTemplate(tpl, pongo2.Context{ + err := renderTemplate(tpl, pongo2.Context{ "mime": metadata.Mimetype, "filename": fileName, "size": sizeHuman, diff --git a/fileserve.go b/fileserve.go index 202e477..27a28a9 100644 --- a/fileserve.go +++ b/fileserve.go @@ -27,6 +27,16 @@ func fileServeHandler(c web.C, w http.ResponseWriter, r *http.Request) { return } + if src, err := checkAccessKey(r, &metadata); err != nil { + // remove invalid cookie + if src == accessKeySourceCookie { + setAccessKeyCookies(w, getSiteURL(r), fileName, "", time.Unix(0, 0)) + } + unauthorizedHandler(c, w, r) + + return + } + if !Config.allowHotlink { referer := r.Header.Get("Referer") u, _ := url.Parse(referer) diff --git a/server.go b/server.go index 59b7b6b..907b719 100644 --- a/server.go +++ b/server.go @@ -15,7 +15,7 @@ import ( "syscall" "time" - "github.com/GeertJohan/go.rice" + rice "github.com/GeertJohan/go.rice" "github.com/andreimarcu/linx-server/backends" "github.com/andreimarcu/linx-server/backends/localfs" "github.com/andreimarcu/linx-server/backends/s3" @@ -69,6 +69,7 @@ var Config struct { s3Bucket string s3ForcePathStyle bool forceRandomFilename bool + accessKeyCookieExpiry uint64 } var Templates = make(map[string]*pongo2.Template) @@ -222,7 +223,8 @@ func setup() *web.Mux { mux.Get(Config.sitePath+"static/*", staticHandler) mux.Get(Config.sitePath+"favicon.ico", staticHandler) mux.Get(Config.sitePath+"robots.txt", staticHandler) - mux.Get(nameRe, fileDisplayHandler) + mux.Get(nameRe, fileAccessHandler) + mux.Post(nameRe, fileAccessHandler) mux.Get(selifRe, fileServeHandler) mux.Get(selifIndexRe, unauthorizedHandler) mux.Get(torrentRe, fileTorrentHandler) @@ -297,6 +299,7 @@ func main() { "Force path-style addressing for S3 (e.g. https://s3.amazonaws.com/linx/example.txt)") flag.BoolVar(&Config.forceRandomFilename, "force-random-filename", false, "Force all uploads to use a random filename") + flag.Uint64Var(&Config.accessKeyCookieExpiry, "access-cookie-expiry", 0, "Expiration time for access key cookies in seconds (set 0 to use session cookies)") iniflags.Parse() diff --git a/static/css/linx.css b/static/css/linx.css index 646c83b..edb88c4 100644 --- a/static/css/linx.css +++ b/static/css/linx.css @@ -264,6 +264,31 @@ body { margin: 0; } +#access_key { + min-width: 100%; + line-height: 1.3em; +} + +#access_key input, span { + vertical-align: middle; +} + +#access_key_checkbox { + margin: 0; +} + +#access_key_checkbox:checked ~ #access_key_input { + display: inline-block; +} +#access_key_checkbox:checked ~ #access_key_text { + display: none; +} + +#access_key_input { + padding: 0; + display: none; +} + .oopscontent { width: 400px; } diff --git a/static/js/upload.js b/static/js/upload.js index fce6e77..c5ac28a 100644 --- a/static/js/upload.js +++ b/static/js/upload.js @@ -1,15 +1,15 @@ // @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later Dropzone.options.dropzone = { - init: function() { + init: function () { var dzone = document.getElementById("dzone"); dzone.style.display = "block"; }, - addedfile: function(file) { + addedfile: function (file) { if (!this.options.autoProcessQueue) { var dropzone = this; var xhr = new XMLHttpRequest(); - xhr.onload = function() { + xhr.onload = function () { if (xhr.readyState !== XMLHttpRequest.DONE) { return; } @@ -39,7 +39,7 @@ Dropzone.options.dropzone = { var cancelAction = document.createElement("span"); cancelAction.className = "cancel"; cancelAction.innerHTML = "Cancel"; - cancelAction.addEventListener('click', function(ev) { + cancelAction.addEventListener('click', function (ev) { this.removeFile(file); }.bind(this)); file.cancelActionElement = cancelAction; @@ -53,19 +53,19 @@ Dropzone.options.dropzone = { document.getElementById("uploads").appendChild(upload); }, - uploadprogress: function(file, p, bytesSent) { + uploadprogress: function (file, p, bytesSent) { p = parseInt(p); file.progressElement.innerHTML = p + "%"; file.uploadElement.setAttribute("style", 'background-image: -webkit-linear-gradient(left, #F2F4F7 ' + p + '%, #E2E2E2 ' + p + '%); background-image: -moz-linear-gradient(left, #F2F4F7 ' + p + '%, #E2E2E2 ' + p + '%); background-image: -ms-linear-gradient(left, #F2F4F7 ' + p + '%, #E2E2E2 ' + p + '%); background-image: -o-linear-gradient(left, #F2F4F7 ' + p + '%, #E2E2E2 ' + p + '%); background-image: linear-gradient(left, #F2F4F7 ' + p + '%, #E2E2E2 ' + p + '%)'); }, - sending: function(file, xhr, formData) { + sending: function (file, xhr, formData) { var randomize = document.getElementById("randomize"); - if(randomize != null) { + if (randomize != null) { formData.append("randomize", randomize.checked); } formData.append("expires", document.getElementById("expires").value); }, - success: function(file, resp) { + success: function (file, resp) { file.fileActions.removeChild(file.progressElement); var fileLabelLink = document.createElement("a"); @@ -79,11 +79,11 @@ Dropzone.options.dropzone = { var deleteAction = document.createElement("span"); deleteAction.innerHTML = "Delete"; deleteAction.className = "cancel"; - deleteAction.addEventListener('click', function(ev) { + deleteAction.addEventListener('click', function (ev) { xhr = new XMLHttpRequest(); xhr.open("DELETE", resp.url, true); xhr.setRequestHeader("Linx-Delete-Key", resp.delete_key); - xhr.onreadystatechange = function(file) { + xhr.onreadystatechange = function (file) { if (xhr.readyState == 4 && xhr.status === 200) { var text = document.createTextNode("Deleted "); file.fileLabel.insertBefore(text, file.fileLabelLink); @@ -97,15 +97,15 @@ Dropzone.options.dropzone = { file.cancelActionElement = deleteAction; file.fileActions.appendChild(deleteAction); }, - canceled: function(file) { + canceled: function (file) { this.options.error(file); }, - error: function(file, resp, xhrO) { + error: function (file, resp, xhrO) { file.fileActions.removeChild(file.cancelActionElement); file.fileActions.removeChild(file.progressElement); if (file.status === "canceled") { - file.fileLabel.innerHTML = file.name + ": Canceled "; + file.fileLabel.innerHTML = file.name + ": Canceled "; } else { if (resp.error) { @@ -125,12 +125,12 @@ Dropzone.options.dropzone = { maxFilesize: Math.round(parseInt(document.getElementById("dropzone").getAttribute("data-maxsize"), 10) / 1024 / 1024), previewsContainer: "#uploads", parallelUploads: 5, - headers: {"Accept": "application/json"}, + headers: { "Accept": "application/json" }, dictDefaultMessage: "Click or Drop file(s) or Paste image", dictFallbackMessage: "" }; -document.onpaste = function(event) { +document.onpaste = function (event) { var items = (event.clipboardData || event.originalEvent.clipboardData).items; for (index in items) { var item = items[index]; @@ -140,4 +140,10 @@ document.onpaste = function(event) { } }; +document.getElementById("access_key_checkbox").onchange = function (event) { + if (event.target.checked == false) { + document.getElementById("access_key_input").value = ""; + } +}; + // @end-license diff --git a/templates.go b/templates.go index 0ab1359..ccd90c7 100644 --- a/templates.go +++ b/templates.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/GeertJohan/go.rice" + rice "github.com/GeertJohan/go.rice" "github.com/flosch/pongo2" ) @@ -51,6 +51,7 @@ func populateTemplatesMap(tSet *pongo2.TemplateSet, tMap map[string]*pongo2.Temp "401.html", "404.html", "oops.html", + "access.html", "display/audio.html", "display/image.html", diff --git a/templates/API.html b/templates/API.html index ab3a356..11d9ab1 100644 --- a/templates/API.html +++ b/templates/API.html @@ -33,6 +33,9 @@
Specify a custom deletion key
Linx-Delete-Key: mysecret
Protect file with password
+ Linx-Access-Key: mysecret
Specify an expiration time (in seconds)
Linx-Expiry: 60