Finished backup service draft
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
15
.idea/.idea.OneDriveBackupService/.idea/.gitignore
generated
vendored
Normal file
15
.idea/.idea.OneDriveBackupService/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Rider ignored files
|
||||||
|
/modules.xml
|
||||||
|
/projectSettingsUpdater.xml
|
||||||
|
/contentModel.xml
|
||||||
|
/.idea.OneDriveBackupService.iml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
4
.idea/.idea.OneDriveBackupService/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.OneDriveBackupService/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
8
.idea/.idea.OneDriveBackupService/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.OneDriveBackupService/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="UserContentModel">
|
||||||
|
<attachedFolders />
|
||||||
|
<explicitIncludes />
|
||||||
|
<explicitExcludes />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
27
ConfigData.cs
Normal file
27
ConfigData.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace OneDriveBackupService;
|
||||||
|
|
||||||
|
public sealed class ConfigData {
|
||||||
|
public string Schedule { get; }
|
||||||
|
public string BackupUploadRoot { get; }
|
||||||
|
public string LocalRoot { get; }
|
||||||
|
public string IncludeFile { get; }
|
||||||
|
public int KeepLast { get; }
|
||||||
|
|
||||||
|
public string TenantId { get; }
|
||||||
|
public string ClientId { get; }
|
||||||
|
public string ClientSecret { get; }
|
||||||
|
public string UserId { get; }
|
||||||
|
|
||||||
|
public ConfigData(IConfiguration config) {
|
||||||
|
Schedule = config["Schedule"]!;
|
||||||
|
BackupUploadRoot = config["UploadRoot"]!;
|
||||||
|
LocalRoot = config["LocalRoot"]!;
|
||||||
|
IncludeFile = config["IncludeFile"]!;
|
||||||
|
KeepLast = int.Parse(config["KeepLast"]!);
|
||||||
|
|
||||||
|
TenantId = config["TenantId"]!;
|
||||||
|
ClientId = config["ClientId"]!;
|
||||||
|
ClientSecret = config["ClientSecret"]!;
|
||||||
|
UserId = config["UserId"]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
|
||||||
|
RUN apt-get update && apt-get install -y tar && rm /var/lib/apt/lists/*
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["OneDriveBackupService.csproj", "./"]
|
||||||
|
RUN dotnet restore "OneDriveBackupService.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/"
|
||||||
|
RUN dotnet build "./OneDriveBackupService.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./OneDriveBackupService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "OneDriveBackupService.dll"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Leon Hoppe
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
17
OneDriveBackupService.csproj
Normal file
17
OneDriveBackupService.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-OneDriveBackupService-890cf0cf-6a2a-4470-ba77-b6237cf7bd2c</UserSecretsId>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||||
|
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0"/>
|
||||||
|
<PackageReference Include="Microsoft.Graph" Version="5.100.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
3
OneDriveBackupService.slnx
Normal file
3
OneDriveBackupService.slnx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="OneDriveBackupService.csproj" />
|
||||||
|
</Solution>
|
||||||
76
OneDriveClient.cs
Normal file
76
OneDriveClient.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.Graph.Drives.Item.Items.Item.CreateUploadSession;
|
||||||
|
using Microsoft.Graph.Models;
|
||||||
|
|
||||||
|
namespace OneDriveBackupService;
|
||||||
|
|
||||||
|
public class OneDriveClient {
|
||||||
|
|
||||||
|
private readonly ConfigData _config;
|
||||||
|
private readonly GraphServiceClient _client;
|
||||||
|
|
||||||
|
public OneDriveClient(ConfigData config) {
|
||||||
|
_config = config;
|
||||||
|
|
||||||
|
var credential = new ClientSecretCredential(_config.TenantId, _config.ClientId, _config.ClientSecret);
|
||||||
|
_client = new GraphServiceClient(credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UploadResult<DriveItem>> UploadFile(string filePath, CancellationToken token) {
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
var remoteFilePath = _config.BackupUploadRoot.Trim('/') + '/' + fileName;
|
||||||
|
|
||||||
|
var defaultDrive = await _client.Users[_config.UserId].Drive.GetAsync(cancellationToken: token);
|
||||||
|
|
||||||
|
var driveFile = _client.Drives[defaultDrive!.Id].Items[$"root:/{remoteFilePath}:"]!;
|
||||||
|
var uploadSession = await driveFile.CreateUploadSession.PostAsync(new CreateUploadSessionPostRequestBody {
|
||||||
|
Item = new DriveItemUploadableProperties {
|
||||||
|
AdditionalData = new Dictionary<string, object> {
|
||||||
|
{ "@microsoft.graph.conflictBehavior", "replace" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, cancellationToken: token);
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(filePath);
|
||||||
|
var uploader = new LargeFileUploadTask<DriveItem>(uploadSession, stream);
|
||||||
|
var result = await uploader.UploadAsync(cancellationToken: token);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteOldFiles(CancellationToken token) {
|
||||||
|
var defaultDrive = await _client.Users[_config.UserId].Drive.GetAsync(cancellationToken: token);
|
||||||
|
|
||||||
|
var remoteFolder = _config.BackupUploadRoot.Trim('/');
|
||||||
|
var backupFiles = await _client.Drives[defaultDrive!.Id]
|
||||||
|
.Items[$"root:/{remoteFolder}:"]
|
||||||
|
.Children.GetAsync(cancellationToken: token);
|
||||||
|
|
||||||
|
if (backupFiles == null || backupFiles.Value == null || backupFiles.Value.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var sortedFiles = backupFiles.Value
|
||||||
|
.Where(i =>
|
||||||
|
i.File != null &&
|
||||||
|
i.Name != null &&
|
||||||
|
i.Name.StartsWith("backup_") &&
|
||||||
|
i.Name.EndsWith(".tar.gz"))
|
||||||
|
.OrderByDescending(i => i.CreatedDateTime)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (sortedFiles.Count < _config.KeepLast)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
foreach (var backupFile in sortedFiles.Skip(_config.KeepLast)) {
|
||||||
|
if (backupFile.Id == null) continue;
|
||||||
|
|
||||||
|
await _client.Drives[defaultDrive.Id].Items[backupFile.Id].DeleteAsync(cancellationToken: token);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
Program.cs
Normal file
13
Program.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using OneDriveBackupService;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Configuration.AddEnvironmentVariables();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(new ConfigData(builder.Configuration));
|
||||||
|
builder.Services.AddTransient<OneDriveClient>();
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
12
Properties/launchSettings.json
Normal file
12
Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"OneDriveBackupService": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# OneDrive Backup Service
|
||||||
|
|
||||||
|
Ein .NET 10 Worker-Service zum automatischen Erstellen und Hochladen von Backups auf OneDrive.
|
||||||
|
Unterstützt Cron-basierte Backups sowie manuelles Triggern über `docker exec`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhaltsverzeichnis
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Manuelles Backup](#manuelles-backup)
|
||||||
|
- [Umgebungsvariablen](#umgebungsvariablen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Automatische Backups nach Cron-Schedule (`appsettings.json` / ENV)
|
||||||
|
- Upload zu OneDrive über Microsoft Graph SDK
|
||||||
|
- Behalten nur der letzten N Backups (`KeepLast`)
|
||||||
|
- Unterstützung für manuelles Backup via `docker exec`
|
||||||
|
- Lokale Zeitzone für Logs und Backup-Zeitstempel
|
||||||
|
- Flexible Konfiguration über ENV oder `appsettings.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuelles Backup
|
||||||
|
|
||||||
|
Um ein Backup manuell auf einem laufenden Container auszuführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it onedrive-backup-server dotnet OneDriveBackupService.dll --run-once
|
||||||
|
```
|
||||||
|
|
||||||
|
* Das löst **ein sofortiges Backup** aus
|
||||||
|
* Cron-Loop des Hauptcontainers bleibt ungestört
|
||||||
|
* Backup-Dateien werden wie üblich nach OneDrive hochgeladen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Beschreibung |
|
||||||
|
|----------------|-------------------------------------------------------------|
|
||||||
|
| `Schedule` | Cron-Expression für automatische Backups |
|
||||||
|
| `UploadRoot` | OneDrive Ordner für Backups |
|
||||||
|
| `LocalRoot` | Lokaler Datenpfad für Backups |
|
||||||
|
| `IncludeFile` | Textdatei mit allen unterordnern, die mit ins Backup sollen |
|
||||||
|
| `KeepLast` | Anzahl zu behaltender Backups |
|
||||||
|
| `TenantId` | Azure TenantId |
|
||||||
|
| `ClientId` | Azure ClientId |
|
||||||
|
| `ClientSecret` | Azure Client Secret |
|
||||||
|
| `UserId` | OneDrive User Id |
|
||||||
|
| `TZ` | Zeitzone für Logs / DateTime.Now (optional) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
* Die Backup-Dateien erhalten eindeutige Namen mit Timestamp: `backup_YYYYMMDD_HHMMSS.tar.gz`
|
||||||
|
* Alte Backups werden automatisch gelöscht, basierend auf `KeepLast`
|
||||||
|
* `docker exec --run-once` löst ein manuelles Backup aus, ohne den Cron-Loop zu stoppen
|
||||||
94
Worker.cs
Normal file
94
Worker.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
|
namespace OneDriveBackupService;
|
||||||
|
|
||||||
|
public class Worker(ILogger<Worker> logger, ConfigData config, OneDriveClient client) : BackgroundService {
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--run-once")) {
|
||||||
|
logger.LogInformation("Manual backup triggered");
|
||||||
|
await RunBackup(DateTime.Now, stoppingToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expression = CronExpression.Parse(config.Schedule);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested) {
|
||||||
|
var utcNow = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var cronTime = expression.GetNextOccurrence(utcNow);
|
||||||
|
var delay = cronTime.GetValueOrDefault(utcNow) - utcNow;
|
||||||
|
|
||||||
|
if (delay < TimeSpan.Zero)
|
||||||
|
delay = TimeSpan.Zero;
|
||||||
|
|
||||||
|
if (!cronTime.HasValue) {
|
||||||
|
logger.LogError("Cron expression falied, falling back to default delay");
|
||||||
|
delay = TimeSpan.FromHours(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextRun = DateTime.Now + delay;
|
||||||
|
logger.LogInformation("Next backup run: {time}", nextRun.ToString("f"));
|
||||||
|
await Task.Delay(delay, stoppingToken);
|
||||||
|
|
||||||
|
await RunBackup(nextRun, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunBackup(DateTime optimalTime, CancellationToken stoppingToken) {
|
||||||
|
logger.LogInformation("Starting backup at {now}", DateTime.Now.ToString("f"));
|
||||||
|
|
||||||
|
var file = await CreateBackupArchive(optimalTime, stoppingToken);
|
||||||
|
if (string.IsNullOrEmpty(file)) {
|
||||||
|
logger.LogError("Backup archive creation failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Backup archive created, starting upload...");
|
||||||
|
|
||||||
|
var uploadResult = await client.UploadFile(file, stoppingToken);
|
||||||
|
if (!uploadResult.UploadSucceeded) {
|
||||||
|
logger.LogError("Upload failed");
|
||||||
|
File.Delete(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Upload completed");
|
||||||
|
|
||||||
|
File.Delete(file);
|
||||||
|
var count = await client.DeleteOldFiles(stoppingToken);
|
||||||
|
logger.LogInformation("Deleted {count} old backups", count);
|
||||||
|
|
||||||
|
logger.LogInformation("Backup completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateBackupArchive(DateTime optimalTime, CancellationToken stoppingToken) {
|
||||||
|
var timestamp = optimalTime.ToString("yyyyMMdd_HHmmss");
|
||||||
|
var output = $"/tmp/backup_{timestamp}.tar.gz";
|
||||||
|
|
||||||
|
var process = new Process {
|
||||||
|
StartInfo = new ProcessStartInfo {
|
||||||
|
FileName = "tar",
|
||||||
|
ArgumentList = {
|
||||||
|
"-czf",
|
||||||
|
output,
|
||||||
|
"-C",
|
||||||
|
config.LocalRoot,
|
||||||
|
"-T",
|
||||||
|
Path.GetRelativePath(config.LocalRoot, config.IncludeFile)
|
||||||
|
},
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
await process.WaitForExitAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (process.ExitCode != 0) {
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
appsettings.Development.json
Normal file
8
appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
appsettings.json
Normal file
19
appsettings.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"Schedule": "0 * * * *",
|
||||||
|
"UploadRoot": "Backups",
|
||||||
|
"LocalRoot": "/backups",
|
||||||
|
"IncludeFile": "/backups/include.txt",
|
||||||
|
"KeepLast": 30,
|
||||||
|
|
||||||
|
"TenantId": "",
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"UserId": ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user