Merge branch 'stek29-accesskey'

This commit is contained in:
Andrei Marcu 2020-03-07 17:53:20 -08:00
commit e4468715ac
17 changed files with 279 additions and 56 deletions

View File

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

147
access.go Normal file
View File

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

View File

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

View File

@ -7,6 +7,7 @@ import (
type Metadata struct {
DeleteKey string
AccessKey string
Sha256sum string
Mimetype string
Size int64

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}

View File

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

View File

@ -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",

View File

@ -33,6 +33,9 @@
<p>Specify a custom deletion key<br/>
<code>Linx-Delete-Key: mysecret</code></p>
<p>Protect file with password<br/>
<code>Linx-Access-Key: mysecret</code></p>
<p>Specify an expiration time (in seconds)<br/>
<code>Linx-Expiry: 60</code></p>
@ -46,6 +49,7 @@
“direct_url”: the url to access the file directly<br/>
“filename”: the (optionally generated) filename<br/>
“delete_key”: the (optionally generated) deletion key,<br/>
“access_key”: the (optionally supplied) access key,<br/>
“expiry”: the unix timestamp at which the file will expire (0 if never)<br/>
“size”: the size in bytes of the file<br/>
“mimetype”: the guessed mimetype of the file<br/>

12
templates/access.html Normal file
View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div id="main" class="oopscontent">
<form action="{{ unlockpath }}" method="POST" enctype="multipart/form-data">
{{ filename }} is protected with a password: <br /><br />
<input name="access_key" type="password" />
<input id="submitbtn" type="submit" value="Unlock">
<br /><br />
</form>
</div>
{% endblock %}

View File

@ -17,16 +17,28 @@
</div>
<div id="choices">
<label>{% if not forcerandom %}<input name="randomize" id="randomize" type="checkbox" checked /> Randomize filename{% endif %}</label>
<span class="hint--top hint--bounce" data-hint="Replace the filename with random characters. The file extension is retained">
<label><input {% if forcerandom %} disabled {% endif %} name="randomize" id="randomize" type="checkbox" checked /> Randomize filename</label>
</span>
<div id="expiry">
<label>File expiry:
<select name="expires" id="expires">
{% for expiry in expirylist %}
<option value="{{ expiry.Seconds }}"{% if forloop.Last %} selected{% endif %}>{{ expiry.Human }}</option>
{% endfor %}
</select>
<select name="expires" id="expires">
{% for expiry in expirylist %}
<option value="{{ expiry.Seconds }}"{% if forloop.Last %} selected{% endif %}>{{ expiry.Human }}</option>
{% endfor %}
</select>
</label>
</div>
<div id="access_key">
<span class="hint--top hint--bounce" data-hint="Require password to access (this does not encrypt the file but only limits access)">
<label>
<input type="checkbox" id="access_key_checkbox"/>
<input id="access_key_input" name="access_key" type="text" placeholder="Access password"/>
<span id="access_key_text">Require access password</span>
</label>
</span>
</div>
</div>
<div class="clear"></div>
</form>

View File

@ -8,6 +8,10 @@
{% if not forcerandom %}<span class="hint--top hint--bounce" data-hint="Leave empty for random filename"><input class="codebox" name='filename' id="filename" type='text' value="" placeholder="filename" /></span>{% endif %}.<span class="hint--top hint--bounce" data-hint="Enable syntax highlighting by adding the extension"><input id="extension" class="codebox" name='extension' type='text' value="" placeholder="txt" /></span>
</div>
<div>
<span class="hint--top hint--bounce" data-hint="Require password to access (leave empty to disable)">
<input class="codebox" name="access_key" type="text" placeholder="password"/>
</span>
<select id="expiry" name="expires">
<option disabled>Expires:</option>
{% for expiry in expirylist %}

View File

@ -40,6 +40,7 @@ type UploadRequest struct {
expiry time.Duration // Seconds until expiry, 0 = never
deleteKey string // Empty string if not defined
randomBarename bool
accessKey string // Empty string if not defined
}
// Metadata associated with a file as it would actually be stored
@ -88,6 +89,7 @@ func uploadPostHandler(c web.C, w http.ResponseWriter, r *http.Request) {
}
upReq.expiry = parseExpiry(r.PostFormValue("expires"))
upReq.accessKey = r.PostFormValue(accessKeyParamName)
if r.PostFormValue("randomize") == "true" {
upReq.randomBarename = true
@ -192,6 +194,7 @@ func uploadRemote(c web.C, w http.ResponseWriter, r *http.Request) {
upReq.filename = filepath.Base(grabUrl.Path)
upReq.src = http.MaxBytesReader(w, resp.Body, Config.maxSize)
upReq.deleteKey = r.FormValue("deletekey")
upReq.accessKey = r.FormValue(accessKeyParamName)
upReq.randomBarename = r.FormValue("randomize") == "yes"
upReq.expiry = parseExpiry(r.FormValue("expiry"))
@ -222,6 +225,7 @@ func uploadHeaderProcess(r *http.Request, upReq *UploadRequest) {
}
upReq.deleteKey = r.Header.Get("Linx-Delete-Key")
upReq.accessKey = r.Header.Get(accessKeyHeaderName)
// Get seconds until expiry. Non-integer responses never expire.
expStr := r.Header.Get("Linx-Expiry")
@ -321,7 +325,7 @@ func processUpload(upReq UploadRequest) (upload Upload, err error) {
upReq.deleteKey = uniuri.NewLen(30)
}
upload.Metadata, err = storageBackend.Put(upload.Filename, io.MultiReader(bytes.NewReader(header), upReq.src), fileExpiry, upReq.deleteKey)
upload.Metadata, err = storageBackend.Put(upload.Filename, io.MultiReader(bytes.NewReader(header), upReq.src), fileExpiry, upReq.deleteKey, upReq.accessKey)
if err != nil {
return upload, err
}
@ -339,6 +343,7 @@ func generateJSONresponse(upload Upload, r *http.Request) []byte {
"direct_url": getSiteURL(r) + Config.selifPath + upload.Filename,
"filename": upload.Filename,
"delete_key": upload.Metadata.DeleteKey,
"access_key": upload.Metadata.AccessKey,
"expiry": strconv.FormatInt(upload.Metadata.Expiry.Unix(), 10),
"size": strconv.FormatInt(upload.Metadata.Size, 10),
"mimetype": upload.Metadata.Mimetype,