Implement file upload / download, fix #77

This commit is contained in:
Daniel Heath 2018-02-16 10:50:32 +11:00
parent 4ebf9e02ef
commit efd4cfb0d1
8 changed files with 4094 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,14 @@
package main package main
import ( import (
"crypto/sha256"
"fmt" "fmt"
"html/template" "html/template"
"io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"os"
"path" "path"
"strconv" "strconv"
"strings" "strings"
@ -22,6 +26,8 @@ var customCSS []byte
var defaultLock string var defaultLock string
var debounceTime int var debounceTime int
var diaryMode bool var diaryMode bool
var allowFileUploads bool
var maxUploadMB uint
func serve( func serve(
host, host,
@ -39,6 +45,7 @@ func serve(
allowInsecure bool, allowInsecure bool,
hotTemplateReloading bool, hotTemplateReloading bool,
) { ) {
if hotTemplateReloading { if hotTemplateReloading {
gin.SetMode(gin.DebugMode) gin.SetMode(gin.DebugMode)
} else { } else {
@ -63,8 +70,8 @@ func serve(
page := c.Param("page") page := c.Param("page")
cmd := c.Param("command") cmd := c.Param("command")
if page == "sitemap.xml" || page == "favicon.ico" || page == "static" { if page == "sitemap.xml" || page == "favicon.ico" || page == "static" || page == "uploads" {
return false // no auth for sitemap return false // no auth for these
} }
if page != "" && cmd == "/read" { if page != "" && cmd == "/read" {
@ -88,6 +95,9 @@ func serve(
c.Redirect(302, "/"+randomAlliterateCombo()) c.Redirect(302, "/"+randomAlliterateCombo())
} }
}) })
router.POST("/uploads", handleUpload)
router.GET("/:page", func(c *gin.Context) { router.GET("/:page", func(c *gin.Context) {
page := c.Param("page") page := c.Param("page")
c.Redirect(302, "/"+page+"/") c.Redirect(302, "/"+page+"/")
@ -135,7 +145,7 @@ func serve(
if TLS { if TLS {
http.ListenAndServeTLS(host+":"+port, crt_path, key_path, router) http.ListenAndServeTLS(host+":"+port, crt_path, key_path, router)
} else { } else {
router.Run(host + ":" + port) panic(router.Run(host + ":" + port))
} }
} }
@ -267,7 +277,26 @@ func handlePageRequest(c *gin.Context) {
} }
c.Data(http.StatusOK, contentType(filename), data) c.Data(http.StatusOK, contentType(filename), data)
return return
} else if page == "uploads" {
pathname := path.Join(pathToData, command[1:]+".upload")
if allowInsecureHtml {
c.Header(
"Content-Disposition",
`inline; filename="`+c.DefaultQuery("filename", "upload")+`"`,
)
} else {
// Prevent malicious html uploads by forcing type to plaintext and 'download-instead-of-view'
c.Header("Content-Type", "text/plain")
c.Header(
"Content-Disposition",
`attachment; filename="`+c.DefaultQuery("filename", "upload")+`"`,
)
}
c.File(pathname)
return
} }
p := Open(page) p := Open(page)
if len(command) < 2 { if len(command) < 2 {
if p.IsPublished { if p.IsPublished {
@ -394,6 +423,8 @@ func handlePageRequest(c *gin.Context) {
"Date": time.Now().Format("2006-01-02"), "Date": time.Now().Format("2006-01-02"),
"UnixTime": time.Now().Unix(), "UnixTime": time.Now().Unix(),
"ChildPageNames": p.ChildPageNames(), "ChildPageNames": p.ChildPageNames(),
"AllowFileUploads": allowFileUploads,
"MaxUploadMB": maxUploadMB,
}) })
} }
@ -583,6 +614,45 @@ func handlePublish(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": message}) c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
} }
func handleUpload(c *gin.Context) {
if !allowFileUploads {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("Uploads are disabled on this server"))
return
}
file, info, err := c.Request.FormFile("file")
defer file.Close()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
newName := "sha256-" + encodeBytesToBase32(h.Sum(nil))
// Replaces any existing version, but sha256 collisions are rare as anything.
outfile, err := os.Create(path.Join(pathToData, newName+".upload"))
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
file.Seek(0, io.SeekStart)
_, err = io.Copy(outfile, file)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Header("Location", "/uploads/"+newName+"?filename="+url.QueryEscape(info.Filename))
return
}
func handleEncrypt(c *gin.Context) { func handleEncrypt(c *gin.Context) {
type QueryJSON struct { type QueryJSON struct {
Page string `json:"page"` Page string `json:"page"`

12
main.go
View File

@ -40,6 +40,9 @@ func main() {
fmt.Printf("\nRunning cowyo server (version %s) at http://%s:%s\n\n", version, host, c.GlobalString("port")) fmt.Printf("\nRunning cowyo server (version %s) at http://%s:%s\n\n", version, host, c.GlobalString("port"))
} }
allowFileUploads = c.GlobalBool("allow-file-uploads")
maxUploadMB = c.GlobalUint("max-upload-mb")
serve( serve(
c.GlobalString("host"), c.GlobalString("host"),
c.GlobalString("port"), c.GlobalString("port"),
@ -131,6 +134,15 @@ func main() {
Value: "secret", Value: "secret",
Usage: "random data to use for cookies; changing it will invalidate all sessions", Usage: "random data to use for cookies; changing it will invalidate all sessions",
}, },
cli.BoolFlag{
Name: "allow-file-uploads",
Usage: "Enable file uploads",
},
cli.UintFlag{
Name: "max-upload-mb",
Value: 2,
Usage: "Largest file upload (in mb) allowed",
},
} }
app.Commands = []cli.Command{ app.Commands = []cli.Command{
{ {

388
static/css/dropzone.css Normal file
View File

@ -0,0 +1,388 @@
/*
* The MIT License
* Copyright (c) 2012 Matias Meno <m@tias.me>
*/
@-webkit-keyframes passing-through {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30%, 70% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); }
100% {
opacity: 0;
-webkit-transform: translateY(-40px);
-moz-transform: translateY(-40px);
-ms-transform: translateY(-40px);
-o-transform: translateY(-40px);
transform: translateY(-40px); } }
@-moz-keyframes passing-through {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30%, 70% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); }
100% {
opacity: 0;
-webkit-transform: translateY(-40px);
-moz-transform: translateY(-40px);
-ms-transform: translateY(-40px);
-o-transform: translateY(-40px);
transform: translateY(-40px); } }
@keyframes passing-through {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30%, 70% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); }
100% {
opacity: 0;
-webkit-transform: translateY(-40px);
-moz-transform: translateY(-40px);
-ms-transform: translateY(-40px);
-o-transform: translateY(-40px);
transform: translateY(-40px); } }
@-webkit-keyframes slide-in {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); } }
@-moz-keyframes slide-in {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); } }
@keyframes slide-in {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); } }
@-webkit-keyframes pulse {
0% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); }
10% {
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
transform: scale(1.1); }
20% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); } }
@-moz-keyframes pulse {
0% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); }
10% {
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
transform: scale(1.1); }
20% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); } }
@keyframes pulse {
0% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); }
10% {
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
transform: scale(1.1); }
20% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); } }
.dropzone, .dropzone * {
box-sizing: border-box; }
.dropzone {
min-height: 150px;
border: 2px solid rgba(0, 0, 0, 0.3);
background: white;
padding: 20px 20px; }
.dropzone.dz-clickable {
cursor: pointer; }
.dropzone.dz-clickable * {
cursor: default; }
.dropzone.dz-clickable .dz-message, .dropzone.dz-clickable .dz-message * {
cursor: pointer; }
.dropzone.dz-started .dz-message {
display: none; }
.dropzone.dz-drag-hover {
border-style: solid; }
.dropzone.dz-drag-hover .dz-message {
opacity: 0.5; }
.dropzone .dz-message {
text-align: center;
margin: 2em 0; }
.dropzone .dz-preview {
position: relative;
display: inline-block;
vertical-align: top;
margin: 16px;
min-height: 100px; }
.dropzone .dz-preview:hover {
z-index: 1000; }
.dropzone .dz-preview:hover .dz-details {
opacity: 1; }
.dropzone .dz-preview.dz-file-preview .dz-image {
border-radius: 20px;
background: #999;
background: linear-gradient(to bottom, #eee, #ddd); }
.dropzone .dz-preview.dz-file-preview .dz-details {
opacity: 1; }
.dropzone .dz-preview.dz-image-preview {
background: white; }
.dropzone .dz-preview.dz-image-preview .dz-details {
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
-ms-transition: opacity 0.2s linear;
-o-transition: opacity 0.2s linear;
transition: opacity 0.2s linear; }
.dropzone .dz-preview .dz-remove {
font-size: 14px;
text-align: center;
display: block;
cursor: pointer;
border: none; }
.dropzone .dz-preview .dz-remove:hover {
text-decoration: underline; }
.dropzone .dz-preview:hover .dz-details {
opacity: 1; }
.dropzone .dz-preview .dz-details {
z-index: 20;
position: absolute;
top: 0;
left: 0;
opacity: 0;
font-size: 13px;
min-width: 100%;
max-width: 100%;
padding: 2em 1em;
text-align: center;
color: rgba(0, 0, 0, 0.9);
line-height: 150%; }
.dropzone .dz-preview .dz-details .dz-size {
margin-bottom: 1em;
font-size: 16px; }
.dropzone .dz-preview .dz-details .dz-filename {
white-space: nowrap; }
.dropzone .dz-preview .dz-details .dz-filename:hover span {
border: 1px solid rgba(200, 200, 200, 0.8);
background-color: rgba(255, 255, 255, 0.8); }
.dropzone .dz-preview .dz-details .dz-filename:not(:hover) {
overflow: hidden;
text-overflow: ellipsis; }
.dropzone .dz-preview .dz-details .dz-filename:not(:hover) span {
border: 1px solid transparent; }
.dropzone .dz-preview .dz-details .dz-filename span, .dropzone .dz-preview .dz-details .dz-size span {
background-color: rgba(255, 255, 255, 0.4);
padding: 0 0.4em;
border-radius: 3px; }
.dropzone .dz-preview:hover .dz-image img {
-webkit-transform: scale(1.05, 1.05);
-moz-transform: scale(1.05, 1.05);
-ms-transform: scale(1.05, 1.05);
-o-transform: scale(1.05, 1.05);
transform: scale(1.05, 1.05);
-webkit-filter: blur(8px);
filter: blur(8px); }
.dropzone .dz-preview .dz-image {
border-radius: 20px;
overflow: hidden;
width: 120px;
height: 120px;
position: relative;
display: block;
z-index: 10; }
.dropzone .dz-preview .dz-image img {
display: block; }
.dropzone .dz-preview.dz-success .dz-success-mark {
-webkit-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
-moz-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
-ms-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
-o-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); }
.dropzone .dz-preview.dz-error .dz-error-mark {
opacity: 1;
-webkit-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
-moz-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
-ms-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
-o-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); }
.dropzone .dz-preview .dz-success-mark, .dropzone .dz-preview .dz-error-mark {
pointer-events: none;
opacity: 0;
z-index: 500;
position: absolute;
display: block;
top: 50%;
left: 50%;
margin-left: -27px;
margin-top: -27px; }
.dropzone .dz-preview .dz-success-mark svg, .dropzone .dz-preview .dz-error-mark svg {
display: block;
width: 54px;
height: 54px; }
.dropzone .dz-preview.dz-processing .dz-progress {
opacity: 1;
-webkit-transition: all 0.2s linear;
-moz-transition: all 0.2s linear;
-ms-transition: all 0.2s linear;
-o-transition: all 0.2s linear;
transition: all 0.2s linear; }
.dropzone .dz-preview.dz-complete .dz-progress {
opacity: 0;
-webkit-transition: opacity 0.4s ease-in;
-moz-transition: opacity 0.4s ease-in;
-ms-transition: opacity 0.4s ease-in;
-o-transition: opacity 0.4s ease-in;
transition: opacity 0.4s ease-in; }
.dropzone .dz-preview:not(.dz-processing) .dz-progress {
-webkit-animation: pulse 6s ease infinite;
-moz-animation: pulse 6s ease infinite;
-ms-animation: pulse 6s ease infinite;
-o-animation: pulse 6s ease infinite;
animation: pulse 6s ease infinite; }
.dropzone .dz-preview .dz-progress {
opacity: 1;
z-index: 1000;
pointer-events: none;
position: absolute;
height: 16px;
left: 50%;
top: 50%;
margin-top: -8px;
width: 80px;
margin-left: -40px;
background: rgba(255, 255, 255, 0.9);
-webkit-transform: scale(1);
border-radius: 8px;
overflow: hidden; }
.dropzone .dz-preview .dz-progress .dz-upload {
background: #333;
background: linear-gradient(to bottom, #666, #444);
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0;
-webkit-transition: width 300ms ease-in-out;
-moz-transition: width 300ms ease-in-out;
-ms-transition: width 300ms ease-in-out;
-o-transition: width 300ms ease-in-out;
transition: width 300ms ease-in-out; }
.dropzone .dz-preview.dz-error .dz-error-message {
display: block; }
.dropzone .dz-preview.dz-error:hover .dz-error-message {
opacity: 1;
pointer-events: auto; }
.dropzone .dz-preview .dz-error-message {
pointer-events: none;
z-index: 1000;
position: absolute;
display: block;
display: none;
opacity: 0;
-webkit-transition: opacity 0.3s ease;
-moz-transition: opacity 0.3s ease;
-ms-transition: opacity 0.3s ease;
-o-transition: opacity 0.3s ease;
transition: opacity 0.3s ease;
border-radius: 8px;
font-size: 13px;
top: 130px;
left: -10px;
width: 140px;
background: #be2626;
background: linear-gradient(to bottom, #be2626, #a92222);
padding: 0.5em 1.2em;
color: white; }
.dropzone .dz-preview .dz-error-message:after {
content: '';
position: absolute;
top: -6px;
left: 64px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #be2626; }

View File

@ -353,3 +353,36 @@ $(window).load(function() {
event.target.classList.add('deleting'); event.target.classList.add('deleting');
}); });
}); });
// TODO: Avoid uploading the same thing twice (check if it's already present while allowing failed uploads to be overwritten?)
function onUploadFinished(file) {
this.removeFile(file);
var cursorPos = $('#userInput').prop('selectionStart');
var cursorEnd = $('#userInput').prop('selectionEnd');
var v = $('#userInput').val();
var textBefore = v.substring(0, cursorPos);
var textAfter = v.substring(cursorPos, v.length);
var message = 'uploaded file';
if (cursorEnd > cursorPos) {
message = v.substring(cursorPos, cursorEnd);
textAfter = v.substring(cursorEnd, v.length);
}
var prefix = '';
if (file.type.startsWith("image")) {
prefix = '!';
}
var extraText = prefix+'['+message+'](' +
file.xhr.getResponseHeader("Location") +
')';
$('#userInput').val(
textBefore +
extraText +
textAfter
);
// Select the newly-inserted link
$('#userInput').prop('selectionStart', cursorPos);
$('#userInput').prop('selectionEnd', cursorPos + extraText.length);
$('#userInput').trigger('keyup'); // trigger a save
}

3504
static/js/dropzone.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@
{{ if and .CustomCSS .ReadPage }} {{ if and .CustomCSS .ReadPage }}
<link rel="stylesheet" type="text/css" href="/static/css/custom.css"> <link rel="stylesheet" type="text/css" href="/static/css/custom.css">
{{ else }} {{ else }}
<link rel="stylesheet" href="/static/css/dropzone.css">
<link rel="stylesheet" type="text/css" href="/static/css/github-markdown.css"> <link rel="stylesheet" type="text/css" href="/static/css/github-markdown.css">
<link rel="stylesheet" type="text/css" href="/static/css/menus-min.css"> <link rel="stylesheet" type="text/css" href="/static/css/menus-min.css">
<link rel="stylesheet" type="text/css" href="/static/css/base-min.css"> <link rel="stylesheet" type="text/css" href="/static/css/base-min.css">
@ -32,6 +33,7 @@
<script type="text/javascript" src="/static/js/jquery-1.8.3.js"></script> <script type="text/javascript" src="/static/js/jquery-1.8.3.js"></script>
<script src="/static/js/highlight.min.js"></script> <script src="/static/js/highlight.min.js"></script>
<script type="text/javascript" src="/static/js/highlight.pack.js"></script> <script type="text/javascript" src="/static/js/highlight.pack.js"></script>
<script src="/static/js/dropzone.js"></script>
{{ end }} {{ end }}
<title>{{ .Page }}</title> <title>{{ .Page }}</title>
@ -130,12 +132,32 @@
<div id="wrap"> <div id="wrap">
{{ if .EditPage }} {{ if .EditPage }}
<div id="pad"> <div id="pad">
<textarea
autofocus <script>
placeholder="Use markdown to write your note! New: you can publish your note when you are done ({{ .Page }} -> Publish)." Dropzone.options.userInputForm = {
id="userInput" clickable: false,
>{{ .RawPage }}</textarea> maxFilesize: {{ if .MaxUploadMB }} {{.MaxUploadMB}} {{ else }} 10 {{end }}, // MB
init: function initDropzone() {
this.on("complete", onUploadFinished);
}
};
</script>
<form
id="userInputForm"
action="/uploads"
{{ if .AllowFileUploads }}
class="dropzone"
{{ end }}
>
<textarea
autofocus
placeholder="Use markdown to write your note! New: you can publish your note when you are done ({{ .Page }} -> Publish)."
id="userInput"
>{{ .RawPage }}</textarea>
</form>
</div> </div>
{{ end }} {{ end }}

View File

@ -196,8 +196,13 @@ func MarkdownToHtml(s string) string {
func GithubMarkdownToHTML(s string) string { func GithubMarkdownToHTML(s string) string {
return string(github_flavored_markdown.Markdown([]byte(s))) return string(github_flavored_markdown.Markdown([]byte(s)))
} }
func encodeToBase32(s string) string { func encodeToBase32(s string) string {
return base32.StdEncoding.EncodeToString([]byte(s)) return encodeBytesToBase32([]byte(s))
}
func encodeBytesToBase32(s []byte) string {
return base32.StdEncoding.EncodeToString(s)
} }
func decodeFromBase32(s string) (s2 string, err error) { func decodeFromBase32(s string) (s2 string, err error) {