Added file uploads

This commit is contained in:
Magnus Åhall 2023-06-21 23:52:21 +02:00
parent 73c8adc86a
commit 5319492760
9 changed files with 381 additions and 36 deletions

View File

@ -24,7 +24,10 @@ type Config struct {
} }
Application struct { Application struct {
StaticDir string Directories struct {
Static string
Upload string
}
} }
Session struct { Session struct {

49
file.go Normal file
View File

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

84
main.go
View File

@ -2,9 +2,13 @@ package main
import ( import (
// Standard // Standard
"crypto/md5"
"encoding/hex"
"path/filepath"
"flag" "flag"
"fmt" "fmt"
"html/template" "html/template"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -17,7 +21,7 @@ import (
const VERSION = "v0.1.2"; const VERSION = "v0.1.2";
const LISTEN_HOST = "0.0.0.0"; const LISTEN_HOST = "0.0.0.0";
const DB_SCHEMA = 2 const DB_SCHEMA = 4
var ( var (
flagPort int flagPort int
@ -59,7 +63,7 @@ func main() {// {{{
connectionManager = NewConnectionManager() connectionManager = NewConnectionManager()
go connectionManager.BroadcastLoop() 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("/css_updated", cssUpdateHandler)
http.HandleFunc("/session/create", sessionCreate) http.HandleFunc("/session/create", sessionCreate)
http.HandleFunc("/session/retrieve", sessionRetrieve) http.HandleFunc("/session/retrieve", sessionRetrieve)
@ -70,6 +74,7 @@ func main() {// {{{
http.HandleFunc("/node/update", nodeUpdate) http.HandleFunc("/node/update", nodeUpdate)
http.HandleFunc("/node/rename", nodeRename) http.HandleFunc("/node/rename", nodeRename)
http.HandleFunc("/node/delete", nodeDelete) http.HandleFunc("/node/delete", nodeDelete)
http.HandleFunc("/node/upload", nodeUpload)
http.HandleFunc("/ws", websocketHandler) http.HandleFunc("/ws", websocketHandler)
http.HandleFunc("/", staticHandler) http.HandleFunc("/", staticHandler)
@ -352,6 +357,79 @@ func nodeDelete(w http.ResponseWriter, r *http.Request) {// {{{
"OK": true, "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) {// {{{ func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{
// Append index.html if needed for further reading of the file // 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] == '/' { if p[len(p)-1] == '/' {
p += "index.html" p += "index.html"
} }
p = config.Application.StaticDir + p p = config.Application.Directories.Static + p
base := path.Base(p) base := path.Base(p)
if tmpl, err = template.New(base).ParseFiles(p); err != nil { return } if tmpl, err = template.New(base).ParseFiles(p); err != nil { return }

10
sql/0003.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE public.file (
id serial NOT NULL,
user_id int4 NOT NULL,
filename varchar(256) NOT NULL DEFAULT '<noname>',
"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
);

1
sql/0004.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE file ADD COLUMN md5 CHAR(32) DEFAULT ''

View File

@ -24,8 +24,7 @@ h1 {
display: grid; display: grid;
color: #fff; color: #fff;
} }
#menu-blackout { #blackout {
display: none;
position: absolute; position: absolute;
left: 0px; left: 0px;
right: 0px; right: 0px;
@ -34,9 +33,6 @@ h1 {
z-index: 1024; z-index: 1024;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
} }
#menu-blackout.show {
display: initial;
}
#menu { #menu {
display: none; display: none;
position: absolute; position: absolute;
@ -59,7 +55,7 @@ h1 {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
#menu .item.separator { #menu .item.separator {
border-bottom: 3px solid #000; border-bottom: 2px solid #000;
} }
#menu .item:hover { #menu .item:hover {
background: #ddd; background: #ddd;
@ -67,6 +63,54 @@ h1 {
#menu .item:last-child { #menu .item:last-child {
border-bottom: none; 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 { header {
display: grid; display: grid;
grid-template-columns: 1fr min-content min-content; grid-template-columns: 1fr min-content min-content;

View File

@ -73,7 +73,7 @@ class App extends Component {
} }
}//}}} }//}}}
responseError({comm, app}) {//{{{ responseError({comm, app, upload}) {//{{{
if(comm !== undefined) { if(comm !== undefined) {
comm.text().then(body=>alert(body)) comm.text().then(body=>alert(body))
return return
@ -87,6 +87,11 @@ class App extends Component {
if(app !== undefined) { if(app !== undefined) {
alert(JSON.stringify(app)) alert(JSON.stringify(app))
} }
if(upload !== undefined) {
alert(upload)
return
}
}//}}} }//}}}
async request(url, params) {//{{{ async request(url, params) {//{{{
return new Promise((resolve, reject)=>{ return new Promise((resolve, reject)=>{

View File

@ -10,6 +10,7 @@ export class NodeUI extends Component {
this.tree = signal(null) this.tree = signal(null)
this.node = signal(null) this.node = signal(null)
this.nodeContent = createRef() this.nodeContent = createRef()
this.upload = signal(false)
window.addEventListener('popstate', evt=>{ window.addEventListener('popstate', evt=>{
if(evt.state && evt.state.hasOwnProperty('nodeID')) if(evt.state && evt.state.hasOwnProperty('nodeID'))
this.goToNode(evt.state.nodeID, true) this.goToNode(evt.state.nodeID, true)
@ -49,14 +50,17 @@ export class NodeUI extends Component {
if(this.props.app.nodeModified.value) if(this.props.app.nodeModified.value)
modified = 'modified'; modified = 'modified';
return html` let upload = '';
<div id="menu-blackout" class="${this.menu.value ? 'show' : ''}" onclick=${()=>this.menu.value = false}></div> if(this.upload.value)
<div id="menu" class="${this.menu.value ? 'show' : ''}"> upload = html`<${UploadUI} app=${this} />`
<div class="item" onclick=${()=>this.renameNode()}>Rename</div>
<div class="item separator" onclick=${()=>this.deleteNode()}>Delete</div>
<div class="item" onclick=${()=>this.logout()}>Log out</div>
</div>
let menu = '';
if(this.menu.value)
upload = html`<${Menu} app=${this} />`
return html`
${menu}
${upload}
<header class="${modified}" onclick=${()=>this.saveNode()}> <header class="${modified}" onclick=${()=>this.saveNode()}>
<div class="name">Notes</div> <div class="name">Notes</div>
<div class="add" onclick=${evt=>this.createNode(evt)}>+</div> <div class="add" onclick=${evt=>this.createNode(evt)}>+</div>
@ -87,21 +91,19 @@ export class NodeUI extends Component {
let handled = true let handled = true
switch(evt.key.toUpperCase()) { switch(evt.key.toUpperCase()) {
case 'S': case 'S':
if(!evt.ctrlKey) { if(evt.ctrlKey || (evt.shiftKey && evt.altKey))
handled = false
break
}
this.saveNode() this.saveNode()
break break
case 'N': case 'N':
if(!evt.ctrlKey && !evt.AltKey) { if((evt.ctrlKey && evt.AltKey) || (evt.shiftKey && evt.altKey))
handled = false
break
}
this.createNode() this.createNode()
break break
case 'U':
if((evt.ctrlKey && evt.altKey) || (evt.shiftKey && evt.altKey))
this.upload.value = true
default: default:
handled = false handled = false
} }
@ -135,6 +137,7 @@ export class NodeUI extends Component {
}) })
}//}}} }//}}}
createNode(evt) {//{{{ createNode(evt) {//{{{
if(evt)
evt.stopPropagation() evt.stopPropagation()
let name = prompt("Name") let name = prompt("Name")
if(!name) if(!name)
@ -257,4 +260,105 @@ class Node {
}//}}} }//}}}
} }
class Menu extends Component {
render({ app }) {//{{{
return html`
<div id="blackout" onclick=${()=>app.menu.value = false}></div>
<div id="menu" class="${app.menu.value ? 'show' : ''}">
<div class="item" onclick=${()=>app.renameNode()}>Rename</div>
<div class="item separator" onclick=${()=>app.deleteNode()}>Delete</div>
<div class="item separator" onclick=${()=>{ app.upload.value = true; app.menu.value = false }}>Upload</div>
<div class="item" onclick=${()=>app.logout()}>Log out</div>
</div>
`
}//}}}
}
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`<div key=file_${i} ref=${this.fileRefs[i]} class="file">${filelist.item(i).name}</div><div class="progress" ref=${this.progressRefs[i]}></div>`)
}
return html`
<div id="blackout" onclick=${()=>nodeui.upload.value = false}></div>
<div id="upload">
<input type="file" ref=${this.file} onchange=${()=>this.upload()} multiple />
<div class="files">
${files}
</div>
</div>
`
}//}}}
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 // vim: foldmethod=marker

View File

@ -30,8 +30,7 @@ h1 {
color: #fff; color: #fff;
} }
#menu-blackout { #blackout {
display: none;
position: absolute; position: absolute;
left: 0px; left: 0px;
right: 0px; right: 0px;
@ -39,10 +38,6 @@ h1 {
bottom: 0px; bottom: 0px;
z-index: 1024; z-index: 1024;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
&.show {
display: initial;
}
} }
#menu { #menu {
@ -68,7 +63,7 @@ h1 {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
&.separator { &.separator {
border-bottom: 3px solid #000; border-bottom: 2px solid #000;
} }
&:hover { &: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 { header {
display: grid; display: grid;
grid-template-columns: 1fr min-content min-content; grid-template-columns: 1fr min-content min-content;