Merge branch 'accesskey' of git://github.com/stek29/linx-server into stek29-accesskey

This commit is contained in:
Andrei Marcu 2020-03-06 15:29:41 -08:00
commit a4240680c8
15 changed files with 237 additions and 34 deletions

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 { type MetadataJSON struct {
DeleteKey string `json:"delete_key"` DeleteKey string `json:"delete_key"`
AccessKey string `json:"access_key,omitempty"`
Sha256sum string `json:"sha256sum"` Sha256sum string `json:"sha256sum"`
Mimetype string `json:"mimetype"` Mimetype string `json:"mimetype"`
Size int64 `json:"size"` Size int64 `json:"size"`
@ -57,6 +58,7 @@ func (b LocalfsBackend) Head(key string) (metadata backends.Metadata, err error)
} }
metadata.DeleteKey = mjson.DeleteKey metadata.DeleteKey = mjson.DeleteKey
metadata.AccessKey = mjson.AccessKey
metadata.Mimetype = mjson.Mimetype metadata.Mimetype = mjson.Mimetype
metadata.ArchiveFiles = mjson.ArchiveFiles metadata.ArchiveFiles = mjson.ArchiveFiles
metadata.Sha256sum = mjson.Sha256sum metadata.Sha256sum = mjson.Sha256sum
@ -85,6 +87,7 @@ func (b LocalfsBackend) writeMetadata(key string, metadata backends.Metadata) er
mjson := MetadataJSON{ mjson := MetadataJSON{
DeleteKey: metadata.DeleteKey, DeleteKey: metadata.DeleteKey,
AccessKey: metadata.AccessKey,
Mimetype: metadata.Mimetype, Mimetype: metadata.Mimetype,
ArchiveFiles: metadata.ArchiveFiles, ArchiveFiles: metadata.ArchiveFiles,
Sha256sum: metadata.Sha256sum, Sha256sum: metadata.Sha256sum,
@ -108,7 +111,7 @@ func (b LocalfsBackend) writeMetadata(key string, metadata backends.Metadata) er
return nil 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) filePath := path.Join(b.filesPath, key)
dst, err := os.Create(filePath) dst, err := os.Create(filePath)
@ -136,6 +139,7 @@ func (b LocalfsBackend) Put(key string, r io.Reader, expiry time.Time, deleteKey
m.Expiry = expiry m.Expiry = expiry
m.DeleteKey = deleteKey m.DeleteKey = deleteKey
m.AccessKey = accessKey
m.ArchiveFiles, _ = helpers.ListArchiveFiles(m.Mimetype, m.Size, dst) m.ArchiveFiles, _ = helpers.ListArchiveFiles(m.Mimetype, m.Size, dst)
err = b.writeMetadata(key, m) err = b.writeMetadata(key, m)

View File

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

View File

@ -86,6 +86,7 @@ func mapMetadata(m backends.Metadata) map[string]*string {
"Size": aws.String(strconv.FormatInt(m.Size, 10)), "Size": aws.String(strconv.FormatInt(m.Size, 10)),
"Mimetype": aws.String(m.Mimetype), "Mimetype": aws.String(m.Mimetype),
"Sha256sum": aws.String(m.Sha256sum), "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.Mimetype = aws.StringValue(input["Mimetype"])
m.Sha256sum = aws.StringValue(input["Sha256sum"]) m.Sha256sum = aws.StringValue(input["Sha256sum"])
if key, ok := input["AccessKey"]; ok {
m.AccessKey = aws.StringValue(key)
}
return 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") tmpDst, err := ioutil.TempFile("", "linx-server-upload")
if err != nil { if err != nil {
return m, err 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.Expiry = expiry
m.DeleteKey = deleteKey m.DeleteKey = deleteKey
m.AccessKey = accessKey
// XXX: we may not be able to write this to AWS easily // XXX: we may not be able to write this to AWS easily
//m.ArchiveFiles, _ = helpers.ListArchiveFiles(m.Mimetype, m.Size, tmpDst) //m.ArchiveFiles, _ = helpers.ListArchiveFiles(m.Mimetype, m.Size, tmpDst)

View File

@ -11,7 +11,7 @@ type StorageBackend interface {
Exists(key string) (bool, error) Exists(key string) (bool, error)
Head(key string) (Metadata, error) Head(key string) (Metadata, error)
Get(key string) (Metadata, io.ReadCloser, 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 PutMetadata(key string, m Metadata) error
Size(key string) (int64, error) Size(key string) (int64, error)
} }

View File

@ -5,7 +5,6 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -21,24 +20,7 @@ import (
const maxDisplayFileSizeBytes = 1024 * 512 const maxDisplayFileSizeBytes = 1024 * 512
var cliUserAgentRe = regexp.MustCompile("(?i)(lib)?curl|wget") func fileDisplayHandler(c web.C, w http.ResponseWriter, r *http.Request, fileName string, metadata backends.Metadata) {
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
}
var expiryHuman string var expiryHuman string
if metadata.Expiry != expiry.NeverExpire { if metadata.Expiry != expiry.NeverExpire {
expiryHuman = humanize.RelTime(time.Now(), metadata.Expiry, "", "") 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"] tpl = Templates["display/file.html"]
} }
err = renderTemplate(tpl, pongo2.Context{ err := renderTemplate(tpl, pongo2.Context{
"mime": metadata.Mimetype, "mime": metadata.Mimetype,
"filename": fileName, "filename": fileName,
"size": sizeHuman, "size": sizeHuman,

View File

@ -27,6 +27,16 @@ func fileServeHandler(c web.C, w http.ResponseWriter, r *http.Request) {
return 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 { if !Config.allowHotlink {
referer := r.Header.Get("Referer") referer := r.Header.Get("Referer")
u, _ := url.Parse(referer) u, _ := url.Parse(referer)

View File

@ -15,7 +15,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/GeertJohan/go.rice" rice "github.com/GeertJohan/go.rice"
"github.com/andreimarcu/linx-server/backends" "github.com/andreimarcu/linx-server/backends"
"github.com/andreimarcu/linx-server/backends/localfs" "github.com/andreimarcu/linx-server/backends/localfs"
"github.com/andreimarcu/linx-server/backends/s3" "github.com/andreimarcu/linx-server/backends/s3"
@ -69,6 +69,7 @@ var Config struct {
s3Bucket string s3Bucket string
s3ForcePathStyle bool s3ForcePathStyle bool
forceRandomFilename bool forceRandomFilename bool
accessKeyCookieExpiry uint64
} }
var Templates = make(map[string]*pongo2.Template) 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+"static/*", staticHandler)
mux.Get(Config.sitePath+"favicon.ico", staticHandler) mux.Get(Config.sitePath+"favicon.ico", staticHandler)
mux.Get(Config.sitePath+"robots.txt", 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(selifRe, fileServeHandler)
mux.Get(selifIndexRe, unauthorizedHandler) mux.Get(selifIndexRe, unauthorizedHandler)
mux.Get(torrentRe, fileTorrentHandler) 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)") "Force path-style addressing for S3 (e.g. https://s3.amazonaws.com/linx/example.txt)")
flag.BoolVar(&Config.forceRandomFilename, "force-random-filename", false, flag.BoolVar(&Config.forceRandomFilename, "force-random-filename", false,
"Force all uploads to use a random filename") "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() iniflags.Parse()

View File

@ -264,6 +264,24 @@ body {
margin: 0; margin: 0;
} }
#access_key_checkbox {
margin: 0;
}
#access_key_checkbox:checked ~ #access_key {
display: inline-block;
}
#access_key_checkbox:checked ~ #access_key_label {
display: none;
}
#access_key {
line-height: 1em;
padding: 1px;
border: 3px;
display: none;
}
.oopscontent { .oopscontent {
width: 400px; width: 400px;
} }

View File

@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/GeertJohan/go.rice" rice "github.com/GeertJohan/go.rice"
"github.com/flosch/pongo2" "github.com/flosch/pongo2"
) )
@ -51,6 +51,7 @@ func populateTemplatesMap(tSet *pongo2.TemplateSet, tMap map[string]*pongo2.Temp
"401.html", "401.html",
"404.html", "404.html",
"oops.html", "oops.html",
"access.html",
"display/audio.html", "display/audio.html",
"display/image.html", "display/image.html",

View File

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

11
templates/access.html Normal file
View File

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

View File

@ -27,6 +27,12 @@
</select> </select>
</label> </label>
</div> </div>
<div>
<input type="checkbox" id="access_key_checkbox"/>
<input id="access_key" name="access_key" type="text" placeholder="Access password"/>
<label id="access_key_label"> Require password to access</label>
</div>
</div> </div>
<div class="clear"></div> <div class="clear"></div>
</form> </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> {% 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>
<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"> <select id="expiry" name="expires">
<option disabled>Expires:</option> <option disabled>Expires:</option>
{% for expiry in expirylist %} {% for expiry in expirylist %}

View File

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