Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 58 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ Locks use an ownership and authorization model (**lock type**) and are created o

Token-owned locks are primarily intended for automatic client locking, for example when a file is opened by a desktop client or another WebDAV client that supports native WebDAV locking. The lock type alone does not imply that a lock was created automatically.

A token-owned lock records both the token and the user who created it. Modifying the file requires the token and the request must come from that user (RFC 4918, section 6.4); a request from another user is refused even if it carries the token, and a write through the PHP file system API requires the token to be presented through the lock scope of `ILockManager`. The lock is released by a native `UNLOCK` that presents the token and comes from someone who may write the file, by the user recorded as its owner, by the file owner, or by an administrator. Possession of the token alone is not enough: the token is a publicly readable value (RFC 4918, section 6.5), so privileges are enforced through the normal permission mechanism as section 6.4 requires.

### Enforcement

Locks are enforced by file identity on every storage a file can be reached through: the owner's home storage, shares, group and team folders, external storages and custom mounts. A user lock blocks every other user, an app lock blocks everything outside the owning app's lock scope, and a token lock blocks everything that does not present the token as described above. Deleting or moving a folder is refused while it contains a file locked by someone else, so a locked file can neither be removed nor relocated through its parent.

### Access paths

An access path is the mechanism used to create or remove a lock. Locks can be created and removed through the Web UI, the OCS API, native WebDAV `LOCK`/`UNLOCK`, or the `X-User-Lock` WebDAV extension. These are access paths to the same lock implementation; they are not separate lock types.
Expand All @@ -48,7 +54,7 @@ An access path is the mechanism used to create or remove a lock. Locks can be cr

Native WebDAV locking is the token-based access path for Type 2 locks.

A `LOCK` request creates a token-owned lock and associates it with a WebDAV lock token. A subsequent `UNLOCK` request must provide that token.
A `LOCK` request creates a token-owned lock for the authenticated user and associates it with a WebDAV lock token. A subsequent `UNLOCK` request must provide that token and must come from a user who may write the file. A `Timeout` header sets the lifetime of the lock (`Second-N` or `Infinite`); without it the configured `lock_timeout` applies. A `LOCK` refresh (no body, the token in the `If` header) moves the expiry. The `<D:owner>` element of the request is not stored; the lock reports the display name of the user who created it. A `LOCK` on a file that already carries another lock answers `423 Locked`, an `UNLOCK` with an unknown token answers `409 Conflict`, and an `UNLOCK` the caller is not allowed to perform answers `403 Forbidden`.

- **WebDAV with `X-User-Lock`**

Expand All @@ -68,16 +74,18 @@ An access path is the mechanism used to create or remove a lock. Locks can be cr

### Unlocking

The file owner can override existing locks through access paths that support user override. Administrators can also force-unlock files using `occ files:lock --unlock <fileId>`. In the case of automatic locks, apps and client applications are typically responsible for removing no longer needed locks.
The file owner can override existing locks on files stored in their own home storage (directly or through a share of it); on group folders, external storages and other mounts every user is reported as owner, so no owner override applies there. The user recorded as the owner of a lock can always release it, whatever the lock type. Administrators can force-unlock files using `occ files:lock --unlock <fileId>`. In the case of automatic locks, apps and client applications are typically responsible for removing no longer needed locks.

By default, files are locked indefinitely.

When unlocking an app-owned lock, provide a user ID that has access to the file:
A forced unlock removes the lock of any type and does not require the user who created it to still have access to the file:

`occ files:lock --unlock <fileId> <userId>`
`occ files:lock --unlock <fileId>`

This command can be helpful when locks become stale. For example, when a user forgets to remove a manually created lock or a desktop client remains offline and automatic unlocking is not configured.

Deleting a file removes its lock. A file restored from the trash bin starts unlocked.

### Lock timeout

Locks have no expiry by default (`lock_timeout = -1`).
Expand All @@ -88,12 +96,16 @@ Administrators can change the time of the maximum lock time in minutes (30) usin

Set `lock_timeout` to `-1` to disable expiration.

Every lock carries an absolute expiry. Locking a file again (OCS, `X-User-Lock`, or a native WebDAV refresh) moves the expiry to the configured timeout, or to the timeout requested through the WebDAV `Timeout` header, counted from the moment of the refresh. Expired locks stop blocking immediately and a background job removes them from the database.

### Locking

Administrators can manually lock files using `occ`:

`occ files:lock <fileId> [<lockOwner>] [--status] [--unlock]`

Only files can be locked through the Web UI, the OCS API, `X-User-Lock` and the command line; a folder ID is refused there. Native WebDAV clients may still lock a collection as RFC 4918 requires; such a lock protects the collection's own name and location, not its members.

## Capabilities API

If locking is available the app will expose itself through the capabilities endpoint under the `files` key:
Expand Down Expand Up @@ -128,9 +140,9 @@ WebDAV returns the following additional properties in response to a `PROPFIND` r
- `2` represents a token-owned lock, typically created through native WebDAV locking
- `{http://nextcloud.org/ns}lock-owner`: User ID of the lock owner for user-owned and token-owned locks. This property is empty for app-owned locks.
- `{http://nextcloud.org/ns}lock-owner-displayname`: Display name of the lock owner
- `{http://nextcloud.org/ns}lock-owner-editor`: App ID for an app-owned lock. Clients can use it to suggest joining the collaborative editing session in the web interface or through direct editing. In the response to an `X-User-Lock` `LOCK` request, this property currently contains the lock owner regardless of lock type.
- `{http://nextcloud.org/ns}lock-owner-editor`: App ID for an app-owned lock. Clients can use it to suggest joining the collaborative editing session in the web interface or through direct editing. It is empty for user-owned and token-owned locks on every access path.
- `{http://nextcloud.org/ns}lock-time`: Timestamp at which the lock was created
- `{http://nextcloud.org/ns}lock-timeout`: Configured lock timeout in seconds from creation. A value of <=`-1` indicates that the lock does not expire.
- `{http://nextcloud.org/ns}lock-timeout`: Lifetime of the lock in seconds counted from `lock-time`; it grows when the lock is refreshed. A value of `0` indicates that the lock does not expire.
- `{http://nextcloud.org/ns}lock-token`: Lock token. Clients using native WebDAV locking must retain it while holding the lock and provide it when unlocking.

```bash
Expand Down Expand Up @@ -185,6 +197,9 @@ The response will give back the updated properties after obtaining the lock with

#### Error status codes

- 400 Unsupported `X-User-Lock-Type`
- 403 The caller lacks update permission, or the resource is a folder
- 404 The resource does not exist
- 423 Unable to lock because the file is already locked by another owner

### Manually unlock a file
Expand Down Expand Up @@ -221,8 +236,10 @@ curl -X UNLOCK \

#### Error status codes

- 400 Unsupported `X-User-Lock-Type`
- 404 The resource does not exist
- 412 Unable to unlock because the file is not locked
- 423 Unable to unlock if the lock is owned by another user
- 423 Unable to unlock if the lock is owned by another user; the response carries the existing lock

## OCS API

Expand Down Expand Up @@ -254,22 +271,37 @@ curl -X PUT 'http://admin:admin@nextcloud.local/ocs/v2.php/apps/files_lock/lock/
```

#### Failure

The file is already locked by someone else. The response carries the existing lock, without its token:
```
<?xml version="1.0"?>
<ocs>
<meta>
<status>failure</status>
<statuscode>500</statuscode>
<message/>
<statuscode>423</statuscode>
<message>File is currently locked by admin</message>
</meta>
<data>
<status>-1</status>
<exception>OCA\FilesLock\Exceptions\AlreadyLockedException</exception>
<message>File is already locked by admin</message>
<id>12</id>
<userId>admin</userId>
<displayName>admin</displayName>
<fileId>123</fileId>
<eta>-1</eta>
<creation>1648046707</creation>
<expiresAt/>
<type>0</type>
</data>
</ocs>
```

#### Status codes

- 200 Lock created or refreshed
- 400 Unsupported `lockType`, invalid file ID, or the ID belongs to a folder
- 403 The caller lacks update permission on the file
- 404 The file does not exist or is not accessible to the caller
- 423 The file is locked by another owner

### Unlocking a file

`DELETE /apps/files_lock/lock/{fileId}`
Expand All @@ -280,7 +312,7 @@ curl -X DELETE 'http://admin:admin@nextcloud.local/ocs/v2.php/apps/files_lock/lo

#### Parameters

Not applicable.
- `lockType` (optional): The lock type the caller asserts, matching the value used when locking. The user recorded as the owner of a lock can release it whatever the type, and the file owner can release any lock on a file of their home storage.

#### Success
```
Expand All @@ -295,18 +327,24 @@ Not applicable.
```

#### Failure

The file is not locked:
```
<?xml version="1.0"?>
<ocs>
<meta>
<status>failure</status>
<statuscode>500</statuscode>
<message/>
<statuscode>412</statuscode>
<message>File is not locked</message>
</meta>
<data>
<status>-1</status>
<exception>OCA\FilesLock\Exceptions\LockNotFoundException</exception>
<message></message>
</data>
<data/>
</ocs>
```

#### Status codes

- 200 Lock released
- 400 Unsupported `lockType` or invalid file ID
- 404 The file does not exist or is not accessible to the caller
- 412 The file is not locked
- 423 The lock is held by someone else and the caller may not release it; the response carries the existing lock
11 changes: 11 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@
use OCA\FilesLock\ConfigLexicon;
use OCA\FilesLock\Listeners\BeforeFileSystemSetupListener;
use OCA\FilesLock\Listeners\LoadAdditionalScripts;
use OCA\FilesLock\Listeners\NodeDeletedListener;
use OCA\FilesLock\Listeners\PropfindPropertiesListener;
use OCA\FilesLock\LockProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Files\Cache\CacheEntryRemovedEvent;
use OCP\Files\Events\BeforeFileSystemSetupEvent;
use OCP\Files\Events\BeforeRemotePropfindEvent;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Lock\ILockManager;

class Application extends App implements IBootstrap {
Expand Down Expand Up @@ -55,6 +58,14 @@ public function register(IRegistrationContext $context): void {
BeforeFileSystemSetupEvent::class,
BeforeFileSystemSetupListener::class
);
$context->registerEventListener(
NodeDeletedEvent::class,
NodeDeletedListener::class
);
$context->registerEventListener(
CacheEntryRemovedEvent::class,
NodeDeletedListener::class
);
$context->registerConfigLexicon(ConfigLexicon::class);
}

Expand Down
38 changes: 22 additions & 16 deletions lib/Command/Lock.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use OCP\Files\InvalidPathException;
use OCP\Files\Lock\ILock;
use OCP\Files\Lock\LockContext;
use OCP\Files\Lock\OwnerLockedException;
use OCP\Files\NotFoundException;
use OCP\IUserManager;
use OCP\User\Exceptions\UserNotFoundException;
Expand All @@ -42,11 +43,10 @@
<comment>occ files:lock --status &lt;file_id&gt;</comment>

<info>Forcibly unlock a file:</info>
<comment>occ files:lock --unlock &lt;file_id&gt; [&lt;user_id&gt;]</comment>
<comment>occ files:lock --unlock &lt;file_id&gt;</comment>

For app-owned locks, provide a user ID that has access to the file. This can be
needed for files stored in Groupfolders:
<comment>occ files:lock --unlock &lt;file_id&gt; &lt;user_id&gt;</comment>
A forced unlock removes the lock of any type regardless of who holds it and
does not require the lock owner to still have access to the file.

<info>Uninstall the app and delete all locks:</info>
<comment>occ files:lock --uninstall</comment>
Expand All @@ -63,17 +63,14 @@ public function __construct(
}

/**
* @throws NotFoundException
* @throws UnauthorizedUnlockException
* @throws NotFileException
* @throws InvalidPathException
*/
public function __invoke(
IOutput $output,
IInput $input,
#[Argument(description: 'ID of the file to lock, unlock, or inspect', name: 'file_id')]
?string $fileId = null,
#[Argument(description: 'Lock owner when locking; user with file access when unlocking an app-owned lock', name: 'user_id')]
#[Argument(description: 'Lock owner when locking', name: 'user_id')]
?string $userId = null,
#[Option(description: 'Fully uninstall the app from your Nextcloud')]
bool $uninstall = false,
Expand All @@ -98,14 +95,26 @@ public function __invoke(
}

if ($unlock === true) {
return $this->unlockFile($output, $userId, $fileId);
return $this->unlockFile($output, $fileId);
}

if ($userId === null || $userId === '') {
throw new InvalidArgumentException('Not enough arguments (missing: "user_id")');
}

return $this->lockFile($output, $fileId, $userId);
try {
return $this->lockFile($output, $fileId, $userId);
} catch (OwnerLockedException $e) {
$output->writeln('<error>File #' . $fileId . ' is already locked by ' . $e->getLock()->getOwner() . '</error>');
} catch (NotFileException) {
$output->writeln('<error>#' . $fileId . ' is not a file; only files can be locked</error>');
} catch (UnauthorizedUnlockException|UserNotFoundException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
} catch (NotFoundException) {
$output->writeln('<error>File #' . $fileId . ' not found for user ' . $userId . '</error>');
}

return ExitCode::Failure;
}

private function getStatus(IOutput $output, int $fileId): ExitCode {
Expand Down Expand Up @@ -144,18 +153,15 @@ private function lockFile(IOutput $output, int $fileId, string $userId): ExitCod
$file = $this->fileService->getFileFromId($user->getUID(), $fileId);

$output->writeln('<info>locking ' . $file->getName() . ' to ' . $userId . '</info>');
$this->lockService->lock(new LockContext(
$this->lockService->acquire(new LockContext(
$file, ILock::TYPE_USER, $userId
));
return ExitCode::Success;
}

/**
* @throws UnauthorizedUnlockException
*/
private function unlockFile(IOutput $output, ?string $userId, int $fileId): ExitCode {
private function unlockFile(IOutput $output, int $fileId): ExitCode {
try {
$this->lockService->unlockFile($fileId, $userId, true);
$this->lockService->forceUnlock($fileId);
$output->writeln('<info>Unlocked file #' . $fileId . '</info>');
} catch (LockNotFoundException) {
$output->writeln('<comment>File #' . $fileId . ' was already unlocked</comment>');
Expand Down
Loading
Loading