Archived
Private
Public Access
1
0

Fixed some bugs + added custom domain support

This commit is contained in:
2023-03-25 20:19:33 +01:00
parent 6dfb5a4b48
commit 16dfadaa7d
19 changed files with 104 additions and 47 deletions

View File

@@ -8,19 +8,21 @@ public interface IProjectApi {
public Project[] GetProjects(string userId); public Project[] GetProjects(string userId);
public Project GetProject(string projectId); public Project GetProject(string projectId);
public Task<string> AddProject(string name, string ownerId); public Task<string> AddProject(string name, string ownerId);
public bool EditProject(string projectId, string name); public Task<bool> EditProject(string projectId, string name, string domain);
public Task DeleteProject(string projectId); public Task DeleteProject(string projectId);
} }
public class ProjectApi : IProjectApi { public class ProjectApi : IProjectApi {
private readonly DatabaseContext _context; private readonly DatabaseContext _context;
private readonly GeneralOptions _options; private readonly GeneralOptions _options;
private readonly ProxyOptions _proxyOptions;
private readonly IDockerApi _docker; private readonly IDockerApi _docker;
private readonly IProxyApi _proxy; private readonly IProxyApi _proxy;
public ProjectApi(DatabaseContext context, IOptions<GeneralOptions> options, IDockerApi docker, IProxyApi proxy) { public ProjectApi(DatabaseContext context, IOptions<GeneralOptions> options, IOptions<ProxyOptions> proxyOptions, IDockerApi docker, IProxyApi proxy) {
_context = context; _context = context;
_options = options.Value; _options = options.Value;
_proxyOptions = proxyOptions.Value;
_docker = docker; _docker = docker;
_proxy = proxy; _proxy = proxy;
} }
@@ -49,12 +51,13 @@ public class ProjectApi : IProjectApi {
Name = name, Name = name,
Port = port Port = port
}; };
project.Domain = _proxyOptions.Enable ? $"{project.ProjectId}.{_proxyOptions.Domain}" : $"{_proxyOptions.Host}:{project.Port}";
var container = await _docker.CreateContainer($"ghcr.io/muchobien/pocketbase:{_options.PocketBaseVersion}", port, _options.Root + project.ProjectId, $"{project.Name}_{project.ProjectId}"); var container = await _docker.CreateContainer($"ghcr.io/muchobien/pocketbase:{_options.PocketBaseVersion}", port, _options.Root + project.ProjectId, $"{project.Name}_{project.ProjectId}");
await _docker.StartContainer(container); await _docker.StartContainer(container);
project.ContainerName = container; project.ContainerName = container;
var (proxyId, certificateId) = await _proxy.AddLocation(project.ProjectId, project.Port); var (proxyId, certificateId) = await _proxy.AddLocation(project.Domain, project.Port);
project.ProxyId = proxyId; project.ProxyId = proxyId;
project.CertificateId = certificateId; project.CertificateId = certificateId;
@@ -64,13 +67,22 @@ public class ProjectApi : IProjectApi {
return project.ProjectId; return project.ProjectId;
} }
public bool EditProject(string projectId, string name) { public async Task<bool> EditProject(string projectId, string name, string domain) {
if (name.Length > 255) return false; if (name.Length > 255) return false;
if (domain.Length > 255) return false;
var project = GetProject(projectId); var project = GetProject(projectId);
if (project == null) return false; if (project == null) return false;
project.Name = name; project.Name = name;
if (!string.IsNullOrEmpty(domain)) {
project.Domain = domain;
var data = await _proxy.UpdateLocation(project);
project.ProxyId = data.Item1;
project.CertificateId = data.Item2;
}
_context.Projects.Update(project); _context.Projects.Update(project);
_context.SaveChanges(); await _context.SaveChangesAsync();
return true; return true;
} }

View File

@@ -1,13 +1,15 @@
using System.Dynamic; using System.Dynamic;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Options; using ProjectManager.Backend.Options;
namespace ProjectManager.Backend.Apis; namespace ProjectManager.Backend.Apis;
public interface IProxyApi { public interface IProxyApi {
public Task<(int, int)> AddLocation(string projectId, int port); public Task<(int, int)> AddLocation(string domain, int port);
public Task RemoveLocation(int proxyId, int certificateId); public Task RemoveLocation(int proxyId, int certificateId);
public Task<(int, int)> UpdateLocation(Project project);
} }
public sealed class ProxyApi : IProxyApi { public sealed class ProxyApi : IProxyApi {
@@ -27,11 +29,11 @@ public sealed class ProxyApi : IProxyApi {
_client.DefaultRequestHeaders.Add("Authorization", $"Bearer {response?.token}"); _client.DefaultRequestHeaders.Add("Authorization", $"Bearer {response?.token}");
} }
public async Task<(int, int)> AddLocation(string projectId, int port) { public async Task<(int, int)> AddLocation(string domain, int port) {
if (!_options.Enable) return (-1, -1); if (!_options.Enable) return (-1, -1);
await Login(); await Login();
var result = await _client.PostAsJsonAsync(_options.Url + "/api/nginx/proxy-hosts", var result = await _client.PostAsJsonAsync(_options.Url + "/api/nginx/proxy-hosts",
new CreateData($"{projectId}.{_options.Domain}", _options.Host, port, _options.Email)); new ProxyData(domain, _options.Host, port, _options.Email));
dynamic data = await result.Content.ReadFromJsonAsync<ExpandoObject>(); dynamic data = await result.Content.ReadFromJsonAsync<ExpandoObject>();
if (data == null) return (-1, -1); if (data == null) return (-1, -1);
int id = Convert.ToInt32($"{data.id}"); int id = Convert.ToInt32($"{data.id}");
@@ -46,9 +48,15 @@ public sealed class ProxyApi : IProxyApi {
await _client.DeleteAsync(_options.Url + "/api/nginx/certificates/" + certificateId); await _client.DeleteAsync(_options.Url + "/api/nginx/certificates/" + certificateId);
} }
public async Task<(int, int)> UpdateLocation(Project project) {
if (!_options.Enable) return (-1, -1);
await RemoveLocation(project.ProxyId, project.CertificateId);
return await AddLocation(project.Domain, project.Port);
}
private sealed record ProxyAuth(string identity, string secret); private sealed record ProxyAuth(string identity, string secret);
private sealed record TokenResponse(string token, string expires); private sealed record TokenResponse(string token, string expires);
private sealed class CreateData { private sealed class ProxyData {
public string access_list_id { get; set; } = "0"; public string access_list_id { get; set; } = "0";
public string advanced_config { get; set; } = " location / {\r\n proxy_pass http://%docker_ip%;\r\n proxy_hide_header X-Frame-Options;\r\n }"; public string advanced_config { get; set; } = " location / {\r\n proxy_pass http://%docker_ip%;\r\n proxy_hide_header X-Frame-Options;\r\n }";
public bool allow_websocket_upgrade { get; set; } = true; public bool allow_websocket_upgrade { get; set; } = true;
@@ -66,7 +74,7 @@ public sealed class ProxyApi : IProxyApi {
public bool ssl_forced { get; set; } = true; public bool ssl_forced { get; set; } = true;
public SslMeta meta { get; set; } public SslMeta meta { get; set; }
public CreateData(string domain, string ip, int port, string email) { public ProxyData(string domain, string ip, int port, string email) {
domain_names = new[] { domain }; domain_names = new[] { domain };
forward_host = ip; forward_host = ip;
forward_port = port; forward_port = port;

View File

@@ -70,11 +70,11 @@ public class ProjectController : ControllerBase {
[Authorized] [Authorized]
[HttpPut("{projectId}")] [HttpPut("{projectId}")]
public IActionResult EditProject(string projectId, [FromBody] ProjectEdit edit) { public async Task<IActionResult> EditProject(string projectId, [FromBody] ProjectEdit edit) {
var project = _projects.GetProject(projectId); var project = _projects.GetProject(projectId);
if (project == null) return NotFound(); if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized(); if (project.OwnerId != _context.UserId) return Unauthorized();
_projects.EditProject(projectId, edit.Name); await _projects.EditProject(projectId, edit.Name, edit.Domain);
return Ok(); return Ok();
} }
@@ -84,7 +84,7 @@ public class ProjectController : ControllerBase {
var project = _projects.GetProject(projectId); var project = _projects.GetProject(projectId);
if (project == null) return NotFound(); if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized(); if (project.OwnerId != _context.UserId) return Unauthorized();
if (_options.Enable) return Redirect($"https://{projectId}.{_options.Domain}/_/"); if (_options.Enable) return Redirect($"https://{project.Domain}/_/");
return Redirect($"http://{_options.Host}:{project.Port}/_/"); return Redirect($"http://{_options.Host}:{project.Port}/_/");
} }
@@ -94,7 +94,7 @@ public class ProjectController : ControllerBase {
var project = _projects.GetProject(projectId); var project = _projects.GetProject(projectId);
if (project == null) return NotFound(); if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized(); if (project.OwnerId != _context.UserId) return Unauthorized();
if (_options.Enable) return Ok(new {url = $"https://{projectId}.{_options.Domain}/_/"}); if (_options.Enable) return Ok(new {url = $"https://{project.Domain}/_/"});
return Ok(new {url = $"http://{_options.Host}:{project.Port}/_/"}); return Ok(new {url = $"http://{_options.Host}:{project.Port}/_/"});
} }

View File

@@ -4,6 +4,7 @@ public class Project {
public string ProjectId { get; set; } public string ProjectId { get; set; }
public string OwnerId { get; set; } public string OwnerId { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Domain { get; set; }
public int Port { get; set; } public int Port { get; set; }
public string ContainerName { get; set; } public string ContainerName { get; set; }
public int ProxyId { get; set; } = -1; public int ProxyId { get; set; } = -1;
@@ -12,4 +13,5 @@ public class Project {
public class ProjectEdit { public class ProjectEdit {
public string Name { get; set; } public string Name { get; set; }
public string Domain { get; set; }
} }

View File

@@ -1,6 +1,6 @@
<h1 mat-dialog-title *ngIf="data.title != undefined">{{data.title}}</h1> <h1 mat-dialog-title *ngIf="data.title != undefined">{{data.title}}</h1>
<div mat-dialog-content *ngIf="data.subtitle != undefined">{{data.subtitle}}</div> <div mat-dialog-content *ngIf="data.subtitle != undefined">{{data.subtitle}}</div>
<div mat-dialog-actions *ngIf="data.buttons != undefined" id="buttons"> <div mat-dialog-actions *ngIf="data.buttons != undefined" class="buttons">
<button mat-button <button mat-button
(click)="dialogRef.close(button.value)" (click)="dialogRef.close(button.value)"
*ngFor="let button of data.buttons" *ngFor="let button of data.buttons"

View File

@@ -1,4 +1,4 @@
#buttons { .buttons {
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;

View File

@@ -6,6 +6,7 @@ export interface DialogData {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
buttons?: {text: string, value: any, color: ThemePalette}[]; buttons?: {text: string, value: any, color: ThemePalette}[];
secondInput?: string;
} }
@Component({ @Component({

View File

@@ -4,10 +4,14 @@
<mat-label>{{data.subtitle}}</mat-label> <mat-label>{{data.subtitle}}</mat-label>
<input type="text" matInput #text> <input type="text" matInput #text>
</mat-form-field> </mat-form-field>
<mat-form-field [ngStyle]="{'display': data.secondInput ? 'block' : 'none'}">
<mat-label>{{data.secondInput}}</mat-label>
<input type="text" matInput #text2>
</mat-form-field>
</form> </form>
<div mat-dialog-actions *ngIf="data.buttons != undefined" id="buttons"> <div mat-dialog-actions *ngIf="data.buttons != undefined" class="buttons">
<button mat-button <button mat-button
(click)="dialogRef.close({success: button.value, data: text.value})" (click)="dialogRef.close({success: button.value, data: text.value, data2: text2?.value})"
*ngFor="let button of data.buttons" *ngFor="let button of data.buttons"
[color]="button.color" [color]="button.color"
>{{button.text}}</button> >{{button.text}}</button>

View File

@@ -0,0 +1,13 @@
form {
width: 350px;
& > * {
width: 100%;
}
}
.buttons {
width: 100%;
display: flex;
justify-content: space-evenly;
}

View File

@@ -12,4 +12,5 @@ export class TextDialogComponent {
public dialogRef: MatDialogRef<TextDialogComponent>, public dialogRef: MatDialogRef<TextDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: DialogData, @Inject(MAT_DIALOG_DATA) public data: DialogData,
) {} ) {}
} }

View File

@@ -16,12 +16,16 @@
open: string, open: string,
edit: string, edit: string,
delete: string, delete: string,
deleteProjQuestion: string,
// Popups // Popups
name: string, name: string,
domain: string,
cancel: string, cancel: string,
createProject: string, createProject: string,
editProject: string editProject: string,
updateProject: string,
deleteProject: string,
// Profile // Profile
profile: string, profile: string,

View File

@@ -2,6 +2,7 @@
projectId?: string; projectId?: string;
ownerId?: string; ownerId?: string;
name?: string; name?: string;
domain?: string;
port?: number; port?: number;
containerName?: string; containerName?: string;
proxyId?: number; proxyId?: number;

View File

@@ -21,19 +21,19 @@ export class LangService {
const res = await firstValueFrom(this.crud.client.get<{files: string[]}>(location?.origin + "/lang")); const res = await firstValueFrom(this.crud.client.get<{files: string[]}>(location?.origin + "/lang"));
const languages = res.files; const languages = res.files;
this.allLanguages = languages.map(lang => lang.replace(".json", "")); this.allLanguages = languages.map(lang => lang.replace(".json", ""));
const tasks = []; this.currentLang = await this.loadLanguage(this.storage.getItem("language") || "en-US");
for (let lang of languages) {
const task = firstValueFrom(this.crud.client.get<Language>(location?.origin + "/assets/languages/" + lang))
.then(result => this.languages.set(lang.replace(".json", ""), result));
tasks.push(task);
}
await Promise.all(tasks);
this.currentLang = this.languages.get(this.storage.getItem("language") || "en-US");
} }
public setLanguage(lang: string) { private async loadLanguage(name: string): Promise<Language> {
const lang = await firstValueFrom(this.crud.client.get<Language>(`${location.origin}/assets/languages/${name}.json`));
this.languages.set(name, lang);
return lang;
}
public async setLanguage(lang: string) {
if (this.languages.get(lang) == undefined)
await this.loadLanguage(lang);
this.currentLang = this.languages.get(lang); this.currentLang = this.languages.get(lang);
this.storage.setItem("language", lang); this.storage.setItem("language", lang);
} }

View File

@@ -31,8 +31,8 @@ export class ProjectService {
return response.content?.projectId; return response.content?.projectId;
} }
public async editProject(projectId: string, name: string): Promise<boolean> { public async editProject(projectId: string, name: string, domain: string): Promise<boolean> {
const response = await this.crud.sendPutRequest("projects/" + projectId, {name}); const response = await this.crud.sendPutRequest("projects/" + projectId, {name, domain});
return response.success; return response.success;
} }

View File

@@ -33,24 +33,26 @@ export class DashboardComponent {
public async editProject(projectId: string) { public async editProject(projectId: string) {
const dialogRef = this.dialog.open(TextDialogComponent, { const dialogRef = this.dialog.open(TextDialogComponent, {
data: {title: "Projekt umbenennen", subtitle: "Name", buttons: [ data: {title: this.langs.currentLang?.editProject, subtitle: "Name", secondInput: this.langs.currentLang?.domain, buttons: [
{text: "Abbrechen", value: false}, {text: this.langs.currentLang?.cancel, value: false},
{text: "Projekt bearbeiten", value: true, color: 'primary'} {text: this.langs.currentLang?.editProject, value: true, color: 'primary'}
]} ]}
}); });
const result = await firstValueFrom(dialogRef.afterClosed()) as {success: boolean, data: string}; const result = await firstValueFrom(dialogRef.afterClosed()) as {success: boolean, data: string, data2: string};
if (!result?.success) return; if (!result?.success) return;
await this.projects.editProject(projectId, result.data); NavigationComponent.spinnerVisible = true;
await this.projects.editProject(projectId, result.data, result.data2);
NavigationComponent.spinnerVisible = false;
await this.projects.loadProjects(); await this.projects.loadProjects();
this.snackBar.open("Projekt aktualisiert!", undefined, {duration: 2000}); this.snackBar.open(this.langs.currentLang?.updateProject, undefined, {duration: 2000});
} }
public async deleteProject(projectId: string) { public async deleteProject(projectId: string) {
const dialogRef = this.dialog.open(DialogComponent, { const dialogRef = this.dialog.open(DialogComponent, {
data: {title: "Möchtest du das Projekt wirklich löschen?", subtitle: "Alle gespeicherten Daten gehen dann verloren!", buttons: [ data: {title: this.langs.currentLang?.deleteProjQuestion, subtitle: this.langs.currentLang?.deleteWarning, buttons: [
{text: "Abbrechen", value: false}, {text: this.langs.currentLang?.cancel, value: false},
{text: "Löschen", value: true, color: 'warn'} {text: this.langs.currentLang?.delete, value: true, color: 'warn'}
]} ]}
}); });
@@ -60,7 +62,7 @@ export class DashboardComponent {
await this.projects.deleteProject(projectId); await this.projects.deleteProject(projectId);
NavigationComponent.spinnerVisible = false; NavigationComponent.spinnerVisible = false;
await this.projects.loadProjects(); await this.projects.loadProjects();
this.snackBar.open("Projekt gelöscht!", undefined, {duration: 2000}); this.snackBar.open(this.langs.currentLang?.deleteProject, undefined, {duration: 2000});
} }
public async updateProjectStatus(projectId: string, start: boolean) { public async updateProjectStatus(projectId: string, start: boolean) {

View File

@@ -14,11 +14,15 @@
"open": "Öffnen", "open": "Öffnen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
"deleteProjQuestion": "Möchtest du dieses Projekt wirklich löschen?",
"name": "Name", "name": "Name",
"domain": "Domain",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"createProject": "Projekt erstellen", "createProject": "Projekt erstellen",
"editProject": "Projekt bearbeiten", "editProject": "Projekt bearbeiten",
"updateProject": "Projekt aktualisiert!",
"deleteProject": "Projekt gelöscht!",
"profile": "Profil", "profile": "Profil",
"profileSub": "Einstellungen", "profileSub": "Einstellungen",
@@ -32,7 +36,7 @@
"updateFailed": "Aktualisierung fehlgeschlagen", "updateFailed": "Aktualisierung fehlgeschlagen",
"accountUpdated": "Account aktualisiert!", "accountUpdated": "Account aktualisiert!",
"deleteQuestion": "Möchtest du deinen Account wirklich löschen?", "deleteQuestion": "Möchtest du deinen Account wirklich löschen?",
"deleteWarning": "All deine Projekte werden für immer gelöscht!", "deleteWarning": "Alle Daten werden für immer gelöscht!",
"accountDeleted": "Account gelöscht!", "accountDeleted": "Account gelöscht!",
"submit": "Bestätigen", "submit": "Bestätigen",

View File

@@ -14,11 +14,15 @@
"open": "Open", "open": "Open",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"deleteProjQuestion": "Do you really want to delete this project?",
"name": "Name", "name": "Name",
"domain": "Domain",
"cancel": "Cancel", "cancel": "Cancel",
"createProject": "Create project", "createProject": "Create project",
"editProject": "Edit project", "editProject": "Edit project",
"updateProject": "Updated project!",
"deleteProject": "Deleted project!",
"profile": "Profile", "profile": "Profile",
"profileSub": "Settings", "profileSub": "Settings",
@@ -32,7 +36,7 @@
"updateFailed": "Update failed", "updateFailed": "Update failed",
"accountUpdated": "Account updated", "accountUpdated": "Account updated",
"deleteQuestion": "Do you really want to delete your account?", "deleteQuestion": "Do you really want to delete your account?",
"deleteWarning": "All your data will be lost!", "deleteWarning": "All data will be lost forever!",
"accountDeleted": "Account deleted!", "accountDeleted": "Account deleted!",
"submit": "Submit", "submit": "Submit",

View File

@@ -3,6 +3,7 @@ CREATE TABLE `Projects` (
`ownerId` varchar(36) DEFAULT NULL, `ownerId` varchar(36) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL, `name` varchar(255) DEFAULT NULL,
`port` int(5) DEFAULT NULL, `port` int(5) DEFAULT NULL,
`domain` varchar(255) DEFAULT NULL,
`containerName` varchar(255) DEFAULT NULL, `containerName` varchar(255) DEFAULT NULL,
`proxyId` int(30) DEFAULT NULL, `proxyId` int(30) DEFAULT NULL,
`certificateId` int(30) DEFAULT NULL `certificateId` int(30) DEFAULT NULL

View File

@@ -6,9 +6,9 @@ Es handelt sich hierbei um ein einfach zu benutzendes WebInterface zum Erstellen
- [x] Automatische Docker Konfiguration - [x] Automatische Docker Konfiguration
- [x] Automatisches DNS Mapping - [x] Automatisches DNS Mapping
- [x] Automatische SSL-Konfiguration mithilfe von NginxProxyManager - [x] Automatische SSL-Konfiguration mithilfe von NginxProxyManager
- [x] Mehrere Sprachen
- [x] Eigene Domains
- [ ] Projekte exportieren / importieren - [ ] Projekte exportieren / importieren
- [ ] Eigene Domains
- [ ] Mehrere Sprachen
## Installation ## Installation
Die Installation erfolgt durch eine Docker-Compose Datei. Hierbei werden zwei Container, einer für das Backend und einer für das Frontend, gestartet. Die Installation erfolgt durch eine Docker-Compose Datei. Hierbei werden zwei Container, einer für das Backend und einer für das Frontend, gestartet.