diff --git a/.vscode/settings.json b/.vscode/settings.json index 632384f7..96522090 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ }, "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js", + "package.json": "package-lock.json, yarn.lock, eslint.config.mjs, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js", "docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml", "README.md": "LICENSE, SECURITY.md" }, @@ -22,6 +22,7 @@ "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "eslint.format.enable": true, + "eslint.useFlatConfig": true, "css.validate": false, "tailwindCSS.includeLanguages": { "vue": "html", diff --git a/Taskfile.yml b/Taskfile.yml index f6c8fc5d..e2da964d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -93,6 +93,16 @@ tasks: - go run ./app/api/ {{ .CLI_ARGS }} & silent: true + go:ci:with-frontend: + desc: Run backend with frontend in CI mode + dir: frontend + cmds: + - pnpm install + - pnpm run build + - cp -r ./.output/public ../backend/app/api/static/ + - task: go:ci + silent: true + go:test: desc: Runs all go tests using gotestsum - supports passing gotestsum args dir: backend @@ -201,12 +211,11 @@ tasks: desc: Runs end-to-end test on a live server dir: frontend cmds: - - task: go:ci - - task: ui:ci + - task: go:ci:with-frontend - pnpm exec playwright install-deps - pnpm exec playwright install - sleep 30 - - TEST_SHUTDOWN_API_SERVER=true pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }} + - TEST_SHUTDOWN_API_SERVER=true E2E_BASE_URL=http://localhost:7745 pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }} pr: desc: Runs all tasks required for a PR diff --git a/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go b/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go index a461d6e8..a8bc571d 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go @@ -190,7 +190,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r log.Err(err).Msg("failed to open bucket") return validate.NewRequestError(err, http.StatusInternalServerError) } - file, err := bucket.NewReader(ctx, doc.Path, nil) + file, err := bucket.NewReader(ctx, ctrl.repo.Attachments.GetFullPath(doc.Path), nil) if err != nil { log.Err(err).Msg("failed to open file") return validate.NewRequestError(err, http.StatusInternalServerError) diff --git a/backend/go.sum b/backend/go.sum index 52adf5e9..eb731dab 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -339,7 +339,6 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s= github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= diff --git a/backend/internal/core/services/service_items_attachments_test.go b/backend/internal/core/services/service_items_attachments_test.go index 7e15fac9..16560fd3 100644 --- a/backend/internal/core/services/service_items_attachments_test.go +++ b/backend/internal/core/services/service_items_attachments_test.go @@ -52,8 +52,8 @@ func TestItemService_AddAttachment(t *testing.T) { // Check that the file exists storedPath := afterAttachment.Attachments[0].Path - // {root}/{group}/{item}/{attachment} - assert.Equal(t, path.Join("/", tGroup.ID.String(), "documents"), path.Dir(storedPath)) + // path should now be relative: {group}/{documents} + assert.Equal(t, path.Join(tGroup.ID.String(), "documents"), path.Dir(storedPath)) // Check that the file contents are correct bts, err := os.ReadFile(path.Join(os.TempDir(), storedPath)) diff --git a/backend/internal/data/migrations/postgres/20250826000000_make_attachment_paths_relative.sql b/backend/internal/data/migrations/postgres/20250826000000_make_attachment_paths_relative.sql new file mode 100644 index 00000000..58221abc --- /dev/null +++ b/backend/internal/data/migrations/postgres/20250826000000_make_attachment_paths_relative.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Make attachment paths relative by removing the prefix path +-- This migration converts absolute paths to relative paths by finding the UUID/documents pattern + +-- Update Unix-style paths that contain "/documents/" by extracting the part starting from the UUID +-- The approach: find the "/documents/" substring, go back 37 characters (UUID + slash), +-- and extract from there to get "uuid/documents/hash" +UPDATE attachments +SET path = SUBSTRING(path FROM POSITION('/documents/' IN path) - 36) +WHERE path LIKE '%/documents/%' + AND POSITION('/documents/' IN path) > 36; + +-- Update Windows-style paths that contain "\documents\" by extracting the part starting from the UUID +-- Convert backslashes to forward slashes in the process for consistency +UPDATE attachments +SET path = REPLACE(SUBSTRING(path FROM POSITION('\documents\' IN path) - 36), '\', '/') +WHERE path LIKE '%\documents\%' + AND POSITION('\documents\' IN path) > 36; + +-- For paths that already look like relative paths (start with UUID), leave them unchanged +-- This handles cases where the migration might be run multiple times + +-- +goose Down +-- Note: This down migration cannot be safely implemented because we don't know +-- what the original prefix paths were. This is a one-way migration. \ No newline at end of file diff --git a/backend/internal/data/migrations/sqlite3/20250826000000_make_attachment_paths_relative.sql b/backend/internal/data/migrations/sqlite3/20250826000000_make_attachment_paths_relative.sql new file mode 100644 index 00000000..5c3c515f --- /dev/null +++ b/backend/internal/data/migrations/sqlite3/20250826000000_make_attachment_paths_relative.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Make attachment paths relative by removing the prefix path +-- This migration converts absolute paths to relative paths by finding the UUID/documents pattern + +-- Update Unix-style paths that contain "/documents/" by extracting the part starting from the UUID +-- The approach: find the "/documents/" substring, go back 37 characters (UUID + slash), +-- and extract from there to get "uuid/documents/hash" +UPDATE attachments +SET path = SUBSTR(path, INSTR(path, '/documents/') - 36) +WHERE path LIKE '%/documents/%' + AND INSTR(path, '/documents/') > 36; + +-- Update Windows-style paths that contain "\documents\" by extracting the part starting from the UUID +-- Convert backslashes to forward slashes in the process for consistency +UPDATE attachments +SET path = REPLACE(SUBSTR(path, INSTR(path, '\documents\') - 36), '\', '/') +WHERE path LIKE '%\documents\%' + AND INSTR(path, '\documents\') > 36; + +-- For paths that already look like relative paths (start with UUID), leave them unchanged +-- This handles cases where the migration might be run multiple times + +-- +goose Down +-- Note: This down migration cannot be safely implemented because we don't know +-- what the original prefix paths were. This is a one-way migration. \ No newline at end of file diff --git a/backend/internal/data/repo/repo_item_attachments.go b/backend/internal/data/repo/repo_item_attachments.go index 94c79e98..f0b8d1f8 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -98,7 +98,15 @@ func ToItemAttachment(attachment *ent.Attachment) ItemAttachment { } func (r *AttachmentRepo) path(gid uuid.UUID, hash string) string { - return filepath.Join(r.storage.PrefixPath, gid.String(), "documents", hash) + return filepath.Join(gid.String(), "documents", hash) +} + +func (r *AttachmentRepo) fullPath(relativePath string) string { + return filepath.Join(r.storage.PrefixPath, relativePath) +} + +func (r *AttachmentRepo) GetFullPath(relativePath string) string { + return r.fullPath(relativePath) } func (r *AttachmentRepo) GetConnString() string { @@ -387,7 +395,7 @@ func (r *AttachmentRepo) Delete(ctx context.Context, gid uuid.UUID, itemId uuid. log.Err(err).Msg("failed to open bucket for thumbnail deletion") return err } - err = thumbBucket.Delete(ctx, thumb.Path) + err = thumbBucket.Delete(ctx, r.fullPath(thumb.Path)) if err != nil { return err } @@ -409,7 +417,7 @@ func (r *AttachmentRepo) Delete(ctx context.Context, gid uuid.UUID, itemId uuid. log.Err(err).Msg("failed to close bucket") } }(bucket) - err = bucket.Delete(ctx, doc.Path) + err = bucket.Delete(ctx, r.fullPath(doc.Path)) if err != nil { return err } @@ -477,7 +485,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen } }(bucket) - origFile, err := bucket.Open(path) + origFile, err := bucket.Open(r.fullPath(path)) if err != nil { err := tx.Rollback() if err != nil { @@ -787,14 +795,15 @@ func (r *AttachmentRepo) UploadFile(ctx context.Context, itemGroup *ent.Group, d ContentType: contentType, ContentMD5: md5hash.Sum(nil), } - path := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut)) - err = bucket.WriteAll(ctx, path, contentBytes, options) + relativePath := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut)) + fullPath := r.fullPath(relativePath) + err = bucket.WriteAll(ctx, fullPath, contentBytes, options) if err != nil { log.Err(err).Msg("failed to write file to bucket") return "", err } - return path, nil + return relativePath, nil } func isImageFile(mimetype string) bool { @@ -844,7 +853,7 @@ func (r *AttachmentRepo) processThumbnailFromImage(ctx context.Context, groupId } newWidth, newHeight := calculateThumbnailDimensions(bounds.Dx(), bounds.Dy(), r.thumbnail.Width, r.thumbnail.Height) dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) - draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) + draw.CatmullRom.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) buf := new(bytes.Buffer) err := webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false}) diff --git a/backend/internal/sys/config/conf_database.go b/backend/internal/sys/config/conf_database.go index af674a7e..22c2b244 100644 --- a/backend/internal/sys/config/conf_database.go +++ b/backend/internal/sys/config/conf_database.go @@ -17,7 +17,7 @@ type Database struct { Host string `yaml:"host"` Port string `yaml:"port"` Database string `yaml:"database"` - SslMode string `yaml:"ssl_mode" conf:"default:prefer"` + SslMode string `yaml:"ssl_mode" conf:"default:require"` SslRootCert string `yaml:"ssl_rootcert"` SslCert string `yaml:"ssl_cert"` SslKey string `yaml:"ssl_key"` diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js deleted file mode 100644 index e2bc53c5..00000000 --- a/frontend/.eslintrc.js +++ /dev/null @@ -1,55 +0,0 @@ -module.exports = { - env: { - browser: true, - es2021: true, - node: true, - }, - extends: [ - "eslint:recommended", - "plugin:vue/essential", - "plugin:@typescript-eslint/recommended", - "@nuxtjs/eslint-config-typescript", - "plugin:vue/vue3-recommended", - "plugin:prettier/recommended", - "plugin:tailwindcss/recommended", - ], - parserOptions: { - ecmaVersion: "latest", - parser: "@typescript-eslint/parser", - sourceType: "module", - }, - plugins: ["vue", "@typescript-eslint"], - rules: { - "no-console": 0, - "no-unused-vars": "off", - "vue/multi-word-component-names": "off", - "vue/no-setup-props-destructure": 0, - "vue/no-multiple-template-root": 0, - "vue/no-v-model-argument": 0, - "vue/no-v-html": 0, - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/ban-ts-comment": 0, - "tailwindcss/no-custom-classname": "warn", - "@typescript-eslint/no-unused-vars": [ - "error", - { - ignoreRestSiblings: true, - destructuredArrayIgnorePattern: "_", - caughtErrors: "none", - }, - ], - "prettier/prettier": [ - "warn", - { - arrowParens: "avoid", - semi: true, - tabWidth: 2, - useTabs: false, - vueIndentScriptAndStyle: true, - singleQuote: false, - trailingComma: "es5", - printWidth: 120, - }, - ], - }, -}; diff --git a/frontend/app.vue b/frontend/app.vue index ba159df7..71428a16 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -6,7 +6,7 @@ - + diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 87b6ac6a..917afa20 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -1046,4 +1046,32 @@ :root { --header-height: 4rem; --header-height-mobile: 7rem; +} + +/* Non-scoped styles for regular text */ +.break-all { + word-break: break-all; + max-width: 100%; +} + +/* Handle very long words */ +pre, +code, +a, +p, +span, +div, +td, +th, +li, +blockquote, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: anywhere; + word-break: normal; + hyphens: auto; } \ No newline at end of file diff --git a/frontend/components/App/CreateModal.vue b/frontend/components/App/CreateModal.vue index a2efe690..5720a972 100644 --- a/frontend/components/App/CreateModal.vue +++ b/frontend/components/App/CreateModal.vue @@ -47,7 +47,8 @@ import { useMediaQuery } from "@vueuse/core"; import type { DialogID } from "@/components/ui/dialog-provider/utils"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; - import { Dialog, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog"; + import { Shortcut } from "@/components/ui/shortcut"; const isDesktop = useMediaQuery("(min-width: 768px)"); diff --git a/frontend/components/App/ImportDialog.vue b/frontend/components/App/ImportDialog.vue index eaf1048d..d85c6418 100644 --- a/frontend/components/App/ImportDialog.vue +++ b/frontend/components/App/ImportDialog.vue @@ -51,7 +51,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; type Props = { - modelValue: boolean; + modelValue?: boolean; }; const { t } = useI18n(); @@ -66,13 +66,13 @@ const api = useUserApi(); - const importCsv = ref(null); + const importCsv = ref(undefined); const importLoading = ref(false); const importRef = ref(); whenever( () => !dialog.value, () => { - importCsv.value = null; + importCsv.value = undefined; } ); @@ -102,7 +102,7 @@ // Reset dialog.value = false; importLoading.value = false; - importCsv.value = null; + importCsv.value = undefined; if (importRef.value) { importRef.value.value = ""; diff --git a/frontend/components/App/OutdatedModal.vue b/frontend/components/App/OutdatedModal.vue index a2d51c13..4d990737 100644 --- a/frontend/components/App/OutdatedModal.vue +++ b/frontend/components/App/OutdatedModal.vue @@ -29,12 +29,12 @@ import { lt } from "semver"; import { AlertDialog, + AlertDialogAction, AlertDialogContent, - AlertDialogHeader, - AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, - AlertDialogAction, + AlertDialogHeader, + AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useDialog } from "@/components/ui/dialog-provider"; diff --git a/frontend/components/App/QuickMenuModal.vue b/frontend/components/App/QuickMenuModal.vue index 4d443033..12875a65 100644 --- a/frontend/components/App/QuickMenuModal.vue +++ b/frontend/components/App/QuickMenuModal.vue @@ -3,11 +3,11 @@ import { DialogID, type NoParamDialogIDs, type OptionalDialogIDs } from "@/components/ui/dialog-provider/utils"; import { CommandDialog, - CommandInput, - CommandList, CommandEmpty, CommandGroup, + CommandInput, CommandItem, + CommandList, CommandSeparator, } from "~/components/ui/command"; import { Shortcut } from "~/components/ui/shortcut"; diff --git a/frontend/components/App/ScannerModal.vue b/frontend/components/App/ScannerModal.vue index e13cb266..546e86b9 100644 --- a/frontend/components/App/ScannerModal.vue +++ b/frontend/components/App/ScannerModal.vue @@ -33,7 +33,7 @@ - +