Compare commits

...

12 Commits

Author SHA1 Message Date
Josh
92ba2258b9 chore: make psalm happier when returning Folder vs Node
Ensure appDataFolder is a valid folder before assignment.

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-21 08:30:09 -04:00
Josh
7d36a854f8 fix: Reorder app data folder retrieval logic
Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:13 -04:00
Josh
0619f16ba1 chore: some final tidying
Refactor app data folder handling and improve caching.

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
86244b3c79 fix: adjust AppData breakage introduced in refactor + misc
Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
b9a7524f0d fix: typo after renaming functions
Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
2c5e71476e fix: Update folder retrieval logic in AppData.php
Refactor folder retrieval to use app data path.

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
baa537bef0 chore: Add ISimpleFolder import to IAppData interface
Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
3e88ade603 refactor(AppData): Tidy up implementation
Refactor AppData class to improve caching and folder retrieval logic.

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
54ada0dd49 refactor(IAppData): Add IAppData interface specific docs
Expanded documentation for IAppData interface with detailed descriptions of methods and their purposes since they differ from ISimpleRoot.

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
d628760b60 refactor(SimpleFS): Update ISimpleRoot documentation
Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
2e5710964c refactor(SimpleFS): Modernize SimpleFolder
- Constructor property promotion
- Use same ordering of function as defined in interface
- Streamline getDirectoryListing() implementation
- Implementation specific docblocks where appropriate

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
Josh
ebeae5e4b7 refactor(SimpleFS): Update ISimpleFolder interface documentation
Updated method documentation for clarity and consistency.

Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-10-16 10:07:12 -04:00
5 changed files with 409 additions and 190 deletions

View File

@@ -19,136 +19,257 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFolder;
/**
* Implements app-specific data storage {@see \OCP\Files\IAppData} by wrapping IRootFolder and
* Folder objects and re-using {@see \OCP\Files\SimpleFS\SIimpleFolder} overlaid on Nextcloud's
* virtual file system.
*
* @internal This class is not part of the public API and may change without notice.
*/
class AppData implements IAppData {
private IRootFolder $rootFolder;
private SystemConfig $config;
private string $appId;
private ?Folder $folder = null;
/** @var CappedMemoryCache<ISimpleFolder|NotFoundException> */
/**
* Cached reference to the app-specific Folder for this application.
* This is the Folder instance for "{$instanceAppDataFolderName}/{$appId}" and is created
* or retrieved on first access via getOrCreateAppDataFolder().
*
* @var Folder|null
*/
private ?Folder $appDataFolder = null;
/**
* Caches references to subfolders or lookup errors for app-specific data directories.
* Keys are in the format "{$appId}/{$name}". Values are either ISimpleFolder on success,
* or NotFoundException if the folder was not found.
*
* @var CappedMemoryCache<ISimpleFolder|NotFoundException>
*/
private CappedMemoryCache $folders;
/**
* AppData constructor.
*
* @param IRootFolder $rootFolder
* @param SystemConfig $systemConfig
* @param string $appId
*/
public function __construct(IRootFolder $rootFolder,
SystemConfig $systemConfig,
string $appId) {
$this->rootFolder = $rootFolder;
$this->config = $systemConfig;
$this->appId = $appId;
public function __construct(
private IRootFolder $rootFolder,
private SystemConfig $config,
private string $appId
) {
$this->folders = new CappedMemoryCache();
}
private function getAppDataFolderName() {
$instanceId = $this->config->getValue('instanceid', null);
if ($instanceId === null) {
throw new \RuntimeException('no instance id!');
}
/**
* {@inheritdoc}
*
* TODO: Possible memory optimization opportunity for larger folders.
*/
public function getDirectoryListing(): array {
$nodes = $this->getOrCreateAppDataFolder()->getDirectoryListing();
$subFolders = [];
return 'appdata_' . $instanceId;
foreach ($nodes as $node) {
if ($node instanceof Folder) {
$subFolders[] = new SimpleFolder($node);
}
}
/** @return ISimpleFolder[] */
return $subFolders;
}
protected function getAppDataRootFolder(): Folder {
$name = $this->getAppDataFolderName();
/**
* {@inheritdoc}
*
* Wraps retrieved Folder in an SimpleFolder.
* Uses an in-memory cache for performance.
*
* @throws \RuntimeException for unrecoverable errors
*/
public function getFolder(string $name): ISimpleFolder {
$cacheKey = $this->buildCacheKey($name);
// Check cache
$cachedFolder = $this->folders->get($cacheKey);
if ($cachedFolder instanceof ISimpleFolder) {
return $cachedFolder;
}
if ($cachedFolder instanceof NotFoundException) {
// We can use the cached NotFound w/o cache management since cache is in-request only
throw $cachedFolder;
}
if ($name === '/') {
// Special case: app's appdata root folder
// Get the Folder object representing this app's appdata root folder
$appDataFolder = $this->getOrCreateAppDataFolder();
$requestedFolder = $appDataFolder;
// Try to get the subfolder (within app's appdata folder) by path
} else {
// Standard case: subfolder within app's appdata
try {
// We don't want to create the subfolder if it doesn't exist.
// So we retrieve the subfolder directly by path,
// instead of calling getOrCreateAppDataFolder().
$appDataPath = $this->buildAppDataPath($name);
$requestedFolder = $this->rootFolder->get($appDataPath);
} catch (NotFoundException $e) {
$this->folders->set($cacheKey, $e);
throw $e;
}
}
return $this->wrapAndCacheFolder($cacheKey, $requestedFolder);
}
/**
* {@inheritdoc}
*/
public function newFolder(string $name): ISimpleFolder {
$cacheKey = $this->buildCacheKey($name);
$newFolder = $this->getOrCreateAppDataFolder()->newFolder($name);
return $this->wrapAndCacheFolder($cacheKey, $newFolder);
}
/**
* Returns the name of the top-level appdata folder for the current Nextcloud instance.
*
* @return string The appdata folder name, including the instance identifier.
* @throws \RuntimeException for unrecoverable errors
*/
private function getInstanceAppDataFolderName(): string {
$instanceId = $this->config->getValue('instanceid', null);
if ($instanceId === null) {
throw new \RuntimeException(
'Could not determine instance appdata folder; configuration is missing an instance id!'
);
}
$instanceAppDataFolderName = 'appdata_' . $instanceId;
return $instanceAppDataFolderName;
}
/**
* Retrieves the top-level appdata folder for the current Nextcloud instance.
* Creates the folder if it does not exist.
*
* Protected rather than private since it's also used by downstream \OC\Preview\Storage\Root class.
*
* @return Folder The instance appdata folder.
* @throws \RuntimeException If the folder cannot be created due to permissions.
*/
protected function getInstanceAppDataFolder(): Folder {
$instanceAppDataFolderName = $this->getInstanceAppDataFolderName();
try {
/** @var Folder $node */
$node = $this->rootFolder->get($name);
$node = $this->rootFolder->get($instanceAppDataFolderName);
return $node;
} catch (NotFoundException $e) {
try {
return $this->rootFolder->newFolder($name);
return $this->rootFolder->newFolder($instanceAppDataFolderName);
} catch (NotPermittedException $e) {
throw new \RuntimeException('Could not get appdata folder');
throw new \RuntimeException(
'Could not get nor create instance appdata folder '
. $instanceAppDataFolderName
. ' while trying to get or create dedicated appdata folder for '
. $this->appId
. '. Check data directory permissions!'
);
}
}
}
/**
* @return Folder
* @throws \RuntimeException
* Retrieves the app-specific data folder for the current application.
* Creates the folder if it does not already exist and caches the reference.
*
* @return Folder The Folder object representing the app's data directory.
* @throws \RuntimeException If the folder cannot be accessed or created.
*/
private function getAppDataFolder(): Folder {
if ($this->folder === null) {
$name = $this->getAppDataFolderName();
try {
$this->folder = $this->rootFolder->get($name . '/' . $this->appId);
} catch (NotFoundException $e) {
$appDataRootFolder = $this->getAppDataRootFolder();
try {
$this->folder = $appDataRootFolder->get($this->appId);
} catch (NotFoundException $e) {
try {
$this->folder = $appDataRootFolder->newFolder($this->appId);
} catch (NotPermittedException $e) {
throw new \RuntimeException('Could not get appdata folder for ' . $this->appId);
}
}
}
private function getOrCreateAppDataFolder(): Folder {
if ($this->appDataFolder !== null) {
return $this->appDataFolder;
}
return $this->folder;
}
public function getFolder(string $name): ISimpleFolder {
$key = $this->appId . '/' . $name;
if ($cachedFolder = $this->folders->get($key)) {
if ($cachedFolder instanceof \Exception) {
throw $cachedFolder;
} else {
return $cachedFolder;
}
}
// Try direct lookup
$appDataFolderName = $this->buildAppDataPath();
try {
// Hardening if somebody wants to retrieve '/'
if ($name === '/') {
$node = $this->getAppDataFolder();
} else {
$path = $this->getAppDataFolderName() . '/' . $this->appId . '/' . $name;
$node = $this->rootFolder->get($path);
}
$this->appDataFolder = $this->rootFolder->get($appDataFolderName);
return $this->appDataFolder;
} catch (NotFoundException $e) {
$this->folders->set($key, $e);
throw $e;
// Continue
}
/** @var Folder $node */
$folder = new SimpleFolder($node);
$this->folders->set($key, $folder);
return $folder;
// Try indirect lookup (instance + appId) - slower/more queries
// TODO: This fallback seems redundant/unnecessary at this point...
// - Can be removed since #12883?
// - I suspect it was just left in to be conservative, but is presumably already a no-op.
$instanceAppDataFolder = $this->getInstanceAppDataFolder();
try {
$node = $instanceAppDataFolder->get($this->appId);
if (!($node instanceof Folder)) {
throw new \RuntimeException('Appdata node is not a folder!');
}
$this->appDataFolder = $node;
return $this->appDataFolder;
} catch (NotFoundException $e) {
// Continue
}
// Still not found, try to create
try {
$this->appDataFolder = $instanceAppDataFolder->newFolder($this->appId);
return $this->appDataFolder;
} catch (NotPermittedException $e) {
throw new \RuntimeException('Could not get nor create appdata folder for ' . $this->appId);
}
}
public function newFolder(string $name): ISimpleFolder {
$key = $this->appId . '/' . $name;
$folder = $this->getAppDataFolder()->newFolder($name);
/**
* Returns the numeric ID of the app-specific data folder.
*
* Public rather than private since it's called by OC\Preview\BackgroundCleanupJob class.
*
* Note: Seems to only be used by downstream \OC\Preview\Storage\Root class.
*
* @return int The folder's unique identifier.
*/
public function getId(): int {
return $this->getOrCreateAppDataFolder()->getId();
}
/**
* Validates the folder name and generates a cache key.
*
* @param string $name
* @return string
* @throws \RuntimeException if the name is empty
*/
private function buildCacheKey(string $name): string {
if ($name === '') {
throw new \RuntimeException('Appdata folder name cannot be (empty).');
}
return $this->appId . '/' . $name;
}
/**
* Wraps a Folder as SimpleFolder, caches it, and returns it.
*
* @param string $cacheKey
* @param Folder $folder
* @return ISimpleFolder
*/
private function wrapAndCacheFolder(string $cacheKey, Folder $folder): ISimpleFolder {
$simpleFolder = new SimpleFolder($folder);
$this->folders->set($key, $simpleFolder);
$this->folders->set($cacheKey, $simpleFolder);
return $simpleFolder;
}
public function getDirectoryListing(): array {
$listing = $this->getAppDataFolder()->getDirectoryListing();
$fileListing = array_map(function (Node $folder) {
if ($folder instanceof Folder) {
return new SimpleFolder($folder);
}
return null;
}, $listing);
$fileListing = array_filter($fileListing);
return array_values($fileListing);
}
public function getId(): int {
return $this->getAppDataFolder()->getId();
/**
* Builds the full path to a subfolder in an app's appdata directory.
*
* If $subfolder is provided, returns the path to that subfolder;
* otherwise returns the path to the root of the app's appdata folder.
*
* @param string|null $subfolder Optional subfolder name
* @return string
*/
private function buildAppDataPath(?string $subfolder = null): string {
$base = $this->getInstanceAppDataFolderName() . '/' . $this->appId;
return $subfolder === null ? $base : $base . '/' . $subfolder;
}
}

View File

@@ -13,46 +13,44 @@ use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
/**
* Concrete implementation of {@see \OCP\Files\SimpleFS\ISimpleFolder}.
*
* Wraps a Folder object to expose simplified filesystem operations.
*
* @internal This class is not part of the public API and may change without notice.
*/
class SimpleFolder implements ISimpleFolder {
/** @var Folder */
private $folder;
public function __construct(private Folder $folder) {
}
/**
* Folder constructor.
*
* @param Folder $folder
* {@inheritdoc}
*/
public function __construct(Folder $folder) {
$this->folder = $folder;
}
public function getName(): string {
return $this->folder->getName();
}
public function getDirectoryListing(): array {
$listing = $this->folder->getDirectoryListing();
$fileListing = array_map(function (Node $file) {
if ($file instanceof File) {
return new SimpleFile($file);
}
return null;
}, $listing);
$fileListing = array_filter($fileListing);
return array_values($fileListing);
}
public function delete(): void {
$this->folder->delete();
$nodes = $this->folder->getDirectoryListing();
$files = [];
foreach ($nodes as $node) {
if ($node instanceof File) {
$files[] = new SimpleFile($node);
}
}
return $files;
}
/**
* {@inheritdoc}
*
* (NotFoundException|NotPermittedException) are treated equally in the underlying class and not passed up;
* Both situations will return false here.
*/
public function fileExists(string $name): bool {
return $this->folder->nodeExists($name);
}
/**
* {@inheritdoc}
*/
public function getFile(string $name): ISimpleFile {
$file = $this->folder->get($name);
@@ -63,6 +61,9 @@ class SimpleFolder implements ISimpleFolder {
return new SimpleFile($file);
}
/**
* {@inheritdoc}
*/
public function newFile(string $name, $content = null): ISimpleFile {
if ($content === null) {
// delay creating the file until it's written to
@@ -73,6 +74,23 @@ class SimpleFolder implements ISimpleFolder {
}
}
/**
* {@inheritdoc}
*/
public function delete(): void {
$this->folder->delete();
}
/**
* {@inheritdoc}
*/
public function getName(): string {
return $this->folder->getName();
}
/**
* {@inheritdoc}
*/
public function getFolder(string $name): ISimpleFolder {
$folder = $this->folder->get($name);
@@ -83,6 +101,12 @@ class SimpleFolder implements ISimpleFolder {
return new SimpleFolder($folder);
}
/**
* {@inheritdoc}
*
* (AlreadyExistsException|InvalidPathException) and other storage-specific exceptions may bubble up here.
* The implementation does not handle them and the interface does not define if we should.
*/
public function newFolder(string $path): ISimpleFolder {
$folder = $this->folder->newFolder($path);
return new SimpleFolder($folder);

View File

@@ -4,14 +4,65 @@
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Files;
use OCP\Files\SimpleFS\ISimpleRoot;
use OCP\Files\SimpleFS\ISimpleFolder;
/**
* Interface IAppData
* Interface for accessing app-specific data storage in Nextcloud.
*
* Implementations of this interface provide a virtual filesystem abstraction
* for storing application data that is isolated from user files. Each Nextcloud
* application can store, retrieve, and manage its own data within a dedicated
* subfolder of the instance-wide appdata directory.
*
* This interface extends {@see OCP\Files\SimpleFS\ISimpleRoot}, allowing
* applications to work with files and folders using simplified filesystem
* operations.
*
* Typical use cases include caching, storing previews, thumbnails, configuration,
* or other non-user-specific data for an app.
*
* @since 11.0.0
*/
interface IAppData extends ISimpleRoot {
/**
* Returns a list of subfolders in the app-specific data folder.
*
* Unlike ISimpleRoot, this method only lists folders within the current application's
* data storage area, not user directories or files. The returned folders are isolated
* from other applications. Files within the appdata folder are not included.
*
* @return ISimpleFolder[] List of subfolders in the appdata directory.
*/
public function getDirectoryListing(): array;
/**
* Retrieves a named subfolder from the app-specific data storage.
*
* The folder is always relative to the application's own appdata directory, and is
* isolated from other apps and user files. If the folder does not exist, an exception
* is thrown.
*
* @param string $name Name of the subfolder to retrieve.
* @return ISimpleFolder The requested folder.
* @throws \OCP\Files\NotFoundException If the folder does not exist.
*/
public function getFolder(string $name): ISimpleFolder;
/**
* Creates a new subfolder within the app-specific data directory.
*
* The folder is created inside the application's appdata storage and is not visible
* to other apps or users. If a folder with the given name already exists, an exception
* may be thrown.
*
* @param string $name Name of the folder to create.
* @return ISimpleFolder The created folder.
* @throws \OCP\Files\NotPermittedException If the folder cannot be created.
*/
public function newFolder(string $name): ISimpleFolder;
}

View File

@@ -10,74 +10,90 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
/**
* Interface ISimpleFolder
* Interface for representing and manipulating simple folders in Nextcloud's virtual filesystem.
*
* Provides methods for listing, creating, retrieving, and deleting folders.
*
* @since 11.0.0
* @api
*/
interface ISimpleFolder {
/**
* Get all the files in a folder
* Get all the files in this folder.
*
* @return ISimpleFile[]
* @return ISimpleFile[] Array of files contained in the folder.
* @since 11.0.0
*/
public function getDirectoryListing(): array;
/**
* Check if a file with $name exists
*
* @param string $name
* @return bool
* @since 11.0.0
*/
/**
* Check if a file with the given name exists in this folder.
*
* @param string $name Name of the file to check.
* @return bool True if the file exists, false otherwise.
* @since 11.0.0
*/
public function fileExists(string $name): bool;
/**
* Get the file named $name from the folder
/**
* Get the file named $name from this folder.
*
* @throws NotFoundException
* @since 11.0.0
*/
* @param string $name Name of the file to retrieve.
* @return ISimpleFile The file object.
* @throws NotFoundException If the file does not exist.
* @throws NotPermittedException If access to the file is not permitted.
* @since 11.0.0
*/
public function getFile(string $name): ISimpleFile;
/**
* Creates a new file with $name in the folder
*
* @param string|resource|null $content @since 19.0.0
* @throws NotPermittedException
* @since 11.0.0
*/
/**
* Creates a new file with the given name in this folder.
*
* @param string $name Name of the new file.
* @param string|resource|null $content Initial content for the file (optional).
* @return ISimpleFile The newly created file object.
* @throws NotPermittedException If file creation is not permitted.
* @since 11.0.0
*/
public function newFile(string $name, $content = null): ISimpleFile;
/**
* Remove the folder and all the files in it
*
* @throws NotPermittedException
* @since 11.0.0
*/
/**
* Remove this folder and all its contents.
*
* @return void
* @throws NotPermittedException If deletion is not permitted.
* @since 11.0.0
*/
public function delete(): void;
/**
* Get the folder name
*
* @since 11.0.0
*/
/**
* Get the name of this folder.
*
* @return string The folder name.
* @since 11.0.0
*/
public function getName(): string;
/**
* Get the folder named $name from the current folder
*
* @throws NotFoundException
* @since 25.0.0
*/
/**
* Get the subfolder named $name from this folder.
*
* @param string $name Name of the subfolder to retrieve.
* @return ISimpleFolder The subfolder object.
* @throws NotFoundException If the subfolder does not exist.
* @since 25.0.0
*/
public function getFolder(string $name): ISimpleFolder;
/**
* Creates a new folder with $name in the current folder
*
* @param string|resource|null $content @since 19.0.0
* @throws NotPermittedException
* @since 25.0.0
*/
/**
* Creates a new subfolder with the given path in this folder.
*
* @param string $path Path (name) of the new subfolder.
* @return ISimpleFolder The newly created subfolder object.
* @throws NotPermittedException If folder creation is not permitted.
* @since 25.0.0
*/
// TODO: rename $path -> $name for consistency (already the case in parallel interfaces such as ISimpleRoot).
// Alternatively/related, clarify whether nested paths/names are officially accepted here (versus for getFolder() where they're not).
// Same technically applies to some other methods with different behavior, such as fileExists().
public function newFolder(string $path): ISimpleFolder;
}

View File

@@ -10,35 +10,42 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
/**
* Interface ISimpleRoot
* Interface for representing and manipulating root of a simple folder structure in Nextcloud's virtual filesystem.
*
* Provides methods for listing, creating, and retrieving folders within the root.
*
* @since 11.0.0
* @api
*/
interface ISimpleRoot {
/**
* Get the folder with name $name
* Get all folders at the root of the simple filesystem.
*
* @throws NotFoundException
* @throws \RuntimeException
* @since 11.0.0
*/
public function getFolder(string $name): ISimpleFolder;
/**
* Get all the Folders
*
* @return ISimpleFolder[]
* @throws NotFoundException
* @throws \RuntimeException
* @return ISimpleFolder[] Array of ISimpleFolder instances representing each folder.
* @throws NotFoundException If no folders are found.
* @throws \RuntimeException For general runtime errors.
* @since 11.0.0
*/
public function getDirectoryListing(): array;
/**
* Create a new folder named $name
* Get the folder named $name from the root of the simple filesystem.
*
* @throws NotPermittedException
* @throws \RuntimeException
* @param string $name The name of the folder to retrieve.
* @return ISimpleFolder The folder instance corresponding to the provided name.
* @throws NotFoundException If the folder with the given name does not exist.
* @throws \RuntimeException For general runtime errors.
* @since 11.0.0
*/
public function getFolder(string $name): ISimpleFolder;
/**
* Creates a new folder named $name at the root of the simple filesystem.
*
* @param string $name The name of the new folder to create.
* @return ISimpleFolder The newly created folder instance.
* @throws NotPermittedException If folder creation is not permitted.
* @throws \RuntimeException For general runtime errors.
* @since 11.0.0
*/
public function newFolder(string $name): ISimpleFolder;