diff --git a/.github/workflows/e2e-partial.yaml b/.github/workflows/e2e-partial.yaml index ec0e8a24c..264cc553a 100644 --- a/.github/workflows/e2e-partial.yaml +++ b/.github/workflows/e2e-partial.yaml @@ -14,6 +14,10 @@ jobs: playwright-tests: timeout-minutes: 60 strategy: + # Don't cancel sibling shards when one fails — every shard's blob report + # still needs to reach the merge-reports job so the final HTML summary + # reflects the full suite. + fail-fast: false matrix: shardIndex: [1,2,3,4] shardTotal: [4] diff --git a/Taskfile.yml b/Taskfile.yml index f7db405f7..8828ea3b9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -88,6 +88,7 @@ tasks: go:ci: env: HBOX_DEMO: true + HBOX_LOG_LEVEL: fatal desc: Runs all go test and lint related tasks dir: backend cmds: diff --git a/backend/app/api/handlers/v1/v1_ctrl_qrcode.go b/backend/app/api/handlers/v1/v1_ctrl_qrcode.go index 333f8d004..00ee961be 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_qrcode.go +++ b/backend/app/api/handlers/v1/v1_ctrl_qrcode.go @@ -54,19 +54,27 @@ func (ctrl *V1Controller) HandleGenerateQRCode() errchain.HandlerFunc { return err } - toWriteCloser := struct { + // Render into a buffer first so we don't touch `w` until we know the + // image is complete. Writing partial bytes and then returning an error + // causes the Errors middleware to call WriteHeader a second time, which + // produces "superfluous response.WriteHeader" log spam (common when the + // label-generator page renders many tags and the client cancels + // slow loads). + var buf bytes.Buffer + qrwriter := standard.NewWithWriter(struct { io.Writer io.Closer - }{ - Writer: w, - Closer: io.NopCloser(nil), - } + }{Writer: &buf, Closer: io.NopCloser(nil)}, standard.WithLogoImage(image)) - qrwriter := standard.NewWithWriter(toWriteCloser, standard.WithLogoImage(image)) + if err := qrc.Save(qrwriter); err != nil { + return err + } - // Return the QR code as a jpeg image w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Content-Disposition", "attachment; filename=qrcode.jpg") - return qrc.Save(qrwriter) + // Ignore write errors — if the client disconnected, there's nothing to + // report back, and returning the error would trigger a superfluous 500. + _, _ = w.Write(buf.Bytes()) + return nil } } diff --git a/backend/app/api/handlers/v1/v1_ctrl_user.go b/backend/app/api/handlers/v1/v1_ctrl_user.go index d7d3e8500..f25645f67 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_user.go +++ b/backend/app/api/handlers/v1/v1_ctrl_user.go @@ -110,7 +110,7 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() errchain.HandlerFunc { func (ctrl *V1Controller) HandleUserSelfDelete() errchain.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { if ctrl.isDemo { - return validate.NewRequestError(nil, http.StatusForbidden) + return validate.NewRequestError(fmt.Errorf("account deletion is disabled in demo mode"), http.StatusForbidden) } actor := services.UseUserCtx(r.Context()) @@ -199,7 +199,7 @@ type ( func (ctrl *V1Controller) HandleUserSelfChangePassword() errchain.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { if ctrl.isDemo { - return validate.NewRequestError(nil, http.StatusForbidden) + return validate.NewRequestError(fmt.Errorf("password change is disabled in demo mode"), http.StatusForbidden) } var cp ChangePassword diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 5ad36ff84..62e8e28a1 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -242,7 +242,7 @@ func run(cfg *config.Config) error { middleware.RequestID, middleware.RealIP, mid.Logger(logger), - mid.SecurityHeaders(), + mid.SecurityHeaders(cfg.Demo), // Restrict the max body size to the upload limit + 1MB (for overhead) mid.MaxBodySize(cfg.Web.MaxUploadSize+1), middleware.Recoverer, diff --git a/backend/internal/sys/validate/errors.go b/backend/internal/sys/validate/errors.go index 09fdf2cc0..7dc21546f 100644 --- a/backend/internal/sys/validate/errors.go +++ b/backend/internal/sys/validate/errors.go @@ -3,6 +3,7 @@ package validate import ( "encoding/json" "errors" + "net/http" ) type UnauthorizedError struct { @@ -59,6 +60,12 @@ func NewRequestError(err error, status int) error { } func (err *RequestError) Error() string { + if err.Err == nil { + if err.Status != 0 { + return http.StatusText(err.Status) + } + return "request error" + } return err.Err.Error() } diff --git a/backend/internal/web/mid/security.go b/backend/internal/web/mid/security.go index 0f196f5c4..9d51dc69d 100644 --- a/backend/internal/web/mid/security.go +++ b/backend/internal/web/mid/security.go @@ -7,13 +7,23 @@ import ( // SecurityHeaders is a middleware that will set security headers on the response // It includes recommended headers from OWASP that are safe for self-hosted applications. // Reference: https://owasp.org/www-project-secure-headers/ -func SecurityHeaders() func(http.Handler) http.Handler { +// +// In demo mode the clipboard-read directive is relaxed to `self` so E2E tests +// (and anyone interacting with the public demo) can read the clipboard for +// copy/paste flows. Production-style deployments keep clipboard-read disabled. +func SecurityHeaders(demo bool) func(http.Handler) http.Handler { + clipboardRead := "clipboard-read=()" + if demo { + clipboardRead = "clipboard-read=(self)" + } + permissionsPolicy := "accelerometer=(), autoplay=(), camera=(self), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), " + clipboardRead + ", clipboard-write=(self), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()" + return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Origin-Embedder-Policy", "require-corp") w.Header().Set("Content-Origin-Opener-Policy", "same-origin") w.Header().Set("Content-Origin-Resource-Policy", "same-site") - w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(self), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(self), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()") + w.Header().Set("Permissions-Policy", permissionsPolicy) w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index ee583bbde..8255bd891 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -962,10 +962,6 @@ width: 100%; overflow-x: hidden; } - body { - -webkit-user-select: none; - -webkit-touch-callout: none; - } } .text-no-transform { diff --git a/frontend/components/Form/Checkbox.vue b/frontend/components/Form/Checkbox.vue index 11236348a..3b6e3a7fb 100644 --- a/frontend/components/Form/Checkbox.vue +++ b/frontend/components/Form/Checkbox.vue @@ -1,15 +1,15 @@ @@ -30,6 +30,10 @@ type: String, default: "", }, + ariaLabel: { + type: String, + default: "", + }, }); const value = useVModel(props, "modelValue"); diff --git a/frontend/components/Form/DatePicker.vue b/frontend/components/Form/DatePicker.vue index c4088ec94..14cfac600 100644 --- a/frontend/components/Form/DatePicker.vue +++ b/frontend/components/Form/DatePicker.vue @@ -1,11 +1,25 @@ @@ -37,6 +51,11 @@ const isDark = useIsThemeInList(darkThemes); const formatDate = (date: Date | string | number) => fmtDate(date, "human", "date"); + // Explicit text-input parsing format so typed dates commit on Enter/Tab. + // The `format` prop above only controls display; without this config the + // text-input parser can't derive a pattern from our function-valued format + // and silently fails to accept typed input. + const textInputOpts = { format: ["MM/dd/yyyy", "yyyy-MM-dd"], enterSubmit: true, tabSubmit: true }; const selected = computed({ get() { diff --git a/frontend/components/Item/CreateModal.vue b/frontend/components/Item/CreateModal.vue index 8779b8784..c470956f0 100644 --- a/frontend/components/Item/CreateModal.vue +++ b/frontend/components/Item/CreateModal.vue @@ -291,7 +291,12 @@ import BaseModal from "@/components/App/CreateModal.vue"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; - import type { EntityCreate, EntityTemplateOut, EntityTemplateSummary, EntityOut } from "~~/lib/api/types/data-contracts"; + import type { + EntityCreate, + EntityTemplateOut, + EntityTemplateSummary, + EntityOut, + } from "~~/lib/api/types/data-contracts"; import { useTagStore } from "~/stores/tags"; import { useLocationStore } from "~~/stores/locations"; import MdiBarcode from "~icons/mdi/barcode"; @@ -390,7 +395,11 @@ if (et?.defaultTemplateId && et.defaultTemplate) { const { data, error } = await api.templates.get(et.defaultTemplateId); if (!error && data) { - selectedTemplate.value = { id: data.id, name: data.name, description: data.description } as EntityTemplateSummary; + selectedTemplate.value = { + id: data.id, + name: data.name, + description: data.description, + } as EntityTemplateSummary; templateData.value = data; form.quantity = data.defaultQuantity; if (data.defaultName) form.name = data.defaultName; @@ -657,7 +666,7 @@ } else { // Normal item creation without template const out: EntityCreate = { - parentId: form.parentId || form.location.id as string, + parentId: form.parentId || (form.location.id as string), name: form.name, quantity: form.quantity, description: form.description, diff --git a/frontend/components/Location/CreateModal.vue b/frontend/components/Location/CreateModal.vue index 503ce5952..fe8e30cfc 100644 --- a/frontend/components/Location/CreateModal.vue +++ b/frontend/components/Location/CreateModal.vue @@ -67,11 +67,7 @@
@@ -142,8 +138,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import BaseModal from "@/components/App/CreateModal.vue"; - import type { EntityTypeSummary } from "~~/lib/api/types/data-contracts"; - import type { EntitySummary } from "~~/lib/api/types/data-contracts"; + import type { EntityTypeSummary, EntitySummary } from "~~/lib/api/types/data-contracts"; import { AttachmentTypes } from "~~/lib/api/types/non-generated"; import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider"; import { useTagStore } from "~/stores/tags"; diff --git a/frontend/components/Location/Tree/Node.vue b/frontend/components/Location/Tree/Node.vue index 5282de397..baef86902 100644 --- a/frontend/components/Location/Tree/Node.vue +++ b/frontend/components/Location/Tree/Node.vue @@ -55,12 +55,13 @@