485 lines
20 KiB
C#
485 lines
20 KiB
C#
/*
|
|
A simple Backup Client / Server
|
|
Copyright (C) 2023 Mike Nolan
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
using System;
|
|
using System.Threading.Tasks;
|
|
using Tesses.VirtualFilesystem;
|
|
using Tesses.WebServer;
|
|
using Tesses.WebServer.Swagme;
|
|
using LiteDB;
|
|
using System.IO;
|
|
using LiteDB.Engine;
|
|
using Tesses.Backup.Models;
|
|
using System.Security.Cryptography;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Tesses.Backup
|
|
{
|
|
public sealed class BackupServer : IDisposable
|
|
{
|
|
LiteDatabase db;
|
|
Stream strm;
|
|
|
|
MountableServer mountableServer;
|
|
ChangeableServer changeableServer=new ChangeableServer();
|
|
public IServer Server => mountableServer;
|
|
public ChangeableServer WebUi => changeableServer;
|
|
|
|
public IVirtualFilesystem Filesystem => fs;
|
|
private IVirtualFilesystem fs;
|
|
private ILiteCollection<Account> Accounts;
|
|
|
|
private ILiteCollection<Login> Logins;
|
|
|
|
private ILiteCollection<Tesses.Backup.Models.Backup> Backups;
|
|
|
|
private ILiteCollection<Device> Devices;
|
|
public BackupServer(IVirtualFilesystem fs)
|
|
{
|
|
|
|
this.fs = fs;
|
|
Directory.CreateDirectory("data");
|
|
bool exists=File.Exists("data/app.db");
|
|
|
|
|
|
|
|
|
|
db=new LiteDatabase("data/app.db");
|
|
Accounts=db.GetCollection<Account>("Accounts");
|
|
Logins = db.GetCollection<Login>("Logins");
|
|
Backups = db.GetCollection<Tesses.Backup.Models.Backup>("Backups");
|
|
Devices = db.GetCollection<Device>("Devices");
|
|
if(!exists)
|
|
{
|
|
Account account=new Account();
|
|
account.IsAdmin =true;
|
|
account.Username = "admin";
|
|
account.Password = "changeme";
|
|
|
|
Accounts.Insert(account);
|
|
}
|
|
|
|
|
|
mountableServer = new MountableServer(WebUi);
|
|
mountableServer.Mount("/api/v1/",CreateSwagme());
|
|
}
|
|
|
|
private IServer CreateSwagme()
|
|
{
|
|
|
|
SwagmeServer swag=new SwagmeServer();
|
|
swag.AbsoluteUrl=true;
|
|
swag.Add("/Backups",EnumerateBackupsAsync,new SwagmeDocumentation("Enumerate Backups for Device",""),"POST");
|
|
swag.Add("/Devices",EnumerateDevicesAsync,new SwagmeDocumentation("Enumerate Devices for Account",""),"POST");
|
|
swag.Add("/Backup",GetBackupAsync,new SwagmeDocumentation("Get Backup for Device",""),"POST");
|
|
swag.Add("/File",GetFileAsync,new SwagmeDocumentation("Get file",""));
|
|
swag.Add("/File",BackupFileAsync,new SwagmeDocumentation("Backup File",""),"PUT");
|
|
swag.Add("/Login",LoginAsync,new SwagmeDocumentation("Login",""),"POST");
|
|
swag.Add("/Logout",LogoutAsync,new SwagmeDocumentation("Logout",""),"POST");
|
|
swag.Add("/Device",DeviceAsync,new SwagmeDocumentation("Device",""),"POST");
|
|
swag.Add("/CreateBackup",CreateBackupAsync,new SwagmeDocumentation("Create Backup",""),"POST");
|
|
swag.Add("/ChangePassword",ChangePasswordAsync,new SwagmeDocumentation("Change Password",""),"POST");
|
|
swag.Add("/CreateUser",CreateUserAsync,new SwagmeDocumentation("Create User",""),"POST");
|
|
return swag;
|
|
}
|
|
|
|
|
|
|
|
private (Account account,bool success,string failTxt) GetAccountFromKey(string key)
|
|
{
|
|
var login=Logins.FindOne(e=>e.Key == key);
|
|
if(login != null)
|
|
{
|
|
var account = Accounts.FindOne(e=>e.Id == login.UserId);
|
|
if(account == null)
|
|
{
|
|
|
|
Logins.Delete(login.Id);
|
|
return (null,false,"No user account");
|
|
}
|
|
return (account,true,"");
|
|
}
|
|
return (null,false,"Invalid Session");
|
|
}
|
|
|
|
|
|
private async Task EnumerateBackupsAsync(ServerContext ctx)
|
|
{
|
|
var backupRequest= await ctx.ReadJsonAsync<EnumerateBackupsRequest>();
|
|
var (account,success,failTxt) = GetAccountFromKey(backupRequest.Key);
|
|
var backupResponse = new EnumerateBackupsResponse();
|
|
backupResponse.Success = success;
|
|
backupResponse.ErrorData = failTxt;
|
|
if(success)
|
|
{
|
|
var backups = Backups.Find(e=>e.UserId == account.Id);
|
|
foreach(var bkp in backups)
|
|
{
|
|
if(bkp.DeviceId == backupRequest.DeviceId)
|
|
{
|
|
BackupEntry entry=new BackupEntry();
|
|
entry.BackupId = bkp.Id;
|
|
entry.Creation = bkp.Creation;
|
|
backupResponse.Backups.Add(entry);
|
|
}
|
|
}
|
|
}
|
|
await ctx.SendJsonAsync(backupResponse);
|
|
}
|
|
|
|
private async Task EnumerateDevicesAsync(ServerContext ctx)
|
|
{
|
|
var deviceRequest= await ctx.ReadJsonAsync<EnumerateDeviceRequest>();
|
|
var (account,success,failTxt) = GetAccountFromKey(deviceRequest.Key);
|
|
|
|
var deviceResponse = new EnumerateDeviceResponse();
|
|
deviceResponse.Success = success;
|
|
deviceResponse.ErrorData = failTxt;
|
|
if(success)
|
|
{
|
|
var devices = Devices.Find(e=>e.UserId == account.Id);
|
|
foreach(var device in devices)
|
|
{
|
|
DeviceEntry entry=new DeviceEntry();
|
|
entry.DeviceId = device.Id;
|
|
entry.DeviceName = device.DeviceName;
|
|
deviceResponse.Devices.Add(entry);
|
|
}
|
|
}
|
|
await ctx.SendJsonAsync(deviceResponse);
|
|
}
|
|
private async Task DeviceAsync(ServerContext ctx)
|
|
{
|
|
var deviceRequest= await ctx.ReadJsonAsync<GetDeviceRequest>();
|
|
var (account,success,failTxt) = GetAccountFromKey(deviceRequest.Key);
|
|
|
|
var deviceResponse = new GetDeviceResponse();
|
|
deviceResponse.Success = success;
|
|
deviceResponse.ErrorData = failTxt;
|
|
|
|
if(success)
|
|
{
|
|
long? deviceId=null;
|
|
var devices = Devices.Find(e=>e.UserId == account.Id);
|
|
foreach(var item in devices)
|
|
{
|
|
if(item.DeviceName == deviceRequest.Name)
|
|
{
|
|
deviceId = item.Id;
|
|
break;
|
|
}
|
|
}
|
|
deviceResponse.Existing = deviceId.HasValue;
|
|
if(deviceId.HasValue)
|
|
{
|
|
deviceResponse.DeviceId = deviceId.Value;
|
|
}else{
|
|
Device dev = new Device();
|
|
dev.DeviceName = deviceRequest.Name;
|
|
dev.UserId = account.Id;
|
|
deviceResponse.DeviceId = Devices.Insert(dev).AsInt64;
|
|
}
|
|
|
|
}
|
|
await ctx.SendJsonAsync(deviceResponse);
|
|
}
|
|
|
|
private async Task LogoutAsync(ServerContext ctx)
|
|
{
|
|
var logoutRequest= await ctx.ReadJsonAsync<LogoutRequest>();
|
|
var login=Logins.FindOne(e=>e.Key == logoutRequest.Key);
|
|
|
|
var logoutResponse = new LogoutResponse();
|
|
logoutResponse.Success =false;
|
|
logoutResponse.ErrorData="Not logged in";
|
|
if(login != null)
|
|
{
|
|
logoutResponse.Success =true;
|
|
logoutResponse.ErrorData="";
|
|
Logins.Delete(login.Id);
|
|
}
|
|
await ctx.SendJsonAsync(logoutResponse);
|
|
}
|
|
|
|
private async Task LoginAsync(ServerContext ctx)
|
|
{
|
|
var loginRequest= await ctx.ReadJsonAsync<LoginRequest>();
|
|
var account = Accounts.FindOne(e=>e.Username == loginRequest.UserName);
|
|
var loginResponse=new LoginResponse();
|
|
if(account != null)
|
|
{
|
|
if(account.IsCorrectPassword(loginRequest.Password))
|
|
{
|
|
var login = Login.Create(account.Id);
|
|
loginResponse.Key = login.Key;
|
|
loginResponse.ErrorData="";
|
|
loginResponse.Success=true;
|
|
Logins.Insert(login);
|
|
}
|
|
else{
|
|
loginResponse.ErrorData = $"Username or password incorrect";
|
|
loginResponse.Success=false;
|
|
}
|
|
}else{
|
|
loginResponse.ErrorData = $"Username or password incorrect";
|
|
loginResponse.Success=false;
|
|
}
|
|
await ctx.SendJsonAsync(loginResponse);
|
|
}
|
|
|
|
private async Task BackupFileAsync(ServerContext ctx)
|
|
{
|
|
//key=KEY&device=Id&hash=HASH&mime=
|
|
string key;
|
|
string device;
|
|
string hash;
|
|
long deviceId;
|
|
|
|
if(ctx.QueryParams.TryGetFirst("key",out key) && ctx.QueryParams.TryGetFirst("device",out device) && ctx.QueryParams.TryGetFirst("hash",out hash) && long.TryParse(device,out deviceId))
|
|
{
|
|
|
|
|
|
var (account,success,failTxt) = GetAccountFromKey(key);
|
|
|
|
if(success)
|
|
{
|
|
UnixPath p = Special.Root / "backups" / account.Id.ToString() / deviceId.ToString() / "files" / hash + ".bin";
|
|
await Filesystem.CreateDirectoryAsync(p.Parent);
|
|
if(!Filesystem.FileExists(p))
|
|
{
|
|
using(var strm = Filesystem.Open(p,FileMode.Create,FileAccess.Write,FileShare.None))
|
|
{
|
|
await ctx.ReadToStreamAsync(strm);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
private async Task GetFileAsync(ServerContext ctx)
|
|
{
|
|
//key=KEY&device=Id&hash=HASH&mime=
|
|
string key;
|
|
string device;
|
|
string hash;
|
|
long deviceId;
|
|
string mime;
|
|
if(ctx.QueryParams.TryGetFirst("key",out key) && ctx.QueryParams.TryGetFirst("device",out device) && ctx.QueryParams.TryGetFirst("hash",out hash) && long.TryParse(device,out deviceId))
|
|
{
|
|
if(!ctx.QueryParams.TryGetFirst("mime",out mime))
|
|
{
|
|
mime="application/octet-stream";
|
|
}
|
|
|
|
var (account,success,failTxt) = GetAccountFromKey(key);
|
|
|
|
if(success)
|
|
{
|
|
UnixPath p = Special.Root / "backups" / account.Id.ToString() / deviceId.ToString() / "files" / hash + ".bin";
|
|
if(!Filesystem.FileExists(p))
|
|
{
|
|
ctx.StatusCode = 404;
|
|
await ctx.SendTextAsync("The file you requested could not be found","text/plain");
|
|
return;
|
|
}
|
|
using(var strm = Filesystem.Open(p,FileMode.Open,FileAccess.Read,FileShare.Read))
|
|
{
|
|
await ctx.SendStreamAsync(strm,mime);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task GetBackupAsync(ServerContext ctx)
|
|
{
|
|
var getBackupRequest= await ctx.ReadJsonAsync<EnumerateBackupRequest>();
|
|
var (account,success,failTxt) = GetAccountFromKey(getBackupRequest.Key);
|
|
|
|
var getBackupResponse = new EnumerateBackupResponse();
|
|
getBackupResponse.Success = success;
|
|
getBackupResponse.ErrorData = failTxt;
|
|
|
|
if(success)
|
|
{
|
|
var backup = Backups.FindOne(e=>e.Id == getBackupRequest.BackupId);
|
|
if(backup != null)
|
|
{
|
|
if(backup.UserId != account.Id)
|
|
{
|
|
getBackupResponse.ErrorData = "This backup is not yours";
|
|
getBackupResponse.Success =false;
|
|
await ctx.SendJsonAsync(getBackupResponse);
|
|
return;
|
|
}
|
|
getBackupResponse.Creation = backup.Creation;
|
|
getBackupResponse.BackupData = backup.Files;
|
|
}
|
|
else
|
|
{
|
|
getBackupResponse.ErrorData = "The backup does not exist";
|
|
getBackupResponse.Success =false;
|
|
}
|
|
|
|
}
|
|
await ctx.SendJsonAsync(getBackupResponse);
|
|
}
|
|
private async Task CreateBackupAsync(ServerContext ctx)
|
|
{
|
|
var createBackupRequest= await ctx.ReadJsonAsync<CreateBackupRequest>();
|
|
var (account,success,failTxt) = GetAccountFromKey(createBackupRequest.Key);
|
|
|
|
var createBackupResponse = new CreateBackupResponse();
|
|
createBackupResponse.Success = success;
|
|
createBackupResponse.ErrorData = failTxt;
|
|
|
|
if(success)
|
|
{
|
|
var device = Devices.FindOne(e=>e.Id ==createBackupRequest.DeviceId);
|
|
if(device != null)
|
|
{
|
|
if(device.UserId != account.Id)
|
|
{
|
|
createBackupResponse.ErrorData = "This device is not yours";
|
|
createBackupResponse.Success =false;
|
|
await ctx.SendJsonAsync(createBackupResponse);
|
|
return;
|
|
}
|
|
Models.Backup backup=new Models.Backup();
|
|
backup.Creation = DateTime.Now;
|
|
backup.Files.AddRange(createBackupRequest.BackupData);
|
|
backup.UserId = account.Id;
|
|
backup.DeviceId = device.Id;
|
|
CreateBackupList(account.Id,device.Id,createBackupRequest.BackupData,createBackupResponse.BackupData);
|
|
long id= Backups.Insert(backup).AsInt64;
|
|
createBackupResponse.BackupId = id;
|
|
}else{
|
|
createBackupResponse.ErrorData = "No Such Device";
|
|
createBackupResponse.Success =false;
|
|
}
|
|
|
|
}
|
|
await ctx.SendJsonAsync(createBackupResponse);
|
|
}
|
|
|
|
private void CreateBackupList(long account, long device, List<BackupFileEntry> toBackup, List<BackupFileEntry> filesToBackup)
|
|
{
|
|
foreach(var entry in toBackup)
|
|
{
|
|
if(!entry.IsFile)
|
|
{
|
|
BackupFileEntry dir = new BackupFileEntry();
|
|
dir.Created = entry.Created;
|
|
dir.Length = 0;
|
|
dir.Modified = entry.Modified;
|
|
dir.IsFile =false;
|
|
dir.Name = entry.Name;
|
|
CreateBackupList(account,device,entry.SubEntries,dir.SubEntries);
|
|
filesToBackup.Add(dir);
|
|
}else{
|
|
UnixPath p = Special.Root / "backups" / account.ToString() / device.ToString() / "files" / entry.Hash + ".bin";
|
|
if(!Filesystem.FileExists(p))
|
|
{
|
|
filesToBackup.Add(entry);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private async Task CreateUserAsync(ServerContext ctx)
|
|
{
|
|
var createUserRequest= await ctx.ReadJsonAsync<CreateUserRequest>();
|
|
var (account,success,failTxt) = GetAccountFromKey(createUserRequest.Key);
|
|
|
|
var createUserResponse = new CreateUserResponse();
|
|
createUserResponse.Success = success;
|
|
createUserResponse.ErrorData = failTxt;
|
|
if(success)
|
|
{
|
|
if(account.IsAdmin)
|
|
{
|
|
var _account_check = Accounts.FindOne(e=>e.Username == createUserRequest.Username);
|
|
if(_account_check != null)
|
|
{
|
|
createUserResponse.Success = false;
|
|
createUserResponse.ErrorData = "User already exists";
|
|
}
|
|
else
|
|
{
|
|
if(!string.IsNullOrWhiteSpace(createUserRequest.Password) && createUserRequest.Password.Length >= 8 && createUserRequest.Password == createUserRequest.ConfirmPassword)
|
|
{
|
|
var newAccount=new Account();
|
|
newAccount.Password=createUserRequest.Password;
|
|
newAccount.Username = createUserRequest.Username;
|
|
newAccount.IsAdmin = createUserRequest.IsAdmin;
|
|
Accounts.Insert(newAccount);
|
|
}
|
|
else
|
|
{
|
|
createUserResponse.ErrorData = $"Passwords do not match or are too short";
|
|
createUserResponse.Success=false;
|
|
}
|
|
}
|
|
}else{
|
|
createUserResponse.Success = false;
|
|
createUserResponse.ErrorData = "Only admins can create users";
|
|
}
|
|
}
|
|
await ctx.SendJsonAsync(createUserResponse);
|
|
}
|
|
|
|
private async Task ChangePasswordAsync(ServerContext ctx)
|
|
{
|
|
|
|
var loginRequest= await ctx.ReadJsonAsync<ChangePasswordRequest>();
|
|
var account = Accounts.FindOne(e=>e.Username == loginRequest.UserName);
|
|
var loginResponse=new ChangePasswordResponse();
|
|
if(account != null)
|
|
{
|
|
if(account.IsCorrectPassword(loginRequest.OldPassword))
|
|
{
|
|
if(!string.IsNullOrWhiteSpace(loginRequest.NewPassword) && loginRequest.NewPassword.Length >= 8 && loginRequest.NewPassword == loginRequest.ConfirmNewPassword)
|
|
{
|
|
loginResponse.Success=true;
|
|
account.Password=loginRequest.NewPassword;
|
|
Accounts.Update(account);
|
|
}
|
|
else
|
|
{
|
|
loginResponse.ErrorData = $"Passwords do not match or are too short";
|
|
loginResponse.Success=false;
|
|
}
|
|
}
|
|
else{
|
|
loginResponse.ErrorData = $"Username or password incorrect";
|
|
loginResponse.Success=false;
|
|
}
|
|
}else{
|
|
loginResponse.ErrorData = $"Username or password incorrect";
|
|
loginResponse.Success=false;
|
|
}
|
|
await ctx.SendJsonAsync(loginResponse);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
db.Dispose();
|
|
strm.Dispose();
|
|
|
|
}
|
|
}
|
|
}
|