From 5319492760fe678936416e0089c78278070708c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 21 Jun 2023 23:52:21 +0200 Subject: [PATCH] Added file uploads --- config.go | 5 +- file.go | 49 +++++++++++++++ main.go | 84 ++++++++++++++++++++++++- sql/0003.sql | 10 +++ sql/0004.sql | 1 + static/css/main.css | 56 +++++++++++++++-- static/js/app.mjs | 7 ++- static/js/node.mjs | 140 ++++++++++++++++++++++++++++++++++++------ static/less/main.less | 65 +++++++++++++++++--- 9 files changed, 381 insertions(+), 36 deletions(-) create mode 100644 file.go create mode 100644 sql/0003.sql create mode 100644 sql/0004.sql diff --git a/config.go b/config.go index aec9c2b..41969cb 100644 --- a/config.go +++ b/config.go @@ -24,7 +24,10 @@ type Config struct { } Application struct { - StaticDir string + Directories struct { + Static string + Upload string + } } Session struct { diff --git a/file.go b/file.go new file mode 100644 index 0000000..7f9cfa3 --- /dev/null +++ b/file.go @@ -0,0 +1,49 @@ +package main + +import ( + // External + "github.com/jmoiron/sqlx" + + // Standard + "fmt" + "time" +) + +type File struct { + ID int + UserID int `db:"user_id"` + Filename string + Size int64 + MIME string + MD5 string + Uploaded time.Time + +} + +func (session Session) AddFile(file *File) (err error) {// {{{ + file.UserID = session.UserID + + var rows *sqlx.Rows + rows, err = db.Queryx(` + INSERT INTO file(user_id, filename, size, mime, md5) + VALUES($1, $2, $3, $4, $5) + RETURNING id + `, + file.UserID, + file.Filename, + file.Size, + file.MIME, + file.MD5, + ) + if err != nil { + return + } + defer rows.Close() + + rows.Next() + err = rows.Scan(&file.ID) + fmt.Printf("%#v\n", file) + return +}// }}} + +// vim: foldmethod=marker diff --git a/main.go b/main.go index 84235cc..01f5558 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,13 @@ package main import ( // Standard + "crypto/md5" + "encoding/hex" + "path/filepath" "flag" "fmt" "html/template" + "io" "log" "net/http" "os" @@ -17,7 +21,7 @@ import ( const VERSION = "v0.1.2"; const LISTEN_HOST = "0.0.0.0"; -const DB_SCHEMA = 2 +const DB_SCHEMA = 4 var ( flagPort int @@ -59,7 +63,7 @@ func main() {// {{{ connectionManager = NewConnectionManager() go connectionManager.BroadcastLoop() - static = http.FileServer(http.Dir(config.Application.StaticDir)) + static = http.FileServer(http.Dir(config.Application.Directories.Static)) http.HandleFunc("/css_updated", cssUpdateHandler) http.HandleFunc("/session/create", sessionCreate) http.HandleFunc("/session/retrieve", sessionRetrieve) @@ -70,6 +74,7 @@ func main() {// {{{ http.HandleFunc("/node/update", nodeUpdate) http.HandleFunc("/node/rename", nodeRename) http.HandleFunc("/node/delete", nodeDelete) + http.HandleFunc("/node/upload", nodeUpload) http.HandleFunc("/ws", websocketHandler) http.HandleFunc("/", staticHandler) @@ -352,6 +357,79 @@ func nodeDelete(w http.ResponseWriter, r *http.Request) {// {{{ "OK": true, }) }// }}} +func nodeUpload(w http.ResponseWriter, r *http.Request) {// {{{ + var err error + var session Session + log.Println("/node/upload") + + if session, _, err = ValidateSession(r, true); err != nil { + responseError(w, err) + return + } + + // Parse our multipart form, 10 << 20 specifies a maximum + // upload of 10 MB files. + r.ParseMultipartForm(100 << 20) + + // FormFile returns the first file for the given key `myFile` + // it also returns the FileHeader so we can get the Filename, + // the Header and the size of the file + file, handler, err := r.FormFile("file") + if err != nil { + responseError(w, err) + return + } + defer file.Close() + + // Read metadata of file for database, + // and also file contents for MD5, which is used + // to store the file on disk. + fileBytes, err := io.ReadAll(file) + if err != nil { + responseError(w, err) + return + } + md5sumBytes := md5.Sum(fileBytes) + md5sum := hex.EncodeToString(md5sumBytes[:]) + + nodeFile := File{ + Filename: handler.Filename, + Size: handler.Size, + MIME: handler.Header.Get("Content-Type"), + MD5: md5sum, + } + if err = session.AddFile(&nodeFile); err != nil { + responseError(w, err) + return + } + + path := filepath.Join( + config.Application.Directories.Upload, + md5sum[0:1], + md5sum[1:2], + md5sum[2:3], + ) + if err = os.MkdirAll(path, 0755); err != nil { + responseError(w, err) + return + } + + path = filepath.Join( + config.Application.Directories.Upload, + md5sum[0:1], + md5sum[1:2], + md5sum[2:3], + md5sum, + ) + if err = os.WriteFile(path, fileBytes, 0644); err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]interface{}{ + "OK": true, + }) +}// }}} func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{ // Append index.html if needed for further reading of the file @@ -359,7 +437,7 @@ func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{ if p[len(p)-1] == '/' { p += "index.html" } - p = config.Application.StaticDir + p + p = config.Application.Directories.Static + p base := path.Base(p) if tmpl, err = template.New(base).ParseFiles(p); err != nil { return } diff --git a/sql/0003.sql b/sql/0003.sql new file mode 100644 index 0000000..f1bec37 --- /dev/null +++ b/sql/0003.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.file ( + id serial NOT NULL, + user_id int4 NOT NULL, + filename varchar(256) NOT NULL DEFAULT '', + "size" int4 NOT NULL DEFAULT 0, + mime varchar(256) NOT NULL DEFAULT '', + uploaded timestamp NOT NULL DEFAULT NOW(), + CONSTRAINT file_pk PRIMARY KEY (id), + CONSTRAINT file_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); diff --git a/sql/0004.sql b/sql/0004.sql new file mode 100644 index 0000000..f5a5a5e --- /dev/null +++ b/sql/0004.sql @@ -0,0 +1 @@ +ALTER TABLE file ADD COLUMN md5 CHAR(32) DEFAULT '' diff --git a/static/css/main.css b/static/css/main.css index 0f4662d..4ab270e 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -24,8 +24,7 @@ h1 { display: grid; color: #fff; } -#menu-blackout { - display: none; +#blackout { position: absolute; left: 0px; right: 0px; @@ -34,9 +33,6 @@ h1 { z-index: 1024; background: rgba(0, 0, 0, 0.35); } -#menu-blackout.show { - display: initial; -} #menu { display: none; position: absolute; @@ -59,7 +55,7 @@ h1 { -webkit-tap-highlight-color: transparent; } #menu .item.separator { - border-bottom: 3px solid #000; + border-bottom: 2px solid #000; } #menu .item:hover { background: #ddd; @@ -67,6 +63,54 @@ h1 { #menu .item:last-child { border-bottom: none; } +#upload { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #333; + background: #fff; + border: 2px solid #000; + padding: 16px; + z-index: 1025; +} +#upload input { + border: 1px solid #000; + font-size: 0.85em; +} +#upload .files { + display: grid; + grid-template-columns: 1fr min-content; + padding-top: 12px; +} +#upload .files .file { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #ddd; +} +#upload .files .file:nth-child(1) { + padding-top: 0px; + margin-top: 0px; + border-top: none; +} +#upload .files .file.done { + color: #0a0; +} +#upload .files .progress { + justify-self: end; + margin-top: 8px; + padding-top: 8px; + padding-left: 8px; + border-top: 1px solid #ddd; +} +#upload .files .progress:nth-child(2) { + padding-top: 0px; + margin-top: 0px; + border-top: none; +} +#upload .files .progress.done { + color: #0a0; +} header { display: grid; grid-template-columns: 1fr min-content min-content; diff --git a/static/js/app.mjs b/static/js/app.mjs index 2e84dea..a5ba406 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -73,7 +73,7 @@ class App extends Component { } }//}}} - responseError({comm, app}) {//{{{ + responseError({comm, app, upload}) {//{{{ if(comm !== undefined) { comm.text().then(body=>alert(body)) return @@ -87,6 +87,11 @@ class App extends Component { if(app !== undefined) { alert(JSON.stringify(app)) } + + if(upload !== undefined) { + alert(upload) + return + } }//}}} async request(url, params) {//{{{ return new Promise((resolve, reject)=>{ diff --git a/static/js/node.mjs b/static/js/node.mjs index 7f19e91..bcfedc0 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -10,6 +10,7 @@ export class NodeUI extends Component { this.tree = signal(null) this.node = signal(null) this.nodeContent = createRef() + this.upload = signal(false) window.addEventListener('popstate', evt=>{ if(evt.state && evt.state.hasOwnProperty('nodeID')) this.goToNode(evt.state.nodeID, true) @@ -49,14 +50,17 @@ export class NodeUI extends Component { if(this.props.app.nodeModified.value) modified = 'modified'; - return html` - - + let upload = ''; + if(this.upload.value) + upload = html`<${UploadUI} app=${this} />` + let menu = ''; + if(this.menu.value) + upload = html`<${Menu} app=${this} />` + + return html` + ${menu} + ${upload}
this.saveNode()}>
Notes
this.createNode(evt)}>+
@@ -87,21 +91,19 @@ export class NodeUI extends Component { let handled = true switch(evt.key.toUpperCase()) { case 'S': - if(!evt.ctrlKey) { - handled = false - break - } - this.saveNode() + if(evt.ctrlKey || (evt.shiftKey && evt.altKey)) + this.saveNode() break case 'N': - if(!evt.ctrlKey && !evt.AltKey) { - handled = false - break - } - this.createNode() + if((evt.ctrlKey && evt.AltKey) || (evt.shiftKey && evt.altKey)) + this.createNode() break + case 'U': + if((evt.ctrlKey && evt.altKey) || (evt.shiftKey && evt.altKey)) + this.upload.value = true + default: handled = false } @@ -135,7 +137,8 @@ export class NodeUI extends Component { }) }//}}} createNode(evt) {//{{{ - evt.stopPropagation() + if(evt) + evt.stopPropagation() let name = prompt("Name") if(!name) return @@ -257,4 +260,105 @@ class Node { }//}}} } +class Menu extends Component { + render({ app }) {//{{{ + return html` +
app.menu.value = false}>
+ + ` + }//}}} +} +class UploadUI extends Component { + constructor() {//{{{ + super() + this.file = createRef() + this.filelist = signal([]) + this.fileRefs = [] + this.progressRefs = [] + }//}}} + render({ nodeui }) {//{{{ + let filelist = this.filelist.value + let files = [] + for(let i = 0; i < filelist.length; i++) { + files.push(html`
${filelist.item(i).name}
`) + } + + return html` +
nodeui.upload.value = false}>
+
+ this.upload()} multiple /> +
+ ${files} +
+
+ ` + }//}}} + componentDidMount() {//{{{ + this.file.current.focus() + }//}}} + + upload() {//{{{ + this.fileRefs = [] + this.progressRefs = [] + + let input = this.file.current + this.filelist.value = input.files + for(let i = 0; i < input.files.length; i++) { + this.fileRefs.push(createRef()) + this.progressRefs.push(createRef()) + this.postFile(input.files[i], progress=>{ + this.progressRefs[i].current.innerHTML = `${progress}%` + }, ()=>{ + this.fileRefs[i].current.classList.add("done") + this.progressRefs[i].current.classList.add("done") + }) + } + }//}}} + postFile(file, progressCallback, doneCallback) {//{{{ + var formdata = new FormData() + formdata.append('file', file) + + var request = new XMLHttpRequest() + + request.addEventListener("error", ()=>{ + window._app.current.responseError({ upload: "An unknown error occured" }) + }) + + request.addEventListener("loadend", ()=>{ + if(request.status != 200) { + window._app.current.responseError({ upload: request.statusText }) + return + } + + let response = JSON.parse(request.response) + if(!response.OK) { + window._app.current.responseError({ upload: response.Error }) + return + } + + doneCallback() + }) + + request.upload.addEventListener('progress', evt=>{ + var fileSize = file.size + + if(evt.loaded <= fileSize) + progressCallback(Math.round(evt.loaded / fileSize * 100)) + if(evt.loaded == evt.total) + progressCallback(100) + }) + + request.open('post', '/node/upload') + request.setRequestHeader("X-Session-Id", window._app.current.session.UUID) + //request.timeout = 45000 + request.send(formdata) + }//}}} +} + + // vim: foldmethod=marker diff --git a/static/less/main.less b/static/less/main.less index 2e7ed22..6a000d0 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -30,8 +30,7 @@ h1 { color: #fff; } -#menu-blackout { - display: none; +#blackout { position: absolute; left: 0px; right: 0px; @@ -39,10 +38,6 @@ h1 { bottom: 0px; z-index: 1024; background: rgba(0, 0, 0, 0.35); - - &.show { - display: initial; - } } #menu { @@ -68,7 +63,7 @@ h1 { -webkit-tap-highlight-color: transparent; &.separator { - border-bottom: 3px solid #000; + border-bottom: 2px solid #000; } &:hover { @@ -81,6 +76,62 @@ h1 { } } +#upload { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + color: #333; + background: #fff; + border: 2px solid #000; + padding: 16px; + z-index: 1025; + + input { + border: 1px solid #000; + font-size: 0.85em; + } + + .files { + display: grid; + grid-template-columns: 1fr min-content; + padding-top: 12px; + + .file { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #ddd; + &:nth-child(1) { + padding-top: 0px; + margin-top: 0px; + border-top: none; + } + + &.done { + color: #0a0; + } + } + + .progress { + justify-self: end; + margin-top: 8px; + padding-top: 8px; + padding-left: 8px; + border-top: 1px solid #ddd; + &:nth-child(2) { + padding-top: 0px; + margin-top: 0px; + border-top: none; + } + + &.done { + color: #0a0; + } + } + } +} + header { display: grid; grid-template-columns: 1fr min-content min-content;