diff --git a/assets/js/editor-assets.js b/assets/js/editor-assets.js index a99c972e..a67735b8 100644 --- a/assets/js/editor-assets.js +++ b/assets/js/editor-assets.js @@ -1,6 +1,92 @@ -// THIS FILE IS SET FOR DELETION +import { progress } from "./uploader.mjs"; + (() => { - "use strict"; + // THIS FILE IS SET FOR DELETION + + //"use strict"; + + progress(`artifact-editor-dl-form`, `artifact-editor-dl-progress`); + + document.body.addEventListener("htmx:beforeRequest", function (event) { + console.log(`before request`, event); + }); + + document.body.addEventListener("htmx:afterRequest", function (event) { + afterRequest( + event, + `artifact-editor-dl-form`, + `artifact-editor-dl-up`, + `artifact-editor-dl-feedback` + ); + }); + + function afterRequest(event, formId, inputName, feedbackName) { + if (event.detail.elt === null) return; + if (event.detail.elt.id !== `${formId}`) return; + const input = document.getElementById(inputName); + if (input === null) { + throw new Error(`The htmx successful input element ${inputName} is null`); + } + const feedback = document.getElementById(feedbackName); + if (feedback === null) { + throw new Error( + `The htmx successful feedback element ${feedbackName} is null` + ); + } + if (event.detail.successful) { + return successful(event, input, feedback); + } + if (event.detail.failed && event.detail.xhr) { + return errorXhr(event, input, feedback); + } + errorBrowser(input, feedback); + } + + function successful(event, input, feedback) { + const xhr = event.detail.xhr; + feedback.innerText = `${xhr.responseText}`; + feedback.classList.remove("invalid-feedback"); + feedback.classList.add("valid-feedback"); + input.classList.remove("is-invalid"); + input.classList.add("is-valid"); + } + + function errorXhr(event, input, feedback) { + const xhr = event.detail.xhr; + feedback.innerText = `Something on the server is not working, ${xhr.status} status: ${xhr.responseText}.`; + feedback.classList.remove("valid-feedback"); + feedback.classList.add("invalid-feedback"); + input.classList.remove("is-valid"); + input.classList.add("is-invalid"); + } + + /** + * Displays an error message usually caused by the browser. + * @param {HTMLElement} alertElm - The alert element where the error message will be displayed. + */ + function errorBrowser(input, feedback) { + input.classList.remove("is-valid"); + input.classList.add("is-invalid"); + feedback.innerText = + "Something with the browser is not working, please try again or refresh the page."; + feedback.classList.remove("d-none"); + } + + const reset = document.getElementById(`artifact-editor-dl-reset`); + if (reset == null) { + console.error(`the reset button is missing`); + return; + } + const artifact = document.getElementById(`artifact-editor-dl-up`); + if (artifact == null) { + console.error(`the artifact file input is missing`); + return; + } + reset.addEventListener(`click`, function () { + artifact.value = ``; + artifact.classList.remove(`is-invalid`); + artifact.classList.remove(`is-valid`); + }); //alert(`editor assets script is running`); diff --git a/docs/todo.md b/docs/todo.md index fd118319..a096b89a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,6 +15,7 @@ - [ ] Render HTML in an iframe instead of readme? Example, http://localhost:1323/f/ad3075 - [ ] Handle magazines title on artifact page, http://localhost:1323/f/a55ed, this is hard to read, "Issue 4\nThe Pirate Syndicate +\nThe Pirate World" - [ ] If artifact is a text file displayed in readme, then delete image preview, these are often super long, large and not needed. +- [ ] If a #hash is appended to a /f/ URL while signed out, then return a 404 or a redirect to the sign in page. Post signing should return to the #hash URL? - [ ] - http://www.platohistory.org/ - [ ] - https://portcommodore.com/dokuwiki/doku.php?id=larry:comp:bbs:about_cbbs diff --git a/handler/app/context.go b/handler/app/context.go index 9996d127..a5d67bae 100644 --- a/handler/app/context.go +++ b/handler/app/context.go @@ -972,16 +972,6 @@ func PlatformTagInfo(c echo.Context) error { return c.String(http.StatusOK, info) } -// PostIntro handles the POST request for the intro upload form. -func PostIntro(c echo.Context) error { - const name = "post intro" - x, err := c.FormParams() - if err != nil { - return InternalErr(c, name, err) - } - return c.JSON(http.StatusOK, x) -} - // PostDesc is the handler for the Search for file descriptions form post page. func PostDesc(c echo.Context, input string) error { const name = "artifacts" diff --git a/handler/htmx/transfer.go b/handler/htmx/transfer.go index 448b2947..27392ca8 100644 --- a/handler/htmx/transfer.go +++ b/handler/htmx/transfer.go @@ -195,7 +195,7 @@ func transfer(c echo.Context, logger *zap.SugaredLogger, key, downloadDir string } id, uid, err := creator.insert(ctx, c, tx, logger) if err != nil { - return fmt.Errorf("creator.insert: %w", err) + return c.HTML(http.StatusInternalServerError, err.Error()) } else if id == 0 { return nil } @@ -242,6 +242,8 @@ func Duplicate(logger *zap.SugaredLogger, uid uuid.UUID, srcPath, dstDir string) logger.Infof("Uploader copied %d bytes for %s, to the destination dir", i, uid.String()) } +// checkDest validates the destination directory for the chosen file upload, +// and confirms that the directory exists and is writable. func checkDest(dest string) (string, error) { st, err := os.Stat(dest) if err != nil { @@ -347,6 +349,11 @@ type creator struct { content []string } +var ( + ErrForm = errors.New("form parameters could not be read") + ErrInsert = errors.New("form submission could not be inserted into the database") +) + func (cr creator) insert(ctx context.Context, c echo.Context, tx *sql.Tx, logger *zap.SugaredLogger, ) (int64, uuid.UUID, error) { noID := uuid.UUID{} @@ -355,8 +362,7 @@ func (cr creator) insert(ctx context.Context, c echo.Context, tx *sql.Tx, logger if logger != nil { logger.Error(err) } - return 0, noID, c.HTML(http.StatusInternalServerError, - "The form parameters could not be read") + return 0, noID, ErrForm } values.Add(cr.key+"-filename", cr.file.Filename) values.Add(cr.key+"-integrity", hex.EncodeToString(cr.checksum)) @@ -381,8 +387,7 @@ func (cr creator) insert(ctx context.Context, c echo.Context, tx *sql.Tx, logger if logger != nil { logger.Error(err) } - return 0, noID, c.HTML(http.StatusInternalServerError, - "The form submission could not be inserted") + return 0, noID, ErrInsert } return id, uid, nil } @@ -463,3 +468,115 @@ func sanitizeID(c echo.Context, name, prod string) (int64, error) { } return id, nil } + +// ------------------------------------------------------------ + +// EditorFileUpload is a handler for the /editor/upload/file route. +func EditorFileUpload(c echo.Context, logger *zap.SugaredLogger, downloadDir string) error { + name := "editor-uploadfile" // TODO, rename + return editorTransfer(c, logger, name, downloadDir) +} + +// Transfer is a generic file transfer handler that uploads and validates a chosen file upload. +// The provided name is that of the form input field. The logger is optional and if nil then +// the function will not log any debug information. +func editorTransfer(c echo.Context, logger *zap.SugaredLogger, name, downloadDir string) error { + if s, err := checkDest(downloadDir); err != nil { + return c.HTML(http.StatusInternalServerError, s) + } + unid := c.FormValue("artifact-editor-unid") + if unid == "" { + return c.String(http.StatusBadRequest, + "The editor file upload is missing the unique identifier") + } + key := c.FormValue("artifact-editor-record-key") + if key == "" { + return c.String(http.StatusBadRequest, + "The editor file upload is missing the record key") + } + id, err := strconv.ParseInt(key, 10, 64) + if err != nil { + return c.String(http.StatusBadRequest, + "The editor file upload record key is invalid") + } + + file, err := c.FormFile(name) + if err != nil { + return checkFormFile(c, logger, name, err) + } + src, err := file.Open() + if err != nil { + return checkFileOpen(c, logger, name, err) + } + defer src.Close() + hasher := sha512.New384() + if _, err := io.Copy(hasher, src); err != nil { + return checkHasher(c, logger, name, err) + } + checksum := hasher.Sum(nil) + ctx := context.Background() + db, tx, err := postgres.ConnectTx() + if err != nil { + return c.HTML(http.StatusServiceUnavailable, + "Cannot begin the database transaction") + } + defer db.Close() + // exist, err := model.SHA384Exists(ctx, tx, checksum) + // if err != nil { + // return checkExist(c, logger, err) + // } + // if exist { + // return c.HTML(http.StatusOK, + // "

Thanks, but the chosen file already exists on Defacto2.

"+ + // html.EscapeString(file.Filename)) + // } + dst, err := copier(c, logger, file, key) + if err != nil { + return fmt.Errorf("copier: %w", err) + } + if dst == "" { + return c.HTML(http.StatusInternalServerError, + "The temporary save cannot be created") + } + content := "" + if list, err := archive.List(dst, file.Filename); err == nil { + content = strings.Join(list, "\n") + } + // readme := archive.Readme(file.Filename, content...) + + fu := model.FileUpload{ + Filename: file.Filename, + Integrity: hex.EncodeToString(checksum), + Content: content, + Filesize: file.Size, + } + if err := fu.Update(ctx, tx, id); err != nil { + if logger != nil { + logger.Error(err) + } + return ErrInsert // TODO: replace + } + + downloadFile := filepath.Join(downloadDir, unid) + _, err = helper.DuplicateOW(dst, downloadFile) + if err != nil { + tx.Rollback() + logger.Errorf("htmx transfer duplicate file: %w,%q, %s", + err, unid, downloadFile) + return err + } + if err := tx.Commit(); err != nil { + return c.HTML(http.StatusInternalServerError, "The database commit failed") + } + + // defer Duplicate(uid, dst, downloadDir) + // func Duplicate(uid uuid.UUID, srcPath, dstDir string) { + // newPath := filepath.Join(dstDir, uid.String()) + // i, err := helper.Duplicate(srcPath, newPath) + + // return success(c, file.Filename, id) + + c.Response().Header().Set("HX-Refresh", "false") + return c.String(http.StatusOK, + fmt.Sprintf("The file %s was uploaded, about to reload the page", file.Filename)) +} diff --git a/handler/router.go b/handler/router.go index 6f934711..4eb52a69 100644 --- a/handler/router.go +++ b/handler/router.go @@ -49,7 +49,6 @@ func (c Configuration) FilesRoutes(e *echo.Echo, logger *zap.SugaredLogger, publ e = c.custom404(e) e = c.debugInfo(e) e = c.static(e) - e = c.uploader(e) e = c.html(e, public) e = c.font(e, public) e = c.embed(e, public) @@ -300,17 +299,6 @@ func (c Configuration) search(e *echo.Echo, logger *zap.SugaredLogger) *echo.Ech return e } -// uploader for anonymous client uploads. -func (c Configuration) uploader(e *echo.Echo) *echo.Echo { - if e == nil { - panic(fmt.Errorf("%w for uploader router", ErrRoutes)) - } - uploader := e.Group("/uploader") - uploader.Use(c.ReadOnlyLock) - uploader.GET("", app.PostIntro) - return e -} - // signin for operators. func (c Configuration) signin(e *echo.Echo, nonce string) *echo.Echo { if e == nil { diff --git a/handler/routerlock.go b/handler/routerlock.go index 600e997f..d11a53b7 100644 --- a/handler/routerlock.go +++ b/handler/routerlock.go @@ -132,6 +132,13 @@ func editor(g *echo.Group, logger *zap.SugaredLogger, dir app.Dirs) { emu.PATCH("/umb/:id", htmx.RecordEmulateUMB) emu.PATCH("/ems/:id", htmx.RecordEmulateEMS) emu.PATCH("/xms/:id", htmx.RecordEmulateXMS) + + // these POSTs should only be used for editor, htmx file uploads, + // and not for general file uploads or data edits. + upload := g.Group("/upload") + upload.POST("/file", func(c echo.Context) error { + return htmx.EditorFileUpload(c, logger, dir.Download) + }) } func get(g *echo.Group, dir app.Dirs) { diff --git a/internal/helper/os.go b/internal/helper/os.go index a0b4104b..d2f00ab6 100644 --- a/internal/helper/os.go +++ b/internal/helper/os.go @@ -91,15 +91,29 @@ func Count(dir string) (int, error) { // Duplicate is a workaround for renaming files across different devices. // A cross device can also be a different file system such as a Docker volume. // It returns the number of bytes written to the new file. +// The function returns an error if the newpath already exists. func Duplicate(oldpath, newpath string) (int64, error) { + const createNoTruncate = os.O_CREATE | os.O_WRONLY | os.O_EXCL + return duplicate(oldpath, newpath, createNoTruncate) +} + +// DuplicateOW is a workaround for renaming files across different devices. +// A cross device can also be a different file system such as a Docker volume. +// It returns the number of bytes written to the new file. +// The function will truncate and overwrite the newpath if it already exists. +func DuplicateOW(oldpath, newpath string) (int64, error) { + const createTruncate = os.O_CREATE | os.O_WRONLY | os.O_TRUNC + return duplicate(oldpath, newpath, createTruncate) +} + +func duplicate(oldpath, newpath string, flag int) (int64, error) { src, err := os.Open(oldpath) if err != nil { return 0, fmt.Errorf("duplicate os.open %w", err) } defer src.Close() - const createNoTruncate = os.O_CREATE | os.O_WRONLY | os.O_EXCL - dst, err := os.OpenFile(newpath, createNoTruncate, WriteWriteRead) + dst, err := os.OpenFile(newpath, flag, WriteWriteRead) if err != nil { return 0, fmt.Errorf("duplicate os.create %w", err) } diff --git a/model/update.go b/model/update.go index 484fc190..4e50c037 100644 --- a/model/update.go +++ b/model/update.go @@ -356,6 +356,7 @@ const ( creText filename github + integrity platform relations section @@ -363,6 +364,7 @@ const ( title virusTotal youtube + zipContent ) // UpdateStringFrom updates the column string from value with val. @@ -409,6 +411,8 @@ func updateStringCases(f *models.File, column stringFrom, val string) error { f.Filename = s case github: f.WebIDGithub = s + case integrity: + f.FileIntegrityStrong = s case platform: f.Platform = s case relations: @@ -423,6 +427,8 @@ func updateStringCases(f *models.File, column stringFrom, val string) error { f.FileSecurityAlertURL = s case youtube: f.WebIDYoutube = s + case zipContent: + f.FileZipContent = s default: return ErrColumn } @@ -690,3 +696,34 @@ func UpdateYMD(ctx context.Context, exec boil.ContextExecutor, id int64, y, m, d } return nil } + +type FileUpload struct { + Filename string + Integrity string + Content string + Filesize int64 +} + +func (fu FileUpload) Update(ctx context.Context, exec boil.ContextExecutor, id int64) error { + if id <= 0 { + return fmt.Errorf("updateuploadfile id value %w: %d", ErrKey, id) + } + f, err := OneFile(ctx, exec, id) + if err != nil { + return fmt.Errorf("updateuploadfile one file %w: %d", err, id) + } + if err = updateStringCases(f, filename, fu.Filename); err != nil { + return fmt.Errorf("updatestringfrom: %w", err) + } + if err = updateStringCases(f, integrity, fu.Integrity); err != nil { + return fmt.Errorf("updatestringfrom: %w", err) + } + if err = updateStringCases(f, zipContent, fu.Content); err != nil { + return fmt.Errorf("updatestringfrom: %w", err) + } + f.Filesize = null.Int64From(fu.Filesize) + if _, err = f.Update(ctx, exec, boil.Infer()); err != nil { + return fmt.Errorf("updateuploadfile update %w: %d", err, id) + } + return nil +} diff --git a/public/js/editor-assets.min.js b/public/js/editor-assets.min.js index a4ab7fbc..f0acd6f0 100644 --- a/public/js/editor-assets.min.js +++ b/public/js/editor-assets.min.js @@ -1,2 +1,2 @@ /* editor-assets.min.js © Defacto2 2024 */ -(()=>{"use strict";const e=document.getElementById("artifact-editor-modal");if(e==null){console.error("the data editor modal is missing");return}const t=document.getElementById("asset-editor-modal");if(t==null){console.error("the asset editor modal is missing");return}const o=document.getElementById("emulate-editor-modal");if(o==null){console.error("the emulate editor modal is missing");return}const a=new bootstrap.Modal(e),s=new bootstrap.Modal(t),r=new bootstrap.Modal(o);switch(new URL(window.location.href).hash){case"#data-editor":a.show();break;case"#file-editor":s.show();break;case"#emulate-editor":r.show();break;default:}})(); +(()=>{var E=100,T=1024*1024,M=100*T;function m(n,i){if(n==null)throw new Error("The formId value of progress is null.");if(i==null)throw new Error("The elementId value of progress is null.");htmx.on(`#${n}`,"htmx:xhr:progress",function(s){s.target.id==`${n}`&&htmx.find(`#${i}`).setAttribute("value",s.detail.loaded/s.detail.total*E)})}(()=>{m("artifact-editor-dl-form","artifact-editor-dl-progress"),document.body.addEventListener("htmx:beforeRequest",function(e){console.log("before request",e)}),document.body.addEventListener("htmx:afterRequest",function(e){n(e,"artifact-editor-dl-form","artifact-editor-dl-up","artifact-editor-dl-feedback")});function n(e,t,r,o){if(e.detail.elt===null||e.detail.elt.id!==`${t}`)return;let a=document.getElementById(r);if(a===null)throw new Error(`The htmx successful input element ${r} is null`);let u=document.getElementById(o);if(u===null)throw new Error(`The htmx successful feedback element ${o} is null`);if(e.detail.successful)return i(e,a,u);if(e.detail.failed&&e.detail.xhr)return s(e,a,u);w(a,u)}function i(e,t,r){let o=e.detail.xhr;r.innerText=`${o.responseText}`,r.classList.remove("invalid-feedback"),r.classList.add("valid-feedback"),t.classList.remove("is-invalid"),t.classList.add("is-valid")}function s(e,t,r){let o=e.detail.xhr;r.innerText=`Something on the server is not working, ${o.status} status: ${o.responseText}.`,r.classList.remove("valid-feedback"),r.classList.add("invalid-feedback"),t.classList.remove("is-valid"),t.classList.add("is-invalid")}function w(e,t){e.classList.remove("is-valid"),e.classList.add("is-invalid"),t.innerText="Something with the browser is not working, please try again or refresh the page.",t.classList.remove("d-none")}let c=document.getElementById("artifact-editor-dl-reset");if(c==null){console.error("the reset button is missing");return}let l=document.getElementById("artifact-editor-dl-up");if(l==null){console.error("the artifact file input is missing");return}c.addEventListener("click",function(){l.value="",l.classList.remove("is-invalid"),l.classList.remove("is-valid")});let d=document.getElementById("artifact-editor-modal");if(d==null){console.error("the data editor modal is missing");return}let f=document.getElementById("asset-editor-modal");if(f==null){console.error("the asset editor modal is missing");return}let h=document.getElementById("emulate-editor-modal");if(h==null){console.error("the emulate editor modal is missing");return}let p=new bootstrap.Modal(d),v=new bootstrap.Modal(f),x=new bootstrap.Modal(h);switch(new URL(window.location.href).hash){case"#data-editor":p.show();break;case"#file-editor":v.show();break;case"#emulate-editor":x.show();break;default:}})();})(); diff --git a/runner/runner.go b/runner/runner.go index 8b5624f8..857220b0 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -28,7 +28,7 @@ func NamedCSS() []string { // The files are located in the assets/js directory. func NamedJS() []string { return []string{ - "editor-assets", + // "editor-assets", "editor-archive", "editor-forapproval", "htmx-response-targets", @@ -75,6 +75,26 @@ func JS(name string) api.BuildOptions { } } +// editor-assets +func EditorAssetsTODO() api.BuildOptions { + min := "editor-assets.min.js" + entryjs := filepath.Join("assets", "js", "editor-assets.js") + output := filepath.Join("public", "js", min) + return api.BuildOptions{ + EntryPoints: []string{entryjs}, + Outfile: output, + Target: ECMAScript, + Write: true, + Bundle: true, + MinifyWhitespace: true, + MinifyIdentifiers: true, + MinifySyntax: true, + Banner: map[string]string{ + "js": fmt.Sprintf("/* %s %s %s */", min, C, time.Now().Format("2006")), + }, + } +} + func Artifact() api.BuildOptions { min := "artifact-editor.min.js" entryjs := filepath.Join("assets", "js", "artifact-editor.js") @@ -180,6 +200,12 @@ func main() { fmt.Fprintf(os.Stderr, "JS build failed: %v\n", result.Errors) } } + { + result := api.Build(EditorAssetsTODO()) + if len(result.Errors) > 0 { + fmt.Fprintf(os.Stderr, "JS build failed: %v\n", result.Errors) + } + } { result := api.Build(Artifact()) if len(result.Errors) > 0 { diff --git a/view/app/artifactfile.tmpl b/view/app/artifactfile.tmpl index 8038495f..b192cf40 100644 --- a/view/app/artifactfile.tmpl +++ b/view/app/artifactfile.tmpl @@ -1,16 +1,5 @@ {{- /* artifactfile.tmpl - - To toggle the visibility of the asset editor modal using a URL link, - create a link with a hash reference to the modal ID. Then match the hash value - on a JS onload event to trigger the modal to open. - - const myModal = document.getElementById('myModal') - const myInput = document.getElementById('myInput') - - myModal.addEventListener('shown.bs.modal', () => { - myInput.focus() - }) */ -}} {{- define "artifactfile" }} {{- if eq false (index . "editor")}}{{/* render nothing */}}{{else}} @@ -69,7 +58,7 @@ + aria-describedby="artifact-editor-key-label" autocomplete="off" readonly>
@@ -77,7 +66,7 @@ + aria-describedby="artifact-editor-unique-id-label" id="artifact-editor-unique-id-value" autocomplete="off" readonly>
@@ -87,35 +76,35 @@ Artifact for download
- +
- +
- - + +
+ data-bs-toggle="tooltip" data-bs-title="On file system" autocomplete="off" readonly> + data-bs-toggle="tooltip" data-bs-title="Stored in the database" autocomplete="off" readonly>
+ data-bs-toggle="tooltip" data-bs-title="Guessed type of file" autocomplete="off" readonly> + data-bs-toggle="tooltip" data-bs-title="Guessed MIME type" autocomplete="off" readonly>
+ value="{{classificationStr $tag $os}}" autocomplete="off" readonly>
{{- if eq $notFound $statSizeB}} @@ -123,33 +112,30 @@ {{- end}} {{- /* Replacement download input */}} -
- - - -
- -
- - - -
- You must agree before submitting. -
-
- - - {{- if eq $notFound $statSizeB}} -
Upload a new file to use as the artifact download.
- {{- else}} -
Normally not required, upload and replace the artifact download.
- {{- end}} + {{/* TODO: confirm replacement if file already exists */}} +
+ + + {{/* artifact-editor-unique-id-value */}} +
+ + + +
+
+ {{- if eq $notFound $statSizeB}} +
Upload a new file to use as the artifact download.
+ {{- else}} +
Normally not required, upload and replace the artifact download.
+ {{- end}} + + {{/*
The new file upload didn't work, .
*/}} +
{{- /* Download content */}} Download content {{- if eq $notFound $statSizeB}} diff --git a/view/app/layout.tmpl b/view/app/layout.tmpl index aadd1935..6f3b525e 100644 --- a/view/app/layout.tmpl +++ b/view/app/layout.tmpl @@ -317,7 +317,7 @@ {{- /* Do not async load the htmx JS */}} - + {{- /* Do not defer, or async load the Bootstrap 5.x JS */}} diff --git a/view/app/layoutjs.tmpl b/view/app/layoutjs.tmpl index 31e91d55..29daea8c 100644 --- a/view/app/layoutjs.tmpl +++ b/view/app/layoutjs.tmpl @@ -7,11 +7,11 @@ {{- $forApproval := index . "forApproval"}} {{- if and (eq true $forApproval) (eq true $editor)}} {{- /* TODO: replace with htmx? */}} - + {{- end}} {{- if and (eq false $readonly) (eq true $editor)}} - - - + + + {{- end}} {{- end}} \ No newline at end of file