diff --git a/file.go b/file.go index 14a01d7..e070d9f 100644 --- a/file.go +++ b/file.go @@ -12,13 +12,14 @@ import ( ) type File struct { - ID int - UUID string - UserID int `db:"user_id"` - Filename string - Size int - MIME string - Modified time.Time + ID int + UUID string + UserID int `db:"user_id"` + Name string `db:"name"` + Size int + Type string `db:"type"` + Modified time.Time + UploadComplete bool `db:"upload_complete"` } var ( @@ -41,9 +42,9 @@ func AddFileToDb(userID int, file *File) (err error) { // {{{ `, file.UserID, file.UUID, - file.Filename, + file.Name, file.Size, - file.MIME, + file.Type, file.Modified, ) if err != nil { @@ -94,7 +95,7 @@ func FileIDToPath(id int) (string, string) { // {{{ return dir, fpath } // }}} -func FileWriteData(userID int, dbID int, data []byte, create bool) (err error) {// {{{ +func FileWriteData(userID int, dbID int, data []byte, create bool) (err error) { // {{{ dir, fpath := FileIDToPath(dbID) err = os.MkdirAll(dir, 0700) if err != nil { @@ -121,6 +122,25 @@ func FileWriteData(userID int, dbID int, data []byte, create bool) (err error) { } return -}// }}} +} // }}} + +func FileMetadata(userID int, uuid string) (f File, err error) { // {{{ + row := db.QueryRowx(`SELECT * FROM public.file WHERE user_id = $1 AND "uuid" = $2`, userID, uuid) + err = row.StructScan(&f) + return + +} // }}} +func FileForUser(userID int, uuid string) (f *os.File, md File, err error) { // {{{ + row := db.QueryRowx(`SELECT * FROM public.file WHERE user_id = $1 AND "uuid" = $2`, userID, uuid) + err = row.StructScan(&md) + if err != nil { + return + } + + _, fpath := FileIDToPath(md.ID) + f, err = os.OpenFile(fpath, os.O_RDONLY, 0600) + return + +} // }}} // vim: foldmethod=marker diff --git a/main.go b/main.go index 0a10092..a30affd 100644 --- a/main.go +++ b/main.go @@ -148,6 +148,9 @@ func main() { // {{{ http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve)) http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount)) + http.HandleFunc("/file/{uuid}", authenticated(actionFile)) + http.HandleFunc("/file/{uuid}/metadata", authenticated(actionFileMetadata)) + http.HandleFunc("/service_worker.js", pageServiceWorker) listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port) @@ -168,6 +171,11 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon // The Bearer token is extracted. authHeader := r.Header.Get("Authorization") + + if authHeader == "" { + authHeader = "Bearer " + r.URL.Query().Get("jwt") + } + authParts := RxpBearerToken.FindStringSubmatch(authHeader) if len(authParts) != 2 { failed(fmt.Errorf("Authorization missing or invalid")) @@ -404,9 +412,9 @@ func actionSyncFileUpload(w http.ResponseWriter, r *http.Request) { // {{{ file := File{} if sentBytes == 0 { file.UUID = uuid - file.Filename = r.Header.Get("X-File-Name") + file.Name = r.Header.Get("X-File-Name") file.Size, _ = strconv.Atoi(r.Header.Get("X-File-Size")) - file.MIME = r.Header.Get("X-File-Type") + file.Type = r.Header.Get("X-File-Type") modded, _ := strconv.ParseInt(r.Header.Get("X-File-Modified"), 10, 64) file.Modified = time.UnixMilli(modded) err := AddFileToDb(session.UserID, &file) @@ -492,6 +500,36 @@ func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} +func actionFile(w http.ResponseWriter, r *http.Request) {// {{{ + uuid := r.PathValue("uuid") + Log.Info("FOO", "uuid", uuid) + session := getUserSession(r) + f, md, err := FileForUser(session.UserID, uuid) + if err != nil { + Log.Error("file_metadata", "error", err) + httpError(w, err) + return + } + + http.ServeContent(w, r, md.Name, md.Modified, f) +}// }}} +func actionFileMetadata(w http.ResponseWriter, r *http.Request) {// {{{ + uuid := r.PathValue("uuid") + session := getUserSession(r) + md, err := FileMetadata(session.UserID, uuid) + + if err != nil { + Log.Error("file_metadata", "error", err) + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Metadata": md, + }) +}// }}} + func createNewUser(username string) { // {{{ reader := bufio.NewReader(os.Stdin) diff --git a/static/js/file.mjs b/static/js/file.mjs index 47e79d6..cb63f56 100644 --- a/static/js/file.mjs +++ b/static/js/file.mjs @@ -1,4 +1,5 @@ import { CustomHTMLElement } from "./lib/custom_html_element.mjs"; +import { API } from 'api' export class N2File extends CustomHTMLElement { static {// {{{ @@ -34,6 +35,9 @@ export class N2File extends CustomHTMLElement { constructor() {// {{{ super(true) + this.file = null + this.uuid = null + this.addEventListener('click', event => { event.preventDefault() event.stopPropagation() @@ -45,16 +49,34 @@ export class N2File extends CustomHTMLElement { } // Open in browser. - window.open( - URL.createObjectURL(this.file), - event.ctrlKey ? '_blank' : '_self', - ) + this.openInBrowser(event) }) this.render() }// }}} + openInBrowser(event) { + // this.file is only defined when file is stored in IndexedDB. + if (this.file) + window.open( + URL.createObjectURL(this.file), + event.ctrlKey ? '_blank' : '_self', + ) + else { + const jwt = localStorage.getItem('token') + window.open( + + `/file/${this.uuid}?jwt=${jwt}`, + event.ctrlKey ? '_blank' : '_self', + ) + } + } async downloadToDisk() {// {{{ try { + // this.file is only defined when file is stored in IndexedDB. + // TODO: implement download of remote content. + if (this.file === null) + throw new Error('This file is only available online. Download not implemented yet.') + const handle = await window.showSaveFilePicker({ suggestedName: this.file.name, }) @@ -78,26 +100,61 @@ export class N2File extends CustomHTMLElement { // while Marked lib has to be returned a string when exiting this function. // populateImg makes sure this returned img element exists and then populates it // with the image from IndexedDB. - const file = await globalThis.nodeStore.files.get(src.slice(5)) - if (!file) - return - this.file = file.file + const fileUUID = src.slice(5) + this.uuid = fileUUID + const file = await globalThis.nodeStore.files.get(fileUUID) - if (file.file.type.startsWith('image/')) - this.elImage.src = URL.createObjectURL(file.file) - else { - // Check for and use an existing MIME type icon. - // Place them in static/images/file_icons/ and replace the slash with an underscore. - const url = `/images/${_VERSION}/file_icons/${file.file.type.replaceAll('/', '_')}.svg` - const res = await fetch(url) - if (res.ok) - this.elImage.src = url - - this.elFilename.innerText = file.file.name - this.elFilename.style.display = 'block' + if (file) { + this.renderLocalFile(file) + } else { + /* can be undefined when a node is retrieved but files aren't synced. + * Try to show from server. + * + * Metadata is needed to know whether to display an image or the file representation. */ + try { + const res = await API.query('GET', `/file/${fileUUID}/metadata`) + this.renderRemoteFile(res.Metadata) + } catch (err) { + console.error(err) + } } + } else + // Probably an external URL. this.elImage.src = src }// }}} + async renderLocalFile(file) {// {{{ + this.file = file.file + + if (file.file.type.startsWith('image/')) + this.elImage.src = URL.createObjectURL(file.file) + else { + // Check for and use an existing MIME type icon. + // Place them in static/images/file_icons/ and replace the slash with an underscore. + const url = `/images/${_VERSION}/file_icons/${file.file.type.replaceAll('/', '_')}.svg` + const res = await fetch(url) + if (res.ok) + this.elImage.src = url + + this.elFilename.innerText = file.file.name + this.elFilename.style.display = 'block' + } + }// }}} + async renderRemoteFile(md) {// {{{ + if (md.Type.startsWith('image/')) { + const jwt = localStorage.getItem('token') + this.elImage.src = `/file/${md.UUID}?jwt=${jwt}` + } else { + // Check for and use an existing MIME type icon. + // Place them in static/images/file_icons/ and replace the slash with an underscore. + const url = `/images/${_VERSION}/file_icons/${md.Type.replaceAll('/', '_')}.svg` + const res = await fetch(url) + if (res.ok) + this.elImage.src = url + + this.elFilename.innerText = md.Name + this.elFilename.style.display = 'block' + } + }// }}} } customElements.define('n2-file', N2File) diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index fa65a27..3ff1843 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -597,7 +597,6 @@ class FileNodesLinkStore extends SimpleNodeStore { // Maintains an index to quickly find orphaned files. nodelink.NodeCount = nodelink.Nodes.length - console.log('removing', nodelink) await this.add({data: nodelink}) } }// }}} @@ -620,7 +619,6 @@ class FileNodesLinkStore extends SimpleNodeStore { // Maintains an index to quickly find orphaned files. nodelink.NodeCount = nodelink.Nodes.length - console.log('pushing', nodelink) await this.add({data: nodelink}) } }// }}} diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index eb717fd..1568c92 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -549,7 +549,6 @@ export class Node { if (this.UUID == ROOT_NODE) return - // File links from the original, unmodified content are found to compare to links from the new content. // All files (images) needs to be indexed to nodes in order to track orphaned files. const before = new Set([...this.data.Content.matchAll(dblink)].map(m => m[1])) diff --git a/static/service_worker.js b/static/service_worker.js index 8522b20..8b0ff21 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -11,16 +11,20 @@ const CACHED_ASSETS = [ '/images/{{ .VERSION }}/collapsed.svg', '/images/{{ .VERSION }}/expanded.svg', + '/images/{{ .VERSION }}/icon_back.svg', '/images/{{ .VERSION }}/icon_history.svg', '/images/{{ .VERSION }}/icon_home.svg', '/images/{{ .VERSION }}/icon_markdown_hollow.svg', '/images/{{ .VERSION }}/icon_markdown.svg', + '/images/{{ .VERSION }}/icon_menu.svg', + '/images/{{ .VERSION }}/icon_new_document.svg', '/images/{{ .VERSION }}/icon_refresh.svg', '/images/{{ .VERSION }}/icon_save_disabled.svg', '/images/{{ .VERSION }}/icon_save.svg', '/images/{{ .VERSION }}/icon_search.svg', '/images/{{ .VERSION }}/icon_settings.svg', '/images/{{ .VERSION }}/icon_table.svg', + '/images/{{ .VERSION }}/icon_transfer.svg', '/images/{{ .VERSION }}/leaf.svg', '/images/{{ .VERSION }}/logo_small.svg', '/images/{{ .VERSION }}/logo.svg', @@ -42,6 +46,7 @@ const CACHED_ASSETS = [ '/js/{{ .VERSION }}/notes2.mjs', '/js/{{ .VERSION }}/page_history.mjs', '/js/{{ .VERSION }}/page_node.mjs', + '/js/{{ .VERSION }}/page_preferences.mjs', '/js/{{ .VERSION }}/page_storage.mjs', '/js/{{ .VERSION }}/sidebar.mjs', '/js/{{ .VERSION }}/sync.mjs',