Skip to content

Restore a pad from the trash on Nextcloud 31 - #190

Open
Jaggob wants to merge 1 commit into
mainfrom
fix/nc31-trash-restore
Open

Restore a pad from the trash on Nextcloud 31#190
Jaggob wants to merge 1 commit into
mainfrom
fix/nc31-trash-restore

Conversation

@Jaggob

@Jaggob Jaggob commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

On Nextcloud 31 restoring a .pad from the trash fails. Through the web UI and WebDAV it ends in an HTTP 500; through occ trashbin:restore it prints a bare Failed: while plain files listed next to it come back fine. The file is not lost, but it cannot be brought back.

NodeRestoredEvent hands the listener a node that cannot be resolved yet, so reading its id throws. LifecycleService::handleRestore() does exactly that on its first line, the exception escapes the listener, and it aborts the restore Nextcloud is in the middle of. Nextcloud 32 and 33 pass a resolvable node, which is why this survived unnoticed — the suite only ever ran against one instance.

The listener now re-resolves the node from its path before handing it on. The owner is taken from the path rather than the session, because occ has none.

The error path made the diagnosis worse than the bug: it logged the file id, from that same node, inside the catch. That threw a second time and replaced the exception being reported, so the log showed an empty NotFoundException and nothing about the cause. Reading an id for a log line can no longer throw.

Verified: two unit tests that fail without the fix — one for the unresolvable node, one asserting the original exception survives when the id cannot be logged. Full PHPUnit 576/2193, Psalm clean. Reproduced end to end on a Nextcloud 31 container before and after: occ trashbin:restore goes from Failed: to success, and the pad-trash-restore browser spec from red to green on 31, 32 and 33.

The container stack those runs used arrives in the follow-up PR for #112.

On Nextcloud 31 NodeRestoredEvent carries a node that cannot be resolved
yet, so reading its id throws. handleRestore() does exactly that, the
exception escaped the listener, and the restore itself was aborted: a
.pad could not be brought back from the trash at all – HTTP 500 through
the web UI and WebDAV, a bare "Failed:" through occ trashbin:restore,
while plain files next to it came back fine. Nextcloud 32 and 33 hand
over a resolvable node, which is why this went unnoticed.

The listener now re-resolves the node from its path before passing it
on. The owner comes from the path rather than the session, because occ
has none.

The error path made this worse: it logged the file id, on that same
node, from inside the catch. That threw a second time and replaced the
exception being reported, so the log showed an empty NotFoundException
and nothing about the actual cause. Reading the id for a log line can no
longer throw.

Both are covered by unit tests that fail without the fix.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix Nextcloud 31 trash restore for .pad nodes by re-resolving event target

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Re-resolve NodeRestoredEvent targets by path to support Nextcloud 31’s unresolvable nodes.
• Prevent logging from throwing while handling lifecycle restore errors.
• Add unit tests covering NC31 restore behavior and error rethrow semantics.
Diagram

graph TD
  E(["NodeRestoredEvent"]) --> L["RestoreFromTrashListener"] --> M{{"materialize()\n(re-resolve)"}} --> S["LifecycleService\nhandleRestore()"]
  M --> R[("IRootFolder")] --> U["User folder"] --> F["Resolved File"] --> S
  S --> G["Logger"]
  subgraph Legend
    direction LR
    _evt(["Event"]) ~~~ _cmp["Component"] ~~~ _dec{{"Decision"}} ~~~ _db[("Storage API")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Move the guard into LifecycleService::handleRestore()
  • ➕ Centralizes protection so all callers benefit (not just this listener).
  • ➕ Avoids duplicating resolution logic across listeners.
  • ➖ LifecycleService may still need a resolvable node for other operations; would likely require the same path-based lookup anyway.
  • ➖ Harder to keep restore-specific path/owner semantics (occ vs session) localized.
2. Version-gated behavior (NC31-only workaround)
  • ➕ Minimizes behavior change surface for later Nextcloud versions.
  • ➖ Adds version detection complexity and maintenance overhead.
  • ➖ Still needs robust fallback behavior if other edge cases produce unresolvable nodes.
3. Defer handling when node is unresolvable (retry/queue)
  • ➕ Avoids guessing path parsing assumptions; could be more resilient if resolution becomes valid shortly after.
  • ➖ Adds async/state complexity and requires a scheduling mechanism.
  • ➖ Restore UX becomes eventually-consistent; harder to reason about failures.

Recommendation: Keep the current approach: re-resolve the node by path within the listener and make log-id retrieval non-throwing. It directly addresses the NC31 event contract mismatch without adding version gates or asynchronous retry complexity, and it preserves correct error reporting by preventing secondary exceptions from masking the original failure.

Files changed (2) +159 / -2

Bug fix (1) +66 / -2
RestoreFromTrashListener.phpMaterialize restored node by path and make fileId logging exception-safe +66/-2

Materialize restored node by path and make fileId logging exception-safe

• Adds a pre-step to re-resolve the restored File node from its path when the event’s target is not yet resolvable (NC31). Introduces a non-throwing file id accessor for logging so exception handling cannot mask the original lifecycle error.

lib/Listeners/RestoreFromTrashListener.php

Tests (1) +93 / -0
RestoreFromTrashListenerTest.phpAdd regression tests for NC31 unresolvable nodes and error rethrowing +93/-0

Add regression tests for NC31 unresolvable nodes and error rethrowing

• Adds unit coverage ensuring unresolvable restore event targets are looked up by path (without relying on a user session) and that lifecycle exceptions are rethrown even when reading the file id for logs would throw.

tests/phpunit/unit/RestoreFromTrashListenerTest.php

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Resolved id not verified 🐞 Bug ≡ Correctness
Description
materialize() returns the path-resolved File without verifying that its getId() is readable, despite
the method’s contract comment. If the resolved node still throws on getId(), restoreNode() will call
LifecycleService::handleRestore(), which immediately reads getId() and can still abort restore
processing with an exception.
Code

lib/Listeners/RestoreFromTrashListener.php[R146-147]

+		return $resolved instanceof File ? $resolved : null;
+	}
Evidence
materialize() only checks instanceof File for the resolved node and returns it, but
LifecycleService::handleRestore() always begins by calling getId(), so an unresolved fallback
node will still fail at restore time.

lib/Listeners/RestoreFromTrashListener.php[109-147]
lib/Service/LifecycleService.php[282-286]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`RestoreFromTrashListener::materialize()` intends to return a node whose id can be read, but it does not validate `getId()` on the *fallback* node obtained via `$this->rootFolder->getUserFolder(...)->get(...)`. If that fallback node is also not resolvable yet, `LifecycleService::handleRestore()` will still throw immediately on its first line (`$file->getId()`), potentially reintroducing the restore abort.

## Issue Context
The listener is explicitly working around Nextcloud 31 delivering an unresolved node; the fallback resolution should be held to the same “id is readable” standard as the original node.

## Fix Focus Areas
- lib/Listeners/RestoreFromTrashListener.php[114-147]

## Suggested fix
After `$resolved` is obtained and verified as `instanceof File`, attempt to read its id in a `try/catch`. If it throws, log a warning/debug (without calling `getId()` again elsewhere) and return `null` instead of returning `$resolved`.

## Test update
Add/extend a unit test where the fallback `$resolved` mock is a `File` whose `getId()` still throws, and assert the listener does not throw and does not call `LifecycleService::handleRestore()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Silent materialize null returns 🐞 Bug ◔ Observability
Description
When materialize() returns null, handle() returns immediately without logging, so lifecycle restore
handling is skipped with no diagnostic trail. This makes future restore edge-cases (unexpected path
formats, NotFoundException) hard to detect and debug.
Code

lib/Listeners/RestoreFromTrashListener.php[R50-53]

+		$node = $this->materialize($node);
+		if ($node === null) {
+			return;
+		}
Evidence
The listener returns early on null materialization and does not call restoreNode(); multiple
materialize() branches return null without logging (except the generic Throwable resolution
case).

lib/Listeners/RestoreFromTrashListener.php[35-56]
lib/Listeners/RestoreFromTrashListener.php[75-93]
lib/Listeners/RestoreFromTrashListener.php[122-137]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several `materialize()` failure paths return `null` without any log (e.g., invalid path format, `getPath()` failure, `NotFoundException`). `handle()` then returns silently, meaning `restoreNode()` / `LifecycleService::handleRestore()` are skipped with no indication why.

## Issue Context
A warning is already logged for the generic `\Throwable` resolution error branch, but the other `null` returns are silent. Adding a lightweight debug/warning log improves diagnosability without reintroducing the restore-aborting behavior.

## Fix Focus Areas
- lib/Listeners/RestoreFromTrashListener.php[35-56]
- lib/Listeners/RestoreFromTrashListener.php[122-137]

## Suggested fix
Add a debug (or warning) log when `materialize()` returns `null`, including at least the best-effort path (guarded by try/catch) and a short reason (e.g., `unexpected_path_format`, `not_found`, `get_path_failed`). Ensure the logging path cannot throw (do not call `getId()` in these logs; reuse `loggableFileId()` only if safe).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context used

Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +146 to +147
return $resolved instanceof File ? $resolved : null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Resolved id not verified 🐞 Bug ≡ Correctness

materialize() returns the path-resolved File without verifying that its getId() is readable, despite
the method’s contract comment. If the resolved node still throws on getId(), restoreNode() will call
LifecycleService::handleRestore(), which immediately reads getId() and can still abort restore
processing with an exception.
Agent Prompt
## Issue description
`RestoreFromTrashListener::materialize()` intends to return a node whose id can be read, but it does not validate `getId()` on the *fallback* node obtained via `$this->rootFolder->getUserFolder(...)->get(...)`. If that fallback node is also not resolvable yet, `LifecycleService::handleRestore()` will still throw immediately on its first line (`$file->getId()`), potentially reintroducing the restore abort.

## Issue Context
The listener is explicitly working around Nextcloud 31 delivering an unresolved node; the fallback resolution should be held to the same “id is readable” standard as the original node.

## Fix Focus Areas
- lib/Listeners/RestoreFromTrashListener.php[114-147]

## Suggested fix
After `$resolved` is obtained and verified as `instanceof File`, attempt to read its id in a `try/catch`. If it throws, log a warning/debug (without calling `getId()` again elsewhere) and return `null` instead of returning `$resolved`.

## Test update
Add/extend a unit test where the fallback `$resolved` mock is a `File` whose `getId()` still throws, and assert the listener does not throw and does not call `LifecycleService::handleRestore()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +50 to +53
$node = $this->materialize($node);
if ($node === null) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Silent materialize null returns 🐞 Bug ◔ Observability

When materialize() returns null, handle() returns immediately without logging, so lifecycle restore
handling is skipped with no diagnostic trail. This makes future restore edge-cases (unexpected path
formats, NotFoundException) hard to detect and debug.
Agent Prompt
## Issue description
Several `materialize()` failure paths return `null` without any log (e.g., invalid path format, `getPath()` failure, `NotFoundException`). `handle()` then returns silently, meaning `restoreNode()` / `LifecycleService::handleRestore()` are skipped with no indication why.

## Issue Context
A warning is already logged for the generic `\Throwable` resolution error branch, but the other `null` returns are silent. Adding a lightweight debug/warning log improves diagnosability without reintroducing the restore-aborting behavior.

## Fix Focus Areas
- lib/Listeners/RestoreFromTrashListener.php[35-56]
- lib/Listeners/RestoreFromTrashListener.php[122-137]

## Suggested fix
Add a debug (or warning) log when `materialize()` returns `null`, including at least the best-effort path (guarded by try/catch) and a short reason (e.g., `unexpected_path_format`, `not_found`, `get_path_failed`). Ensure the logging path cannot throw (do not call `getId()` in these logs; reuse `loggableFileId()` only if safe).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant