Compare commits

..

7 commits

27 changed files with 1269 additions and 1038 deletions

2
.l10nignore Normal file
View file

@ -0,0 +1,2 @@
js/
vendor/

View file

@ -5,7 +5,7 @@
<name>Groupfolder Filesystem Snapshots</name>
<summary>Allows restoring a groupfolder to a previous snapshot in the filesystem</summary>
<description>App proving a PHP API for other apps, that allows (partially) restoring a groupfolder to a previous snapshot in the filesystem. Requires a filesystem with snapshot support (tested with and made for ZFS). It is made for other apps to integrate with, IT DOES NOT WORK STANDALONE</description>
<version>1.3.1</version>
<version>1.4.1</version>
<licence>agpl</licence>
<author homepage="https://verdigado.com/">verdigado eG</author>
<author mail="jonathan.treffler@verdigado.com">Jonathan Treffler</author>
@ -13,7 +13,7 @@
<category>files</category>
<bugs>https://git.verdigado.com/verdigado-public/nextcloud_groupfolder_filesystem_snapshots/issues</bugs>
<dependencies>
<nextcloud min-version="28" max-version="29"/>
<nextcloud min-version="29" max-version="30"/>
<database>pgsql</database>
<database>sqlite</database>
<database>mysql</database>

View file

@ -1,6 +1,6 @@
[package]
before_cmds = [
"composer install",
"npm install",
"npm ci",
"npm run build",
]

0
l10n/.gitkeep Normal file
View file

9
l10n/de.js Normal file
View file

@ -0,0 +1,9 @@
OC.L10N.register(
"groupfolder_filesystem_snapshots",
{
"Automated hourly backup" : "Stündliches automatisches Backup",
"Automated daily backup" : "Tägliches automatisches Backup",
"Automated weekly backup" : "Wöchentliches automatisches Backup",
"Automated monthly backup" : "Monatliches automatisches Backup"
},
"nplurals=2; plural=(n != 1);");

7
l10n/de.json Normal file
View file

@ -0,0 +1,7 @@
{ "translations": {
"Automated hourly backup" : "Stündliches automatisches Backup",
"Automated daily backup" : "Tägliches automatisches Backup",
"Automated weekly backup" : "Wöchentliches automatisches Backup",
"Automated monthly backup" : "Monatliches automatisches Backup"
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}

9
l10n/de_DE.js Normal file
View file

@ -0,0 +1,9 @@
OC.L10N.register(
"groupfolder_filesystem_snapshots",
{
"Automated hourly backup" : "Stündliches automatisches Backup",
"Automated daily backup" : "Tägliches automatisches Backup",
"Automated weekly backup" : "Wöchentliches automatisches Backup",
"Automated monthly backup" : "Monatliches automatisches Backup"
},
"nplurals=2; plural=(n != 1);");

7
l10n/de_DE.json Normal file
View file

@ -0,0 +1,7 @@
{ "translations": {
"Automated hourly backup" : "Stündliches automatisches Backup",
"Automated daily backup" : "Tägliches automatisches Backup",
"Automated weekly backup" : "Wöchentliches automatisches Backup",
"Automated monthly backup" : "Monatliches automatisches Backup"
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}

View file

@ -16,6 +16,8 @@ class DiffTaskResult extends Entity implements JsonSerializable {
protected $beforeSize;
protected $currentFileExists;
protected $currentFileId;
protected $currentPath;
protected $currentSize;
protected $reverted;
@ -26,6 +28,7 @@ class DiffTaskResult extends Entity implements JsonSerializable {
$this->addType('beforeFileExists','boolean');
$this->addType('beforeSize','integer');
$this->addType('currentFileExists','boolean');
$this->addType('currentFileId','integer');
$this->addType('currentSize','integer');
$this->addType('reverted','boolean');
}
@ -42,6 +45,7 @@ class DiffTaskResult extends Entity implements JsonSerializable {
],
'current' => [
'fileExists' => $this->currentFileExists,
'fileId' => $this->currentFileId,
'path' => $this->currentPath,
'size' => $this->currentSize,
],

View file

@ -5,16 +5,30 @@ namespace OCA\GroupfolderFilesystemSnapshots\Entity;
use JsonSerializable;
class Snapshot implements JsonSerializable {
/** @var string */
private $id;
public function __construct(
private string $id,
private string $name,
private ?\DateTimeImmutable $createdTimestamp = null,
) {
}
public function __construct(string $id) {
$this->id = $id;
public function getId(): string {
return $this->id;
}
public function getName(): string {
return $this->name;
}
public function getCreatedTimestamp(): ?\DateTimeImmutable {
return $this->createdTimestamp;
}
public function jsonSerialize(): mixed {
return [
'id' => $this->id
'id' => $this->id,
'name' => $this->name,
'createdTimestamp' => $this->createdTimestamp?->getTimestamp(),
];
}
}

View file

@ -70,12 +70,12 @@ class PathManager {
private function checkIfGroupfolderExists(int $groupfolderId): bool {
$storageId = $this->getRootFolderStorageId();
if ($storageId === null) {
return "storage Id null";
return false;
}
$folder = $this->groupfolderFolderManager->getFolder($groupfolderId, $storageId);
if ($folder === false) {
return "Folder does not exist";
return false;
}
return true;

View file

@ -2,16 +2,21 @@
namespace OCA\GroupfolderFilesystemSnapshots\Manager;
use OCA\GroupfolderFilesystemSnapshots\Manager\PathManager;
use OCP\IL10N;
use OCA\GroupfolderFilesystemSnapshots\Manager\PathManager;
use OCA\GroupfolderFilesystemSnapshots\Service\SettingsService;
use OCA\GroupfolderFilesystemSnapshots\Entity\Snapshot;
class SnapshotManager {
private PathManager $pathManager;
private string $snapshotNamingScheme = "";
public function __construct(PathManager $pathManager){
$this->pathManager = $pathManager;
public function __construct(
protected readonly IL10N $l10n,
private readonly PathManager $pathManager,
private readonly SettingsService $settingsService,
){
$this->snapshotNamingScheme = $this->settingsService->getAppValue("snapshot_naming_scheme");
}
private function validSnapshotId(string $snapshotId) {
@ -27,20 +32,94 @@ class SnapshotManager {
}
}
private function createSnapshotEntity(string $id): Snapshot {
if ($this->snapshotNamingScheme === "zfs-auto-snapshot" && str_starts_with($id, "zfs-auto-snap")) {
if (str_starts_with($id, "zfs-auto-snap_hourly-")) {
$name = $this->l10n->t("Automated hourly backup");
$datetimestring = str_replace("zfs-auto-snap_hourly-", "", $id);
} elseif (str_starts_with($id, "zfs-auto-snap_daily-")) {
$name = $this->l10n->t("Automated daily backup");
$datetimestring = str_replace("zfs-auto-snap_daily-", "", $id);
} elseif (str_starts_with($id, "zfs-auto-snap_weekly-")) {
$name = $this->l10n->t("Automated weekly backup");
$datetimestring = str_replace("zfs-auto-snap_weekly-", "", $id);
} elseif (str_starts_with($id, "zfs-auto-snap_monthly-")) {
$name = $this->l10n->t("Automated monthly backup");
$datetimestring = str_replace("zfs-auto-snap_monthly-", "", $id);
}
if(isset($datetimestring)) {
$datetimearray = explode("-", $datetimestring);
$timestring = array_pop($datetimearray);
$year = (int)$datetimearray[0];
$month = (int)$datetimearray[1];
$day = (int)$datetimearray[2];
$hour = (int)substr($timestring, 0, 2);
$minute = (int)substr($timestring, 2, 2);
$createdTimestamp = (new \DateTimeImmutable())
->setDate($year, $month, $day)
->setTime($hour, $minute);
}
return new Snapshot(
id: $id,
name: $name ?: $id,
createdTimestamp: $createdTimestamp,
);
} else {
return new Snapshot(
id: $id,
name: $id,
);
}
}
function get(string $snapshotId) {
if(self::snapshotExists($snapshotId)) {
return new Snapshot($snapshotId);
return $this->createSnapshotEntity($snapshotId);
} else {
return false;
}
}
function getAll() {
function getAll(): array {
$snapshots = [];
$iterator = new \FilesystemIterator($this->pathManager->getFilesystemSnapshotsPath());
foreach ($iterator as $fileinfo) {
if(!$fileinfo->isDir()) continue;
yield new Snapshot($fileinfo->getFilename());
$snapshots[] = $this->createSnapshotEntity($fileinfo->getFilename());
}
return $snapshots;
}
function getAllGenerator(){
$iterator = new \FilesystemIterator($this->pathManager->getFilesystemSnapshotsPath());
foreach ($iterator as $fileinfo) {
if(!$fileinfo->isDir()) continue;
yield $this->createSnapshotEntity($fileinfo->getFilename());
}
}
/**
* @var $subPathFilter Only return snapshots that have this subfolder in the specified groupfolder
*/
function getFilteredGenerator(int $groupfolderId, string $subDirectoryFilter) {
$iterator = new \FilesystemIterator($this->pathManager->getFilesystemSnapshotsPath());
$groupfolderSubdirectoryPath = $this->pathManager->getGroupFolderDirectory($groupfolderId, $subDirectoryFilter);
foreach ($iterator as $fileinfo) {
if(!$fileinfo->isDir()) continue;
$snapshotId = $fileinfo->getFilename();
$filterFullPath = $this->pathManager->convertToSnapshotPath($groupfolderSubdirectoryPath, $snapshotId);
if(!(is_dir($filterFullPath))) continue;
yield $this->createSnapshotEntity($snapshotId);
}
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace OCA\GroupfolderFilesystemSnapshots\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\SimpleMigrationStep;
use OCP\Migration\IOutput;
class Version140Date20250701164500 extends SimpleMigrationStep {
const RESULTS_TABLE = "groupfolder_snapshots_task_results";
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable(self::RESULTS_TABLE);
if(!$table->hasColumn('current_file_id')) {
$table->addColumn('current_file_id', Types::BIGINT, [
'notnull' => false,
]);
}
return $schema;
}
}

View file

@ -5,15 +5,6 @@ namespace OCA\GroupfolderFilesystemSnapshots;
use OCA\GroupfolderFilesystemSnapshots\Helpers\FileHelper;
class RecursiveDiff {
public string $dir1;
public string $dir2;
private $prefix;
private $newResultCallback;
private $progressCallback;
private $scan1files = [];
private $scan1folders = [];
@ -25,14 +16,14 @@ class RecursiveDiff {
private $subJobProgress = [];
private $progress = 0;
public function __construct($dir1, $dir2, $prefix = "", $newResultCallback, $progressCallback){
$this->dir1 = $dir1;
$this->dir2 = $dir2;
$this->prefix = $prefix;
$this->newResultCallback = $newResultCallback;
$this->progressCallback = $progressCallback;
}
public function __construct(
public readonly string $dir1,
public readonly string $dir2,
private readonly string $prefix = "",
private readonly array $folderBlocklist = [],
private $newResultCallback,
private $progressCallback,
){}
public function scan() {
$scan_num_files = 0;
@ -54,8 +45,13 @@ class RecursiveDiff {
$subdir1 = $this->dir1 . DIRECTORY_SEPARATOR . $folder;
$subdir2 = $this->dir2 . DIRECTORY_SEPARATOR . $folder;
$subprefix = $this->prefix . DIRECTORY_SEPARATOR . $folder;
$subFolderBlocklist = $this->folderBlocklist[$folder] ?? [];
$newJob = new RecursiveDiff($subdir1, $subdir2, $subprefix, $this->newResultCallback, function($numDoneFiles) use ($key) {
if($subFolderBlocklist === true) {
continue;
}
$newJob = new RecursiveDiff($subdir1, $subdir2, $subprefix, $subFolderBlocklist, $this->newResultCallback, function($numDoneFiles) use ($key) {
$this->subJobProgress[$key] = $numDoneFiles;
$this->updateProgress();

View file

@ -58,7 +58,7 @@ class DiffTaskResultService {
$diffTaskResult = $this->find($id);
if($diffTaskResult->getReverted()) {
throw new AlreadyRevertedException;
throw new AlreadyRevertedException();
}
$taskId = $diffTaskResult->getTaskId();
@ -66,35 +66,35 @@ class DiffTaskResultService {
$snapshotPath = $this->pathManager->getGroupFolderSnapshotDirectory($diffTask->getGroupfolderId(), $diffTask->getRelativePath(), $diffTask->getSnapshotId());
$gruenerFolder = $this->pathManager->getGroupfolderMountById($diffTask->getGroupfolderId())->get($diffTask->getRelativePath());
$parentFolder = $this->pathManager->getGroupfolderMountById($diffTask->getGroupfolderId())->get($diffTask->getRelativePath());
switch($diffTaskResult->getType()) {
case "CREATION":
$currentFile = $this->getSubfolderMustExist($gruenerFolder, $diffTaskResult->getCurrentPath());
$currentFile = $this->getSubfolderMustExist($parentFolder, $diffTaskResult->getCurrentPath());
$currentFile->delete();
break;
case "RENAME":
$currentFile = $this->getSubfolderMustExist($gruenerFolder, $diffTaskResult->getCurrentPath());
$beforeDirectory = $this->getOrCreateSubdirectoryByFilepath($gruenerFolder, $diffTaskResult->getBeforePath());
$currentFile = $this->getSubfolderMustExist($parentFolder, $diffTaskResult->getCurrentPath());
$beforeDirectory = $this->getOrCreateSubdirectoryByFilepath($parentFolder, $diffTaskResult->getBeforePath());
$currentFile->move($beforeDirectory->getPath() . DIRECTORY_SEPARATOR . basename($diffTaskResult->getBeforePath()));
break;
case "DELETION":
$beforeFileFilesystemPath = $snapshotPath . DIRECTORY_SEPARATOR . $diffTaskResult->getBeforePath();
$beforeDirectory = $this->getOrCreateSubdirectoryByFilepath($gruenerFolder, $diffTaskResult->getBeforePath());
$beforeDirectory = $this->getOrCreateSubdirectoryByFilepath($parentFolder, $diffTaskResult->getBeforePath());
$restoredFile = $beforeDirectory->newFile(basename($diffTaskResult->getBeforePath()));
$this->copyFilesystemFileToNextcloudFile($beforeFileFilesystemPath, $restoredFile);
break;
case "EDIT":
$currentFile = $this->getSubfolderMustExist($gruenerFolder, $diffTaskResult->getCurrentPath());
$currentFile = $this->getSubfolderMustExist($parentFolder, $diffTaskResult->getCurrentPath());
$beforeFileFilesystemPath = $snapshotPath . DIRECTORY_SEPARATOR . $diffTaskResult->getBeforePath();
$this->copyFilesystemFileToNextcloudFile($beforeFileFilesystemPath, $currentFile);
break;
default:
throw new \Exception;
throw new Exception();
}
return $this->mapper->markReverted($id);
@ -104,7 +104,7 @@ class DiffTaskResultService {
if($parent->nodeExists($path)) {
return $parent->get($path);
} else {
throw new ChangesMadeSinceDiffException;
throw new ChangesMadeSinceDiffException();
}
}
@ -119,7 +119,7 @@ class DiffTaskResultService {
if($temp instanceof \OCP\Files\Folder) {
$beforeDirectory = $temp;
} else {
throw new ChangesMadeSinceDiffException;
throw new ChangesMadeSinceDiffException();
}
} else {
$beforeDirectory = $beforeDirectory->newFolder($subdir);

View file

@ -58,7 +58,8 @@ class DiffTaskService {
}
}
function create(string $relativePathInGroupfolder, int $groupfolderId, string $snapshotId, string $userId, Callable $progressCallback = null): ?DiffTask {
function create(string $relativePathInGroupfolder, int $groupfolderId, string $snapshotId, string $userId, array $folderBlocklist, Callable $progressCallback = null): ?DiffTask {
$parentNode = $this->pathManager->getGroupfolderMountById($groupfolderId)->get($relativePathInGroupfolder);
$snapshotPath = $this->pathManager->getGroupFolderSnapshotDirectory($groupfolderId, $relativePathInGroupfolder, $snapshotId);
$groupfolderPath = $this->pathManager->getGroupFolderDirectory($groupfolderId, $relativePathInGroupfolder);
@ -80,16 +81,25 @@ class DiffTaskService {
$snapshotPath,
$groupfolderPath,
"",
function(string $type, bool $beforeFileExists, ?string $beforePath, ?int $beforeSize, bool $currentFileExists, ?string $currentPath, ?int $currentSize) use ($task) {
$folderBlocklist,
function(string $type, bool $beforeFileExists, ?string $beforePath, ?int $beforeSize, bool $currentFileExists, ?string $currentPath, ?int $currentSize) use ($task, $parentNode) {
$newResult = new DiffTaskResult();
$newResult->setTaskId($task->getId());
$newResult->setType($type);
$newResult->setBeforeFileExists($beforeFileExists);
if($beforeFileExists) {
$newResult->setBeforePath($beforePath);
$newResult->setBeforeSize($beforeSize);
}
$newResult->setCurrentFileExists($currentFileExists);
if($currentFileExists) {
$newResult->setCurrentFileId($parentNode->get($currentPath)?->getId());
$newResult->setCurrentPath($currentPath);
$newResult->setCurrentSize($currentSize);
}
$newResult = $this->diffTaskResultMapper->insert($newResult);
},
function($numDoneFiles) use ($progressCallback, &$numFiles) {
@ -97,8 +107,7 @@ class DiffTaskService {
($progressCallback)([
"overallFiles" => $numFiles,
"doneFiles" => $numDoneFiles,
"progress" => number_format(($numDoneFiles / $numFiles),2),
"progressPercent" => (number_format(($numDoneFiles / $numFiles),2) * 100) . "%",
"progress" => floor(($numDoneFiles / $numFiles) * 100) / 100,
]);
}
},
@ -113,7 +122,6 @@ class DiffTaskService {
"overallFiles" => $numFiles,
"doneFiles" => $numFiles,
"progress" => 1.0,
"progressPercent" => "100.00%",
"result" => $task,
]);
}

View file

@ -9,7 +9,7 @@ use OCP\IConfig;
class SettingsService {
private static array $VALID_APP_SETTINGS = ["filesystem_mountpoint_path", "filesystem_snapshots_path"];
private static array $VALID_APP_SETTINGS = ["filesystem_mountpoint_path", "filesystem_snapshots_path", "snapshot_naming_scheme"];
public function __construct(private IConfig $config) {
}

1303
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,6 @@
"devDependencies": {
"@nextcloud/babel-config": "^1.2.0",
"@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/webpack-vue-config": "^6.0.0"
"@nextcloud/webpack-vue-config": "^5.4.0"
}
}

View file

@ -4,12 +4,13 @@
name="Groupfolder Filesystem Snapshots"
:limit-width="false">
<div v-if="!loading">
<Field :is="setting.sensitive ? NcPasswordField : NcTextField"
<Field :is="getAppSettingComponent(setting)"
v-for="setting in app_settings"
v-bind="getAppSettingProps(setting)"
:key="setting.id"
class="settings_field"
:value="settings?.[setting.id]"
:label="setting.name"
@update:modelValue="(newValue) => updateSetting(setting.id, newValue)"
@update:value="(newValue) => updateSetting(setting.id, newValue)" />
</div>
</NcSettingsSection>
@ -18,7 +19,7 @@
<script setup>
import { ref } from "vue";
import debounceFunction from 'debounce-fn';
import { NcSettingsSection, NcTextField, NcPasswordField } from "@nextcloud/vue"
import { NcSettingsSection, NcTextField, NcPasswordField, NcSelect } from "@nextcloud/vue"
import { adminSettingsApi } from "./adminSettingsApi.js";
@ -26,10 +27,53 @@ let loading = ref(true);
let settings = ref({});
const app_settings = [
{id: "filesystem_mountpoint_path", name: "Filesystem Mountpoint Path"},
{id: "filesystem_snapshots_path", name: "Filesystem Snapshots Path"},
{id: "filesystem_mountpoint_path", name: "Filesystem Mountpoint Path", type: "text" },
{id: "filesystem_snapshots_path", name: "Filesystem Snapshots Path", type: "text"},
{
id: "snapshot_naming_scheme",
name: "Snapshot Naming Scheme",
type: "select",
options: [
{
id: '',
label: 'None',
},
{
id: 'zfs-auto-snapshot',
label: 'zfs-auto-snapshot',
},
]
},
];
const getAppSettingComponent = function(appSetting) {
if(appSetting.type === "select") {
return NcSelect;
} else {
if(appSetting?.sensitive) {
return NcPasswordField;
} else {
return NcTextField;
}
}
}
const getAppSettingProps = function(appSetting) {
if(appSetting.type === "select") {
return {
options: appSetting.options,
label: "label",
inputLabel: appSetting.name,
reduce: (option) => ( option.id )
};
} else {
return {
label: appSetting.name,
clearable: false,
};
}
}
adminSettingsApi.getAllSettings().then((result) => {
settings.value = result;
loading.value = false;