Commit Graph
1830 Commits
Author SHA1 Message Date
Darko Gjorgjijoski 03bb6f1164 chore(release): 2.4.3-beta.3 (#722)
A third pre-release, to verify the pipeline end to end after beta.2
exposed a hole in it: that release was published by the workflow itself,
so nothing downstream ran and it reached the updater only by being
registered by hand — and never got Docker images at all.

This one exercises the corrected flow. The tag produces a draft; a human
publishes it, which is what actually triggers registration and the image
build.

Contains no application changes — everything on 2.x since beta.2 is
release tooling and repository housekeeping, and the changelog entry
says so.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2.4.3-beta.3
2026-07-29 17:51:25 +02:00
Darko Gjorgjijoski 8fe6975a81 chore: drop laravel/boost, make AGENTS.md the source of truth (#721)
* chore: drop laravel/boost, make AGENTS.md the source of truth

The two agent files were inverted here relative to 3.x. AGENTS.md held
nothing but Laravel Boost's generated guidelines, while CLAUDE.md carried
the actual project guide — so an agent reading AGENTS.md, the file the
convention points at, got generic Laravel advice and none of the
specifics for this repository.

Boost is not used, so it goes rather than being worked around: the
dependency, the boost:update hook on post-update-cmd, the .cursor MCP
config that existed only to launch boost:mcp, and the three skill
packages it installed there.

AGENTS.md now carries the project guide and is the committed source of
truth. CLAUDE.md, GEMINI.md and .github/copilot-instructions.md become
gitignored symlinks to it, created by bin/ai-docs.php from
post-autoload-dump — the same arrangement, and the same script, as 3.x.

* chore: regenerate .phpstorm.meta.php

Removes every boost.* entry now that the package is gone. Most of the
diff is unrelated churn — the file had not been regenerated in a while,
so it also picks up drift in routes and config. Kept separate so the
boost removal stays readable.
2026-07-29 17:39:20 +02:00
Darko Gjorgjijoski c15549872f ci: stop at the draft; publishing is a human action (#719)
2.4.3-beta.2 was cut by tag and came out correctly — published,
pre-release, zip attached, notes from CHANGELOG.md — and then nothing
else happened. No updater registration, no Docker images.

GitHub does not start workflow runs from events created with
GITHUB_TOKEN. The publish step authenticated as github-actions[bot], so
`release: published` fired and triggered nothing. Every earlier
docker.yaml run was event=release from a release a human published,
which is why beta.1 worked and beta.2 did not.

The workflow now stops at the draft. Everything else is unchanged: the
tag still runs the tests, reads the notes, builds the package and
attaches it. Pressing Publish fires the event under a real identity and
the proven downstream runs as it always has — and it puts a deliberate
gate in front of a release going public.

prerelease and make_latest move onto the draft rather than being applied
at publish time, so the flags are already right when the button is
pressed; GitHub's publish dialog otherwise defaults "Set as the latest
release" to checked, which would let a 3.0.0 alpha displace 2.4.x.

The run now ends by writing the draft URL and the resolved flags to the
job summary, since a draft nobody knows about is no use.

Documents the whole flow in the agent guide, including why publishing is
manual — the reasoning is not guessable from the workflow alone.
2026-07-29 17:04:01 +02:00
Darko Gjorgjijoski 1e3871a75e chore(release): 2.4.3-beta.2 (#718)
A second pre-release, to exercise the tag-triggered flow added in #717 —
the first release on this branch that will be created by pushing a tag
rather than by publishing it by hand.

Contains no application changes; #717 is the only commit on 2.x since
beta.1, and the changelog entry says so.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2.4.3-beta.2
2026-07-29 16:19:36 +02:00
Darko Gjorgjijoski 590c9f8457 ci: cut releases from a tag, with notes from CHANGELOG.md (#717)
Port of #715 and #716 to 2.x. Releases here were published by hand, so
the notes every install is offered were written at that moment rather
than reviewed with the change — 2.4.2's were composed at the point of
release, and nothing checked they existed or matched what shipped.

Tagging is now all that is required. release.yaml reads the CHANGELOG.md
section for the tag, builds via `make clean dist`, creates the release as
a draft with the package already attached, and publishes it as a separate
step. `release: published` therefore fires only once the tests have
passed and the asset is in place, so a failed run leaves no release at
all rather than a published one nobody can download — which is what
2.4.2 left behind. A tag with no section fails before anything is
published, and before any dependency is installed.

Because the asset is attached at publication, docker.yaml no longer needs
to build it: release_artifact_build is gone, and register_release loses
both that dependency and the always() dance it needed to survive the job
being skipped on a manual dispatch.

GitHub's "Latest release" pointer is gated on LATEST_MAJOR, the same
expression docker.yaml uses for its moving image tags.

The workflow is identical to 3.x's but for comments. tests.yaml carries
this branch's own test definition — xdebug coverage and a frontend build,
where 3.x runs in parallel and splits out module tests.
2026-07-29 16:12:48 +02:00
Darko Gjorgjijoski bd81ac851b chore(release): 2.4.3-beta.1 (#714)
A pre-release to exercise the release pipeline end to end — build, upload,
changelog extraction, updater registration and the post-registration
verification — with the guards added since 2.4.2 live.

Contains no application changes. Everything on 2.x since 2.4.2 is release
tooling, and the changelog entry says so, since insider-channel installs
will be offered it.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2.4.3-beta.1
2026-07-29 15:12:38 +02:00
Darko Gjorgjijoski 6458c47f62 ci: source release notes from CHANGELOG.md (#712)
Registration sent the GitHub release body to the updater, so the notes
every install sees were written at publish time — after review, outside
the repo, with nothing checking they existed or matched what shipped.

CHANGELOG.md becomes the source. It is written and reviewed alongside the
change itself, so what installs are offered cannot drift from what was
merged, and the release body can simply point at it.

A release with no section for its tag now fails the job rather than
registering an empty changelog — the same reasoning as the token check
added in #708. A manual dispatch falls back to the release body instead,
since re-registering a release older than this file is legitimate.

Section boundaries are matched on version headings rather than any "## ",
because release notes routinely contain their own second-level headings:
5 of the 12 historical 2.x releases do. Verified by round-tripping 2.4.2
(2504 bytes, byte-identical) and 2.4.0 (5 inner headings, no content
lost), and that "2.4" does not match the "2.4.2" section.

Backfilled with the 2.4.x line; older releases stay on GitHub.
2026-07-29 15:04:20 +02:00
Darko Gjorgjijoski e62823255d ci: don't rebuild the docker image on a registration dispatch (#710)
manual_docker_build fires on any workflow_dispatch, and pushes
invoiceshelf/invoiceshelf:${{ inputs.tag }} where tag is required and
defaults to "latest". Dispatching to re-register a release therefore also
rebuilt from the dispatched branch and overwrote the published :latest
image.

The registration path added in #708 is meant to be a safe recovery route,
so triggering it must not have that side effect. A dispatch carrying
register_tag now runs registration only.
2026-07-29 14:36:53 +02:00
Darko Gjorgjijoski 786c52c0bb ci: fix updater registration and make it re-runnable (#708)
The 2.4.2 release published, uploaded its zip, and built its images — then
failed to register on the updater, so no install was offered the update:

    Registering 2.4.2 (channel=stable, min_php=8.2.0) on the updater
    curl: (26) Failed to open/read local data from file/application

The step wrote the changelog to /tmp/changelog.txt but told curl to read
`changelog.txt`, a relative path resolved against the checkout. `changelog`
is required server-side, so nothing would have been accepted regardless.

The path is the bug; the reason it reached production is that the step only
ever ran during a real release, so it could not be exercised beforehand.
Three changes address that:

Registration moves into its own job that downloads the published asset,
rather than reusing the build job's working directory — the coupling that
made the relative path look reasonable. It runs on a release, or on demand
against any existing tag, so a failure no longer needs production shell
access to repair and the path can be rehearsed deliberately.

A missing WEBSITE_RELEASE_TOKEN is now fatal on a release. Warning and
exiting 0 meant a release could look completely successful while reaching
nobody — the same silent failure as the bug itself.

A verification step follows the POST: /releases/download/{tag} 404s unless
the Release row exists and its zip is retrievable from storage, so it proves
the whole chain rather than trusting a 2xx. It would have caught this.
2026-07-29 13:31:13 +02:00
Darko Gjorgjijoski a820744cf0 chore(release): 2.4.2
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2.4.2
2026-07-29 12:45:15 +02:00
Darko Gjorgjijoski 7e483d06ce fix(installation): support mariadb in the setup wizard (#707)
Backport of InvoiceShelf/InvoiceShelf#704 to 2.x.
Fixes InvoiceShelf/docker#79.

A fresh install using the shipped docker-compose.mysql.yml cannot get past
the database step, because that compose file sets DB_CONNECTION=mariadb.

getDatabaseEnvironment() switched on sqlite, pgsql and mysql with no arm
for mariadb and no default, so it answered {"config":[]}. The wizard
renders <component :is="database_connection">, which resolves to nothing,
so the step came out blank — and nothing reached the log, because the app
never errored, it just replied with nothing.

Adds the mariadb arm plus a default, so an unrecognised driver is echoed
back with server defaults rather than producing an unrenderable response.
MariaDB is registered as an alias of the MySQL form, whose fields are
identical, and offered in each driver picker.

Tested against the original controller, where three of the new cases fail
with "Failed asserting that null is identical to 'mariadb'".
2026-07-29 12:37:41 +02:00
Darko Gjorgjijoski 2cf3b3e840 fix(config): make the application timezone configurable (#706)
Backport of InvoiceShelf/InvoiceShelf#703 to 2.x.
Fixes InvoiceShelf/docker#64.

Two bugs compounded so that no supported value had any effect. config/app.php
had no timezone key, so Laravel's own fallback won — and that fallback is a
literal 'UTC' string, not an env() lookup, leaving APP_TIMEZONE inert despite
shipping in .env.example. inject.sh then wrote a bare TIMEZONE key that nothing
reads, so the documented container variable did nothing either.

inject.sh now writes APP_TIMEZONE and accepts either spelling, so existing
compose files keep working unchanged.
2026-07-29 12:37:07 +02:00
Darko Gjorgjijoski 6c8b7d4dde fix(docker): recreate storage directories and guard the chown (#705)
Backport of InvoiceShelf/InvoiceShelf#702 to 2.x. The chown half of it is
a 2.x-only fix — 3.x had already dropped that line.

storage/framework/{cache,sessions,views}, storage/logs and storage/app
hold no tracked content, only .gitignore stubs, so nothing guarantees they
exist inside a mounted volume. Docker seeds a named volume from the image
once, when it is empty, and never again — a volume created by an older
image keeps whatever it had through every upgrade. Without those
directories Laravel cannot boot and the sqlite branch cannot place its
database.

The unguarded `chown -R www-data:www-data storage` was worse than
ineffective. The image runs as www-data (uid 82), so chown of any file it
does not own returns EPERM, and under `set -e` that aborts the entrypoint.
A single uploaded file owned by the host user is enough to stop the
container from starting — on exactly the mounted-volume setups the chown
was meant to help. Verified against a built image: entrypoint exits 1
before this change, 0 after.

A mount the container genuinely cannot write to is not something the
entrypoint can repair, so it now says what is wrong and names the remedy
instead of failing later with `cp: can't create ...`.
2026-07-29 12:36:56 +02:00
Darko Gjorgjijoski 78b2e35a2e fix(config): resolve the mysql SSL CA attribute per PHP version (#701)
PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA in favour of
Pdo\Mysql::ATTR_SSL_CA, so on 8.5 every test in the suite was reported
as deprecated rather than passed — noise that would hide a real one.

Pdo\Mysql does not exist before 8.5 and this package supports ^8.4, so
the constant is resolved at runtime; the untaken ternary branch is never
looked up, which keeps 8.4 working. The lookup stays behind the
extension_loaded() check because neither name is defined when pdo_mysql
is missing.

Verified against both runtimes: with MYSQL_ATTR_SSL_CA set, 8.4 resolves
to attribute 1009 and 8.5 to 1008 — each version's own value, matching
what the previous code produced there.

Same change as InvoiceShelf/InvoiceShelf#696 on 3.x.
2026-07-29 11:50:17 +02:00
Darko Gjorgjijoski 4d6ece6230 fix(gotenberg): allow a declared private host past the SSRF guard (#700)
Backport of InvoiceShelf/InvoiceShelf#691 to 2.x, for the reporter of
#688 who is on 2.4.1.

The guard added in the 2.4.0 security round rejects private addresses,
which includes the shipped default host `http://pdf:3000` and every
Docker Compose sidecar deployment. So the guard rejects its own default
and Gotenberg cannot be configured at all on the standard setup — a
usability regression introduced by a security patch, which is why this
lands during the 2.x feature freeze.

GOTENBERG_ALLOWED_PRIVATE_HOST names the single host that may skip the
check. It is deliberately not a boolean and not settable from the admin
UI: the driver streams the upstream response body back as the PDF, so a
blanket "allow private" switch would let gotenberg_host be repointed at
a link-local metadata endpoint and the response read back. Naming one
host keeps the sidecar working while every other private target stays
blocked.

GotenbergHostPolicy owns the comparison so the save-time rule and the
runtime driver guard cannot drift, normalising case, trailing slash and
surrounding whitespace on both sides.

Note this differs from 3.x in one respect: SafeRemoteUrl rejects hosts
that do not resolve, where 3.x's PrivateNetworkGuard lets them through.
That behaviour is unchanged here — a typo'd host is still refused at save
time on 2.x, which is the friendlier outcome.
2026-07-29 11:46:53 +02:00
Darko GjorgjijoskiandClaude Opus 4.8 b2180eb2c8 ci: auto-register published release on the website updater (#694)
Append a step to the release_artifact_build job that POSTs the freshly built
InvoiceShelf.zip + metadata to the website updater's /api/releases endpoint
(Bearer WEBSITE_RELEASE_TOKEN) right after the asset upload, so deployed installs
are offered the release automatically instead of a manual kubectl+tinker import.

- Runs only on release events; skips with a warning if WEBSITE_RELEASE_TOKEN is unset
- Channel derived from the prerelease flag / "-" tag suffix (GA->stable, pre->insider)
- min_php + extensions read from config/installer.php; release fields passed via env
  to avoid shell injection from the release body
- Idempotent (the endpoint upserts per version)


Claude-Session: https://claude.ai/code/session_012tpgisKcrC4D4mCbGTeTKz

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:33:42 +02:00
Darko GjorgjijoskiandClaude Opus 4.8 55224e0007 ci(docker): authenticate composer against GitHub in the image build
Multi-arch builds run composer (incl. the merge-plugin's update) twice and exhausted GitHub's unauthenticated API rate limit, failing with 'Could not authenticate against github.com'. Pass the Actions token as a build secret and feed it to composer via COMPOSER_AUTH (build-time only, never in the image).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:31:31 +02:00
Darko GjorgjijoskiandClaude Opus 4.8 cc21c7d4e5 chore(release): 2.4.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2.4.1
2026-06-14 23:43:18 +02:00
Darko GjorgjijoskiandClaude Opus 4.8 6f45b58933 feat(updater): disable the in-app updater in containerized installs
Consume the injected CONTAINERIZED flag: expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:43:18 +02:00
Darko GjorgjijoskiandClaude Opus 4.8 9795bc1d6f ci(docker): release-driven tags, drop nightly cron, transitional :nightly alias
Remove scheduled nightly/alpha builds; gate :latest on LATEST_MAJOR; keep a transitional :nightly alias on stable releases. Fix the production Dockerfile so a stale host public/build cannot clobber the freshly built frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:43:18 +02:00
Darko GjorgjijoskiandClaude Opus 4.8 734ac9421d fix(users): scope user view & update to the acting company
User view/update authorized only that the requester owns their active company, not that the target user belonged to it, allowing an owner of one company to read or modify users of another. Require shared company membership in UserPolicy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:43:18 +02:00
Darko Gjorgjijoski d2465c8a4e ci: bump ramsey/composer-install v2 -> 4.0.0 (node24 internal cache) (#677)
Follow-up to #676: composer-install@v2 is composite but internally calls
actions/cache@v3 (Node 20), the last Node-20 deprecation on 2.x CI. 4.0.0
uses actions/cache v5.0.3 (Node 24) and keeps the composer-options input.
2026-06-12 13:15:51 +02:00
Darko Gjorgjijoski 0cdf3063ed ci: bump GitHub Actions to Node 24 versions (#676)
GitHub removes Node 20 runners on 2026-09-16; bump every Node-20 action
so v2 CI keeps working through the 2.x security-support window. checkout
v4->v6, setup-node v4->v6, paths-filter v3->v4, cancel-workflow-action
0.12.1->0.13.1, pnpm/action-setup v4->v6, docker/{setup-buildx v3->v4,
login v3->v4, metadata v5->v6, build-push v5->v7}. setup-php@v2,
ramsey/composer-install@v2 and svenstaro/upload-release-action@v2 are
already on node24.
2026-06-12 12:26:15 +02:00
Darko Gjorgjijoski e3ab12ad77 docs: add Supported Versions policy (2.x feature freeze, security until 2027-09-01) (#675) 2.4.0 2026-06-12 10:14:56 +02:00
Darko Gjorgjijoski fdb9a9d860 chore: bump version to 2.4.0 (#667) 2026-06-12 09:48:27 +02:00
Darko Gjorgjijoski 9c5b8776c6 fix(deps): bump vulnerable dependencies to patched versions (#674)
Resolves the open Dependabot alerts on 2.x plus a laravel/framework
advisory surfaced by composer audit:

- vite 8.0.3 -> 8.0.5 (npm) — keeps rolldown 1.0.0-rc.12 so the build
  stays correct; vite 8.0.15+ pulls the broken rolldown 1.0.3
- laravel/framework -> 13.15.0 (CVE-2026-48019, CRLF injection in the
  default email validation rule)
- symfony/{mime,http-kernel,mailer,routing,yaml,polyfill-intl-idn} and
  guzzlehttp/psr7 -> patched releases

composer audit clean; frontend build verified (no dangling chunk refs).
2026-06-12 09:47:43 +02:00
Darko Gjorgjijoski 2f641ace0e build: migrate frontend tooling to pnpm + pin vite 8.0.3 (#666)
* build: migrate frontend tooling from yarn to pnpm

The Dockerfiles ran `yarn && yarn build`, which broke on node:24 (yarn no
longer on PATH; the corepack yarn shim made `npm i -g yarn` fail EEXIST),
while CI + Makefile used npm and only a yarn.lock was committed — an
inconsistent yarn/npm split. Standardize on pnpm, pinned via the
packageManager field + corepack.

- package.json: packageManager pnpm@11.6.0.
- pnpm-workspace.yaml: nodeLinker: hoisted (flat node_modules, npm/yarn-like, so
  directly-imported transitive deps like flatpickr resolve) + allow vue-demi's
  postinstall (it selects the Vue 3 entry). pnpm 11 reads these here, not .npmrc.
- Generate pnpm-lock.yaml (imported from yarn.lock); delete yarn.lock.
- Dockerfiles (dev/nginx/production): node:24 + `corepack enable && pnpm install --frozen-lockfile && pnpm build`.
- CI (check.yaml, docker.yaml): pnpm/action-setup + setup-node cache:pnpm; pnpm install --frozen-lockfile / pnpm build.
- Makefile, composer.json dev script, CLAUDE.md: npm/yarn -> pnpm.

pnpm build verified on a clean install (1425 modules, hoisted node_modules).

* fix(build): pin vite to 8.0.3 to fix rolldown chunk regression

vite 8.0.16 (pulled in by #653) bundles rolldown 1.0.3, which emits a
lazy chunk referencing an undefined Vue runtime-init function
(init_runtime_dom_esm_bundler), breaking the SPA at runtime. The build
succeeds so CI never caught it. Pin vite to 8.0.3 (the version 2.3.3
shipped, rolldown 1.0.0) which produces a correct bundle.
2026-06-12 09:31:08 +02:00
Darko Gjorgjijoski 107a951126 fix(security): recompute document totals server-side (GHSA-8c69) (#665)
Invoice/estimate/recurring-invoice creation and update accepted total,
sub_total, tax and due_amount straight from the request with no server-side
recalculation, so a client could persist financial totals that don't match the
line items (and, on invoice update, corrupt the due-amount/paid-amount logic
which keyed off the client total).

- Adds App\Support\DocumentTotals which recomputes item totals (round(price *
  quantity) minus per-item discount) and document totals (sub_total, tax with
  per-item vs document and tax_included handling, total, due_amount), mirroring
  the front-end calculation. Only price/quantity/discounts/tax-line amounts are
  trusted.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
  client totals with the recomputed values; createItems recomputes each stored
  item total; Invoice::updateInvoice keys its due-amount/paid-amount logic off
  the recomputed total instead of the request total.

Adds DocumentTotals unit tests + a feature test proving a tampered invoice
total is ignored. Existing create/update tests no longer assert the (now
server-authoritative) derived totals.
2026-06-12 09:30:44 +02:00
Darko Gjorgjijoski d615cfb4ff fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#664)
gotenberg_host was validated only with Laravel's 'url' rule, which permits
loopback/private/link-local hosts (e.g. http://127.0.0.1, http://10.0.0.1,
the cloud metadata endpoint http://169.254.169.254). When a PDF renders, the
server POSTs the document HTML to that host — an SSRF primitive.

- Adds App\Rules\SafeRemoteUrl: requires http(s) and rejects any host that
  resolves to a loopback/private/link-local/CGNAT/reserved address (IPv4 and
  IPv6), including literal-IP hosts.
- Wires it into PDFConfigurationRequest for gotenberg_host.
- Adds a defensive re-check in GotenbergPDFDriver before the outbound call to
  cover hosts set via env/seed/stale config or DNS rebinding (TOCTOU).

Adds unit tests for the rule + validator integration.
2026-06-12 09:18:57 +02:00
Darko Gjorgjijoski e92b08ef6a fix(security): block ORDER BY SQL injection via orderByField (GHSA-cp8p) (#663)
The orderByField/orderBy query params were passed straight into Eloquent's
orderBy() in every model's scopeWhereOrder (and Invoice::scopeApplyFilters),
allowing arbitrary SQL in the ORDER BY clause (boolean-based blind injection).

Adds App\Support\SafeOrderBy::apply() which only accepts a plain, optionally
table-qualified column identifier as the sort target (rejecting expressions,
sub-selects, etc.) and clamps the direction to asc/desc. Routed all 10 model
sort sinks through it. Table-qualified columns stay valid, so joined/aliased
sorts (e.g. estimates by customers.name) are unaffected.

Adds unit tests covering injection rejection, plain + aliased columns, and
direction clamping.
2026-06-12 09:18:29 +02:00
Darko Gjorgjijoski 5839d8385d fix(security): harden public EmailLog token endpoints (GHSA-73q7) (#662)
Customer PDF controllers resolved the target document by raw mailable_id,
ignoring mailable_type, and skipped the expiry check on the JSON endpoints.

- Resolve via the $emailLog->mailable morph relation and assert the expected
  type (abort 404) so a token issued for one document type can't disclose
  another whose numeric id collides.
- Enforce isExpired() (abort 403) on every public path, including the JSON
  getInvoice/getEstimate/getPayment endpoints.
- Harden EmailLog::isExpired() to treat a null/unresolvable mailable as
  expired instead of throwing.

Adds tests for cross-type 404, JSON-path expiry 403, and the valid path.
2026-06-12 09:18:16 +02:00
Darko Gjorgjijoski e432e4e62f fix(security): enforce company scope on notes, estimate-convert, and user bulk-delete (#661)
- Notes IDOR (GHSA-85wc): NotePolicy::viewNotes/manageNotes now receive the
  Note and require hasCompany($note->company_id); NotesController passes the
  bound model to authorize() on show/update/destroy.
- Estimate->Invoice IDOR (GHSA-j2vg): ConvertEstimateController authorizes
  'view' on the source estimate before creating the invoice.
- User bulk-delete (GHSA-wxrv): UsersController scopes candidate ids via
  User::whereCompany() before deletion so cross-company accounts are protected.

Adds feature tests for cross-company 403s plus same-company happy paths.
2026-06-12 09:17:52 +02:00
Darko Gjorgjijoski c1cadb7ee0 fix(updater): manifest-based stale-file cleanup + cache clearing for v2→v3 (#659)
The v2 self-updater only overlays new files (copyFiles) and never removes
files a release deleted; the one removal path (deleted_files) is not even
sent by the web UI. A major upgrade (v2 → v3) removes thousands of files,
so overlay-only leaves a broken hybrid, and stale bootstrap/cache config +
package-discovery survive and break the new boot.

Backport v3's manifest allow-list approach into this final v2 release:

- Updater::cleanStaleFiles(?string $basePath): delete every file under the
  install not listed in the release's manifest.json, except the configured
  update_protected_paths. No manifest present → safe no-op (v2→v2 updates).
- Updater::clearCompiledCaches(): wipe bootstrap/cache/*.php and compiled
  views so the freshly copied release re-reads config and re-runs package
  discovery. Called at the end of copyFiles() — the last point that runs as
  the currently-installed code before the new release boots, and necessary
  because bootstrap/cache is itself a protected path.
- DeleteFilesController + UpdateCommand: when manifest.json is present, run
  cleanStaleFiles(); otherwise fall back to the legacy deleted_files list.
  No route or frontend change — both already call the delete step between
  copy and migrate.
- config/invoiceshelf.php: add update_protected_paths (.env, storage,
  vendor, node_modules, Modules, public/storage, .git, bootstrap/cache,
  manifest.json).

The v3 release zip already ships manifest.json (built by its make dist), so
a v2 instance running this updater cleans itself up correctly on upgrade.

Tested: tests/Unit/UpdaterTest.php covers stale removal, protected-path and
manifest preservation, empty-dir pruning, no-manifest no-op, and invalid
manifest. Full suite green.
2026-06-12 09:10:49 +02:00
Darko Gjorgjijoski c9d623a0dd Revert "Export system"
This reverts commit a79c4ec5ee.
2026-06-11 08:36:05 +02:00
Rihards Simanovičs ed5103e54d chore: resign from project (#651) 2026-06-05 00:48:47 +02:00
Darko Gjorgjijoski 8c412fa2cf fix(security): bump axios, vite, postcss, follow-redirects to patched versions (#653)
Clears all 28 open Dependabot alerts on 2.x (13 high, 14 medium, 1 low),
which collapse to four frontend packages:

- axios 1.14.0 -> 1.16.0  (the bulk; exact-pinned, so Dependabot couldn't auto-PR)
- vite 8.0.3 -> 8.0.16    (>= 8.0.5 patched)
- follow-redirects 1.15.11 -> 1.16.0
- postcss 8.5.8 -> 8.5.15 (>= 8.5.10 patched)

Supersedes Dependabot PRs #629 (vite) and #630 (follow-redirects).
2026-06-05 00:41:32 +02:00
Darko Gjorgjijoski 0c47869c51 Swap master with versioned branch for nightlies
2.x is still nightly
2026-06-05 00:22:55 +02:00
mchev 8e6e7f9981 Merge pull request #649 from mchev/exports
Add CSV exports for customers, items, invoices, estimates, and expenses
2026-06-04 17:28:45 +02:00
mchev a79c4ec5ee Export system 2026-06-04 17:20:00 +02:00
mchev 83caff13cd Merge pull request #615 from InvoiceShelf/translations
New Crowdin updates
2026-04-08 09:29:39 +02:00
mchev d2736b1c46 Merge pull request #616 from mchev/taxdecimal
Support 3-decimal tax percentages (e.g. 6.625%)
2026-04-08 09:28:44 +02:00
mchev cdc504f518 Merge pull request #617 from mchev/duplicate_expense
Add duplicate expense action
2026-04-08 09:28:22 +02:00
Darko Gjorgjijoski 2c840b2d97 Bump version from 2.3.2 to 2.3.3 2.3.3 2026-04-07 21:25:20 +02:00
Darko Gjorgjijoski 34db4b7897 Sanitize PDF address fields against SSRF, not just notes
Closes the residual surface from the three published SSRF advisories (GHSA-pc5v-8xwc-v9xq, GHSA-38hf-fq8x-q49r, GHSA-q9wx-ggwq-mcgh / CVE-2026-34365 to 34367). The original fix in 07757e74 only sanitized the Notes field via Invoice/Estimate/Payment::getNotes(), but the same blade templates also render company/billing/shipping address fields with {!! !!} (Blade unescaped output). Those address strings are produced by getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() which feed into GeneratesPdfTrait::getFormattedString() — and that method does not call PdfHtmlSanitizer.

Customer-controlled fields (name, street, phone, custom field values) are substituted into address templates via getFieldsArray() without HTML-escaping. A malicious customer name like "Acme <img src='http://attacker/probe'>" therefore reaches Dompdf as raw HTML through the address path, exactly the same CWE-918 SSRF pattern the advisories describe — only blocked today by the secondary defense of dompdf's enable_remote=false. If a self-hoster sets DOMPDF_ENABLE_REMOTE=true for legitimate remote logos, the address surface immediately re-opens.

Move the PdfHtmlSanitizer::sanitize() call into the chokepoint at GeneratesPdfTrait::getFormattedString(), so all four sinks — notes plus the three address fields, on all three models — get the same treatment via a single call site. The explicit wrapper in each model's getNotes() becomes redundant and is removed (along with the now-unused App\Support\PdfHtmlSanitizer imports). Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly) so there is no risk of stripping useful HTML from a non-PDF context.

Extends tests/Unit/PdfHtmlSanitizerTest.php with three new cases covering the address-template scenario, iframe/link tag stripping, and on* event handler removal. All 8 tests pass via vendor/bin/pest tests/Unit/PdfHtmlSanitizerTest.php.
2026-04-07 20:39:19 +02:00
mchev beb2a43ed3 Duplicate expense 2026-04-07 19:00:07 +02:00
mchev 7a25a15877 Increasing tax decimal 2026-04-07 18:35:30 +02:00
Darko Gjorgjijoski 8eee4bc4f5 New translations en.json (Serbian (Latin)) 2026-04-07 14:51:02 +02:00
Darko Gjorgjijoski c34cfeea11 New translations en.json (Swahili) 2026-04-07 14:51:01 +02:00
Darko Gjorgjijoski f37edbcc92 New translations en.json (Malay) 2026-04-07 14:50:59 +02:00
Darko Gjorgjijoski ba581313df New translations en.json (Hindi) 2026-04-07 14:50:58 +02:00