28 Commits
v1b15 ... v1b16

Author SHA1 Message Date
Marcel Baumgartner
388deacf60 Merge pull request #250 from Moonlight-Panel/ImproveInstallUX
Improved install user experience
2023-08-08 00:54:10 +02:00
Marcel Baumgartner
aa547038de Improved install user experience 2023-08-08 00:53:00 +02:00
Marcel Baumgartner
17e3345b8a Added date formatter for user overview 2023-08-06 22:00:59 +02:00
Marcel Baumgartner
f95312c1e3 Merge pull request #248 from Moonlight-Panel/AddTicketSystem
Added ticket system
2023-08-06 21:57:24 +02:00
Marcel Baumgartner
2144ca3823 Added byte converter and fixed memory view at the nodes 2023-08-06 21:55:23 +02:00
Marcel Baumgartner
de45ff40d8 Implemented a basic ticket system 2023-08-04 14:00:25 +02:00
Marcel Baumgartner
606085c012 Merge pull request #242 from Moonlight-Panel/AddNewBackupFormat
Added new backup format
2023-08-02 22:05:39 +02:00
Marcel Baumgartner
00525d8099 Added new backup format 2023-08-02 22:05:07 +02:00
Marcel Baumgartner
26617d67f5 Merge pull request #241 from Moonlight-Panel/SmallFixes
Did some small fixes, added connection timeout check, improved ux
2023-08-02 03:07:47 +02:00
Marcel Baumgartner
600bec3417 Did some small fixes, added connection timeout check, improved ux 2023-08-02 03:06:54 +02:00
Marcel Baumgartner
4e85d1755a Merge pull request #240 from DatGamet/patch-1
Update de_de.lang
2023-08-02 01:39:58 +02:00
DatGamet
0832936933 Update de_de.lang 2023-08-02 01:39:08 +02:00
Marcel Baumgartner
ecda2ec6d1 Merge pull request #239 from Moonlight-Panel/FixPluginSystem
Fixed plugin loading system
2023-08-01 22:21:54 +02:00
Marcel Baumgartner
6f3765a3bf Fixed plugin loading system 2023-08-01 22:21:19 +02:00
Marcel Baumgartner
29002d3445 Update Moonlight.csproj 2023-08-01 20:38:22 +02:00
Marcel Baumgartner
6d0456a008 Merge pull request #238 from Moonlight-Panel/AddDdosProtection
Added new ddos protection
2023-07-24 00:33:22 +02:00
Marcel Baumgartner
3e698123bb Merge branch 'main' into AddDdosProtection 2023-07-24 00:33:01 +02:00
Marcel Baumgartner
f3fb86819a Merge pull request #237 from Moonlight-Panel/FixDomainIdentityIssues
Fixed domain identity issues
2023-07-24 00:30:50 +02:00
Marcel Baumgartner
e2248a8444 Fixed domain identity issues 2023-07-24 00:30:18 +02:00
Marcel Baumgartner
2cf2b77090 Added new ddos protection 2023-07-24 00:23:29 +02:00
Marcel Baumgartner
fedc9278d4 Updated dependencies 2023-07-23 22:12:17 +02:00
Marcel Baumgartner
f29206a69b Merge pull request #236 from Moonlight-Panel/AddPluginSystem
Add plugin system
2023-07-23 21:31:26 +02:00
Marcel Baumgartner
0658e55a78 Implemented basic plugin store and improved plugin system 2023-07-23 21:30:57 +02:00
Marcel Baumgartner
21bea974a9 Implemented a basic plugin system 2023-07-22 23:44:45 +02:00
Marcel Baumgartner
33ef09433e Merge pull request #235 from Moonlight-Panel/AddMinerCheck
Add basic miner check
2023-07-22 02:24:16 +02:00
Marcel Baumgartner
173bff67df Add basic miner check 2023-07-22 02:08:39 +02:00
Marcel Baumgartner
512a989609 Merge pull request #234 from Moonlight-Panel/FixDomainOwnerMatching
Fixed domain owner matching
2023-07-20 17:24:05 +02:00
Marcel Baumgartner
2d7dac5089 Fixed domain owner matching 2023-07-20 17:17:05 +02:00
75 changed files with 5673 additions and 1272 deletions

View File

@@ -17,6 +17,15 @@ public class ConfigV1
[Description("The url moonlight is accesible with from the internet")] [Description("The url moonlight is accesible with from the internet")]
public string AppUrl { get; set; } = "http://your-moonlight-url-without-slash"; public string AppUrl { get; set; } = "http://your-moonlight-url-without-slash";
[JsonProperty("EnableLatencyCheck")]
[Description(
"This will enable a latency check for connections to moonlight. Users with an too high latency will be warned that moonlight might be buggy for them")]
public bool EnableLatencyCheck { get; set; } = true;
[JsonProperty("LatencyCheckThreshold")]
[Description("Specify the latency threshold which has to be reached in order to trigger the warning message")]
public int LatencyCheckThreshold { get; set; } = 500;
[JsonProperty("Auth")] public AuthData Auth { get; set; } = new(); [JsonProperty("Auth")] public AuthData Auth { get; set; } = new();
[JsonProperty("Database")] public DatabaseData Database { get; set; } = new(); [JsonProperty("Database")] public DatabaseData Database { get; set; } = new();
@@ -50,6 +59,15 @@ public class ConfigV1
[JsonProperty("Sentry")] public SentryData Sentry { get; set; } = new(); [JsonProperty("Sentry")] public SentryData Sentry { get; set; } = new();
[JsonProperty("Stripe")] public StripeData Stripe { get; set; } = new(); [JsonProperty("Stripe")] public StripeData Stripe { get; set; } = new();
[JsonProperty("Tickets")] public TicketsData Tickets { get; set; } = new();
}
public class TicketsData
{
[JsonProperty("WelcomeMessage")]
[Description("The message that will be sent when a user created a ticket")]
public string WelcomeMessage { get; set; } = "Welcome to the support";
} }
public class StripeData public class StripeData
@@ -276,6 +294,10 @@ public class ConfigV1
[Blur] [Blur]
public string Token { get; set; } = Guid.NewGuid().ToString(); public string Token { get; set; } = Guid.NewGuid().ToString();
[JsonProperty("BlockIpDuration")]
[Description("The duration in minutes a ip will be blocked by the anti ddos system")]
public int BlockIpDuration { get; set; } = 15;
[JsonProperty("ReCaptcha")] public ReCaptchaData ReCaptcha { get; set; } = new(); [JsonProperty("ReCaptcha")] public ReCaptchaData ReCaptcha { get; set; } = new();
} }

View File

@@ -42,6 +42,11 @@ public class DataContext : DbContext
public DbSet<IpBan> IpBans { get; set; } public DbSet<IpBan> IpBans { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; } public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<SecurityLog> SecurityLogs { get; set; } public DbSet<SecurityLog> SecurityLogs { get; set; }
public DbSet<BlocklistIp> BlocklistIps { get; set; }
public DbSet<WhitelistIp> WhitelistIps { get; set; }
public DbSet<Ticket> Tickets { get; set; }
public DbSet<TicketMessage> TicketMessages { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View File

@@ -0,0 +1,10 @@
namespace Moonlight.App.Database.Entities;
public class BlocklistIp
{
public int Id { get; set; }
public string Ip { get; set; } = "";
public DateTime ExpiresAt { get; set; }
public long Packets { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
using Moonlight.App.Models.Misc;
namespace Moonlight.App.Database.Entities;
public class Ticket
{
public int Id { get; set; }
public string IssueTopic { get; set; } = "";
public string IssueDescription { get; set; } = "";
public string IssueTries { get; set; } = "";
public User CreatedBy { get; set; }
public User? AssignedTo { get; set; }
public TicketPriority Priority { get; set; }
public TicketStatus Status { get; set; }
public TicketSubject Subject { get; set; }
public int SubjectId { get; set; }
public List<TicketMessage> Messages { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
namespace Moonlight.App.Database.Entities;
public class TicketMessage
{
public int Id { get; set; }
public string Content { get; set; } = "";
public string? AttachmentUrl { get; set; }
public User? Sender { get; set; }
public bool IsSystemMessage { get; set; }
public bool IsEdited { get; set; }
public bool IsSupportMessage { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.App.Database.Entities;
public class WhitelistIp
{
public int Id { get; set; }
public string Ip { get; set; } = "";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddIpRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BlocklistIps",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Ip = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Packets = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlocklistIps", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "WhitelistIps",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Ip = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_WhitelistIps", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BlocklistIps");
migrationBuilder.DropTable(
name: "WhitelistIps");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddNewTicketModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tickets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
IssueTopic = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IssueDescription = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IssueTries = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedById = table.Column<int>(type: "int", nullable: false),
AssignedToId = table.Column<int>(type: "int", nullable: true),
Priority = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Subject = table.Column<int>(type: "int", nullable: false),
SubjectId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tickets", x => x.Id);
table.ForeignKey(
name: "FK_Tickets_Users_AssignedToId",
column: x => x.AssignedToId,
principalTable: "Users",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tickets_Users_CreatedById",
column: x => x.CreatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TicketMessages",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Content = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AttachmentUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SenderId = table.Column<int>(type: "int", nullable: true),
IsSystemMessage = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsEdited = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsSupportMessage = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
TicketId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TicketMessages", x => x.Id);
table.ForeignKey(
name: "FK_TicketMessages_Tickets_TicketId",
column: x => x.TicketId,
principalTable: "Tickets",
principalColumn: "Id");
table.ForeignKey(
name: "FK_TicketMessages_Users_SenderId",
column: x => x.SenderId,
principalTable: "Users",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_SenderId",
table: "TicketMessages",
column: "SenderId");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_TicketId",
table: "TicketMessages",
column: "TicketId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_AssignedToId",
table: "Tickets",
column: "AssignedToId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_CreatedById",
table: "Tickets",
column: "CreatedById");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TicketMessages");
migrationBuilder.DropTable(
name: "Tickets");
}
}
}

View File

@@ -19,6 +19,30 @@ namespace Moonlight.App.Database.Migrations
.HasAnnotation("ProductVersion", "7.0.3") .HasAnnotation("ProductVersion", "7.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 64); .HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Moonlight.App.Database.Entities.BlocklistIp", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("Ip")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Packets")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("BlocklistIps");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.CloudPanel", b => modelBuilder.Entity("Moonlight.App.Database.Entities.CloudPanel", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -690,6 +714,97 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("SupportChatMessages"); b.ToTable("SupportChatMessages");
}); });
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int?>("AssignedToId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CreatedById")
.HasColumnType("int");
b.Property<string>("IssueDescription")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("IssueTopic")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("IssueTries")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("Subject")
.HasColumnType("int");
b.Property<int>("SubjectId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AssignedToId");
b.HasIndex("CreatedById");
b.ToTable("Tickets");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.TicketMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("AttachmentUrl")
.HasColumnType("longtext");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEdited")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSupportMessage")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSystemMessage")
.HasColumnType("tinyint(1)");
b.Property<int?>("SenderId")
.HasColumnType("int");
b.Property<int?>("TicketId")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("TicketId");
b.ToTable("TicketMessages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.User", b => modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -842,6 +957,21 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("WebSpaces"); b.ToTable("WebSpaces");
}); });
modelBuilder.Entity("Moonlight.App.Database.Entities.WhitelistIp", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Ip")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("WhitelistIps");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.DdosAttack", b => modelBuilder.Entity("Moonlight.App.Database.Entities.DdosAttack", b =>
{ {
b.HasOne("Moonlight.App.Database.Entities.Node", "Node") b.HasOne("Moonlight.App.Database.Entities.Node", "Node")
@@ -1000,6 +1130,36 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Sender"); b.Navigation("Sender");
}); });
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "AssignedTo")
.WithMany()
.HasForeignKey("AssignedToId");
b.HasOne("Moonlight.App.Database.Entities.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AssignedTo");
b.Navigation("CreatedBy");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.TicketMessage", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "Sender")
.WithMany()
.HasForeignKey("SenderId");
b.HasOne("Moonlight.App.Database.Entities.Ticket", null)
.WithMany("Messages")
.HasForeignKey("TicketId");
b.Navigation("Sender");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.User", b => modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
{ {
b.HasOne("Moonlight.App.Database.Entities.Subscription", "CurrentSubscription") b.HasOne("Moonlight.App.Database.Entities.Subscription", "CurrentSubscription")
@@ -1055,6 +1215,11 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Variables"); b.Navigation("Variables");
}); });
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.WebSpace", b => modelBuilder.Entity("Moonlight.App.Database.Entities.WebSpace", b =>
{ {
b.Navigation("Databases"); b.Navigation("Databases");

View File

@@ -0,0 +1,119 @@
using System.Diagnostics;
using System.IO.Compression;
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database;
using Moonlight.App.Services;
using MySql.Data.MySqlClient;
namespace Moonlight.App.Helpers;
public class BackupHelper
{
public async Task CreateBackup(string path)
{
Logger.Info("Started moonlight backup creation");
Logger.Info($"This backup will be saved to '{path}'");
var stopWatch = new Stopwatch();
stopWatch.Start();
var cachePath = PathBuilder.Dir("storage", "backups", "cache");
Directory.CreateDirectory(cachePath);
//
// Exporting database
//
Logger.Info("Exporting database");
var configService = new ConfigService(new());
var dataContext = new DataContext(configService);
await using MySqlConnection conn = new MySqlConnection(dataContext.Database.GetConnectionString());
await using MySqlCommand cmd = new MySqlCommand();
using MySqlBackup mb = new MySqlBackup(cmd);
cmd.Connection = conn;
await conn.OpenAsync();
mb.ExportToFile(PathBuilder.File(cachePath, "database.sql"));
await conn.CloseAsync();
//
// Saving config
//
Logger.Info("Saving configuration");
File.Copy(
PathBuilder.File("storage", "configs", "config.json"),
PathBuilder.File(cachePath, "config.json"));
//
// Saving all storage items needed to restore the panel
//
Logger.Info("Saving resources");
CopyDirectory(
PathBuilder.Dir("storage", "resources"),
PathBuilder.Dir(cachePath, "resources"));
Logger.Info("Saving logs");
CopyDirectory(
PathBuilder.Dir("storage", "logs"),
PathBuilder.Dir(cachePath, "logs"));
Logger.Info("Saving uploads");
CopyDirectory(
PathBuilder.Dir("storage", "uploads"),
PathBuilder.Dir(cachePath, "uploads"));
//
// Compressing the backup to a single file
//
Logger.Info("Compressing");
ZipFile.CreateFromDirectory(cachePath,
path,
CompressionLevel.Fastest,
false);
Directory.Delete(cachePath, true);
stopWatch.Stop();
Logger.Info($"Backup successfully created. Took {stopWatch.Elapsed.TotalSeconds} seconds");
}
private void CopyDirectory(string sourceDirName, string destDirName, bool copySubDirs = true)
{
DirectoryInfo dir = new DirectoryInfo(sourceDirName);
if (!dir.Exists)
{
throw new DirectoryNotFoundException($"Source directory does not exist or could not be found: {sourceDirName}");
}
if (!Directory.Exists(destDirName))
{
Directory.CreateDirectory(destDirName);
}
FileInfo[] files = dir.GetFiles();
foreach (FileInfo file in files)
{
string tempPath = Path.Combine(destDirName, file.Name);
file.CopyTo(tempPath, false);
}
if (copySubDirs)
{
DirectoryInfo[] dirs = dir.GetDirectories();
foreach (DirectoryInfo subdir in dirs)
{
string tempPath = Path.Combine(destDirName, subdir.Name);
CopyDirectory(subdir.FullName, tempPath, copySubDirs);
}
}
}
}

View File

@@ -0,0 +1,56 @@
namespace Moonlight.App.Helpers;
public class ByteSizeValue
{
public long Bytes { get; set; }
public long KiloBytes
{
get => Bytes / 1024;
set => Bytes = value * 1024;
}
public long MegaBytes
{
get => KiloBytes / 1024;
set => KiloBytes = value * 1024;
}
public long GigaBytes
{
get => MegaBytes / 1024;
set => MegaBytes = value * 1024;
}
public static ByteSizeValue FromBytes(long bytes)
{
return new()
{
Bytes = bytes
};
}
public static ByteSizeValue FromKiloBytes(long kiloBytes)
{
return new()
{
KiloBytes = kiloBytes
};
}
public static ByteSizeValue FromMegaBytes(long megaBytes)
{
return new()
{
MegaBytes = megaBytes
};
}
public static ByteSizeValue FromGigaBytes(long gigaBytes)
{
return new()
{
GigaBytes = gigaBytes
};
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.App.Helpers;
public class ComponentHelper
{
public static RenderFragment FromType(Type type) => builder =>
{
builder.OpenComponent(0, type);
builder.CloseComponent();
};
}

View File

@@ -44,8 +44,10 @@ public class DatabaseCheckupService
if (migrations.Any()) if (migrations.Any())
{ {
Logger.Info($"{migrations.Length} migrations pending. Updating now"); Logger.Info($"{migrations.Length} migrations pending. Updating now");
await BackupDatabase(); var backupHelper = new BackupHelper();
await backupHelper.CreateBackup(
PathBuilder.File("storage", "backups", $"{new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds()}.zip"));
Logger.Info("Applying migrations"); Logger.Info("Applying migrations");
@@ -58,53 +60,4 @@ public class DatabaseCheckupService
Logger.Info("Database is up-to-date. No migrations have been performed"); Logger.Info("Database is up-to-date. No migrations have been performed");
} }
} }
public async Task BackupDatabase()
{
Logger.Info("Creating backup from database");
var configService = new ConfigService(new StorageService());
var dateTimeService = new DateTimeService();
var config = configService.Get().Moonlight.Database;
var connectionString = $"host={config.Host};" +
$"port={config.Port};" +
$"database={config.Database};" +
$"uid={config.Username};" +
$"pwd={config.Password}";
string file = PathBuilder.File("storage", "backups", $"{dateTimeService.GetCurrentUnix()}-mysql.sql");
Logger.Info($"Saving it to: {file}");
Logger.Info("Starting backup...");
try
{
var sw = new Stopwatch();
sw.Start();
await using MySqlConnection conn = new MySqlConnection(connectionString);
await using MySqlCommand cmd = new MySqlCommand();
using MySqlBackup mb = new MySqlBackup(cmd);
cmd.Connection = conn;
await conn.OpenAsync();
mb.ExportToFile(file);
await conn.CloseAsync();
sw.Stop();
Logger.Info($"Done. {sw.Elapsed.TotalSeconds}s");
}
catch (Exception e)
{
Logger.Fatal("-----------------------------------------------");
Logger.Fatal("Unable to create backup for moonlight database");
Logger.Fatal("Moonlight will start anyways in 30 seconds");
Logger.Fatal("-----------------------------------------------");
Logger.Fatal(e);
Thread.Sleep(TimeSpan.FromSeconds(30));
}
}
} }

View File

@@ -43,19 +43,22 @@ public class WingsFileAccess : FileAccess
$"api/servers/{Server.Uuid}/files/list-directory?directory={CurrentPath}" $"api/servers/{Server.Uuid}/files/list-directory?directory={CurrentPath}"
); );
var x = new List<FileData>(); var result = new List<FileData>();
foreach (var response in res) foreach (var resGrouped in res.GroupBy(x => x.Directory))
{ {
x.Add(new() foreach (var resItem in resGrouped.OrderBy(x => x.Name))
{ {
Name = response.Name, result.Add(new()
Size = response.File ? response.Size : 0, {
IsFile = response.File, Name = resItem.Name,
}); Size = resItem.File ? resItem.Size : 0,
IsFile = resItem.File,
});
}
} }
return x.ToArray(); return result.ToArray();
} }
public override Task Cd(string dir) public override Task Cd(string dir)

View File

@@ -3,6 +3,7 @@ using Moonlight.App.Database.Entities;
using Moonlight.App.Events; using Moonlight.App.Events;
using Moonlight.App.Http.Requests.Daemon; using Moonlight.App.Http.Requests.Daemon;
using Moonlight.App.Repositories; using Moonlight.App.Repositories;
using Moonlight.App.Services.Background;
namespace Moonlight.App.Http.Controllers.Api.Remote; namespace Moonlight.App.Http.Controllers.Api.Remote;
@@ -10,19 +11,17 @@ namespace Moonlight.App.Http.Controllers.Api.Remote;
[Route("api/remote/ddos")] [Route("api/remote/ddos")]
public class DdosController : Controller public class DdosController : Controller
{ {
private readonly NodeRepository NodeRepository; private readonly Repository<Node> NodeRepository;
private readonly EventSystem Event; private readonly DdosProtectionService DdosProtectionService;
private readonly DdosAttackRepository DdosAttackRepository;
public DdosController(NodeRepository nodeRepository, EventSystem eventSystem, DdosAttackRepository ddosAttackRepository) public DdosController(Repository<Node> nodeRepository, DdosProtectionService ddosProtectionService)
{ {
NodeRepository = nodeRepository; NodeRepository = nodeRepository;
Event = eventSystem; DdosProtectionService = ddosProtectionService;
DdosAttackRepository = ddosAttackRepository;
} }
[HttpPost("update")] [HttpPost("start")]
public async Task<ActionResult> Update([FromBody] DdosStatus ddosStatus) public async Task<ActionResult> Start([FromBody] DdosStart ddosStart)
{ {
var tokenData = Request.Headers.Authorization.ToString().Replace("Bearer ", ""); var tokenData = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
var id = tokenData.Split(".")[0]; var id = tokenData.Split(".")[0];
@@ -35,18 +34,26 @@ public class DdosController : Controller
if (token != node.Token) if (token != node.Token)
return Unauthorized(); return Unauthorized();
await DdosProtectionService.ProcessDdosSignal(ddosStart.Ip, ddosStart.Packets);
return Ok();
}
var ddosAttack = new DdosAttack() [HttpPost("stop")]
{ public async Task<ActionResult> Stop([FromBody] DdosStop ddosStop)
Ongoing = ddosStatus.Ongoing, {
Data = ddosStatus.Data, var tokenData = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
Ip = ddosStatus.Ip, var id = tokenData.Split(".")[0];
Node = node var token = tokenData.Split(".")[1];
};
ddosAttack = DdosAttackRepository.Add(ddosAttack); var node = NodeRepository.Get().FirstOrDefault(x => x.TokenId == id);
await Event.Emit("node.ddos", ddosAttack); if (node == null)
return NotFound();
if (token != node.Token)
return Unauthorized();
return Ok(); return Ok();
} }

View File

@@ -0,0 +1,7 @@
namespace Moonlight.App.Http.Requests.Daemon;
public class DdosStart
{
public string Ip { get; set; } = "";
public long Packets { get; set; }
}

View File

@@ -1,8 +0,0 @@
namespace Moonlight.App.Http.Requests.Daemon;
public class DdosStatus
{
public bool Ongoing { get; set; }
public long Data { get; set; }
public string Ip { get; set; } = "";
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.App.Http.Requests.Daemon;
public class DdosStop
{
public string Ip { get; set; } = "";
public long Traffic { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.App.Models.Misc;
namespace Moonlight.App.Models.Forms;
public class CreateTicketDataModel
{
[Required(ErrorMessage = "You need to specify a issue topic")]
[MinLength(5, ErrorMessage = "The issue topic needs to be longer than 5 characters")]
public string IssueTopic { get; set; }
[Required(ErrorMessage = "You need to specify a issue description")]
[MinLength(10, ErrorMessage = "The issue description needs to be longer than 10 characters")]
public string IssueDescription { get; set; }
[Required(ErrorMessage = "You need to specify your tries to solve this issue")]
public string IssueTries { get; set; }
public TicketSubject Subject { get; set; }
public int SubjectId { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Moonlight.App.Models.Misc;
public class OfficialMoonlightPlugin
{
public string Name { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Moonlight.App.Models.Misc;
public enum TicketPriority
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}

View File

@@ -0,0 +1,9 @@
namespace Moonlight.App.Models.Misc;
public enum TicketStatus
{
Closed = 0,
Open = 1,
WaitingForUser = 2,
Pending = 3
}

View File

@@ -0,0 +1,9 @@
namespace Moonlight.App.Models.Misc;
public enum TicketSubject
{
Webspace = 0,
Server = 1,
Domain = 2,
Other = 3
}

View File

@@ -15,6 +15,13 @@ public static class Permissions
Name = "Admin Statistics", Name = "Admin Statistics",
Description = "View statistical information about the moonlight instance" Description = "View statistical information about the moonlight instance"
}; };
public static Permission AdminSysPlugins = new()
{
Index = 2,
Name = "Admin system plugins",
Description = "View and install plugins"
};
public static Permission AdminDomains = new() public static Permission AdminDomains = new()
{ {
@@ -44,13 +51,6 @@ public static class Permissions
Description = "Create a new shared domain in the admin area" Description = "Create a new shared domain in the admin area"
}; };
public static Permission AdminNodeDdos = new()
{
Index = 8,
Name = "Admin Node DDoS",
Description = "Manage DDoS protection for nodes in the admin area"
};
public static Permission AdminNodeEdit = new() public static Permission AdminNodeEdit = new()
{ {
Index = 9, Index = 9,
@@ -400,6 +400,13 @@ public static class Permissions
Name = "Admin security logs", Name = "Admin security logs",
Description = "View the security logs" Description = "View the security logs"
}; };
public static Permission AdminSecurityDdos = new()
{
Index = 59,
Name = "Admin security ddos",
Description = "Manage the integrated ddos protection"
};
public static Permission? FromString(string name) public static Permission? FromString(string name)
{ {

View File

@@ -0,0 +1,15 @@
using Moonlight.App.Plugin.UI.Servers;
using Moonlight.App.Plugin.UI.Webspaces;
namespace Moonlight.App.Plugin;
public abstract class MoonlightPlugin
{
public string Name { get; set; } = "N/A";
public string Author { get; set; } = "N/A";
public string Version { get; set; } = "N/A";
public Func<ServerPageContext, Task>? OnBuildServerPage { get; set; }
public Func<WebspacePageContext, Task>? OnBuildWebspacePage { get; set; }
public Func<IServiceCollection, Task>? OnBuildServices { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Plugin.UI.Servers;
public class ServerPageContext
{
public List<ServerTab> Tabs { get; set; } = new();
public List<ServerSetting> Settings { get; set; } = new();
public Server Server { get; set; }
public User User { get; set; }
public string[] ImageTags { get; set; }
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.App.Plugin.UI.Servers;
public class ServerSetting
{
public string Name { get; set; }
public RenderFragment Component { get; set; }
}

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.App.Plugin.UI.Servers;
public class ServerTab
{
public string Name { get; set; }
public string Route { get; set; }
public string Icon { get; set; }
public RenderFragment Component { get; set; }
}

View File

@@ -0,0 +1,10 @@
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Plugin.UI.Webspaces;
public class WebspacePageContext
{
public List<WebspaceTab> Tabs { get; set; } = new();
public User User { get; set; }
public WebSpace WebSpace { get; set; }
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.App.Plugin.UI.Webspaces;
public class WebspaceTab
{
public string Name { get; set; } = "N/A";
public string Route { get; set; } = "/";
public RenderFragment Component { get; set; }
}

View File

@@ -0,0 +1,129 @@
using Moonlight.App.ApiClients.Daemon;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Helpers;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.Background;
public class DdosProtectionService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
public DdosProtectionService(IServiceScopeFactory serviceScopeFactory)
{
ServiceScopeFactory = serviceScopeFactory;
Task.Run(UnBlocker);
}
private async Task UnBlocker()
{
var periodicTimer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (true)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistIpRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var ips = blocklistIpRepo
.Get()
.ToArray();
foreach (var ip in ips)
{
if (DateTime.UtcNow > ip.ExpiresAt)
{
blocklistIpRepo.Delete(ip);
}
}
var newCount = blocklistIpRepo
.Get()
.Count();
if (newCount != ips.Length)
{
await RebuildNodeFirewalls();
}
await periodicTimer.WaitForNextTickAsync();
}
}
public async Task RebuildNodeFirewalls()
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistIpRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var nodeRepo = scope.ServiceProvider.GetRequiredService<Repository<Node>>();
var nodeService = scope.ServiceProvider.GetRequiredService<NodeService>();
var ips = blocklistIpRepo
.Get()
.Select(x => x.Ip)
.ToArray();
foreach (var node in nodeRepo.Get().ToArray())
{
try
{
await nodeService.RebuildFirewall(node, ips);
}
catch (Exception e)
{
Logger.Warn($"Error rebuilding firewall on node {node.Name}");
Logger.Warn(e);
}
}
}
public async Task ProcessDdosSignal(string ip, long packets)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var whitelistRepo = scope.ServiceProvider.GetRequiredService<Repository<WhitelistIp>>();
var whitelistIps = whitelistRepo.Get().ToArray();
if(whitelistIps.Any(x => x.Ip == ip))
return;
var blocklistIps = blocklistRepo.Get().ToArray();
if(blocklistIps.Any(x => x.Ip == ip))
return;
await BlocklistIp(ip, packets);
}
public async Task BlocklistIp(string ip, long packets)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var configService = scope.ServiceProvider.GetRequiredService<ConfigService>();
var eventSystem = scope.ServiceProvider.GetRequiredService<EventSystem>();
var blocklistIp = blocklistRepo.Add(new()
{
Ip = ip,
Packets = packets,
ExpiresAt = DateTime.UtcNow.AddMinutes(configService.Get().Moonlight.Security.BlockIpDuration),
CreatedAt = DateTime.UtcNow
});
await RebuildNodeFirewalls();
await eventSystem.Emit("ddos.add", blocklistIp);
}
public async Task UnBlocklistIp(string ip)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var blocklist = blocklistRepo.Get().First(x => x.Ip == ip);
blocklistRepo.Delete(blocklist);
await RebuildNodeFirewalls();
}
}

View File

@@ -36,6 +36,7 @@ public class DiscordNotificationService
Event.On<User>("supportChat.close", this, OnSupportChatClose); Event.On<User>("supportChat.close", this, OnSupportChatClose);
Event.On<User>("user.rating", this, OnUserRated); Event.On<User>("user.rating", this, OnUserRated);
Event.On<User>("billing.completed", this, OnBillingCompleted); Event.On<User>("billing.completed", this, OnBillingCompleted);
Event.On<BlocklistIp>("ddos.add", this, OnIpBlockListed);
} }
else else
{ {
@@ -43,6 +44,18 @@ public class DiscordNotificationService
} }
} }
private async Task OnIpBlockListed(BlocklistIp blocklistIp)
{
await SendNotification("", builder =>
{
builder.Color = Color.Red;
builder.Title = "New ddos attack detected";
builder.AddField("IP", blocklistIp.Ip);
builder.AddField("Packets", blocklistIp.Packets);
});
}
private async Task OnBillingCompleted(User user) private async Task OnBillingCompleted(User user)
{ {
await SendNotification("", builder => await SendNotification("", builder =>

View File

@@ -19,6 +19,7 @@ public class MalwareScanService
private readonly IServiceScopeFactory ServiceScopeFactory; private readonly IServiceScopeFactory ServiceScopeFactory;
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; }
public bool ScanAllServers { get; set; }
public readonly Dictionary<Server, MalwareScanResult[]> ScanResults; public readonly Dictionary<Server, MalwareScanResult[]> ScanResults;
public string Status { get; private set; } = "N/A"; public string Status { get; private set; } = "N/A";
@@ -26,7 +27,6 @@ public class MalwareScanService
{ {
ServiceScopeFactory = serviceScopeFactory; ServiceScopeFactory = serviceScopeFactory;
Event = eventSystem; Event = eventSystem;
ScanResults = new(); ScanResults = new();
} }
@@ -42,6 +42,7 @@ public class MalwareScanService
private async Task Run() private async Task Run()
{ {
// Clean results
IsRunning = true; IsRunning = true;
Status = "Clearing last results"; Status = "Clearing last results";
await Event.Emit("malwareScan.status", IsRunning); await Event.Emit("malwareScan.status", IsRunning);
@@ -53,6 +54,55 @@ public class MalwareScanService
await Event.Emit("malwareScan.result"); await Event.Emit("malwareScan.result");
// Load servers to scan
using var scope = ServiceScopeFactory.CreateScope();
// Load services from di scope
NodeRepository = scope.ServiceProvider.GetRequiredService<Repository<Node>>();
ServerRepository = scope.ServiceProvider.GetRequiredService<Repository<Server>>();
NodeService = scope.ServiceProvider.GetRequiredService<NodeService>();
ServerService = scope.ServiceProvider.GetRequiredService<ServerService>();
Status = "Fetching servers to scan";
await Event.Emit("malwareScan.status", IsRunning);
Server[] servers;
if (ScanAllServers)
servers = ServerRepository.Get().ToArray();
else
servers = await GetOnlineServers();
// Perform scan
int i = 1;
foreach (var server in servers)
{
Status = $"[{i} / {servers.Length}] Scanning server {server.Name}";
await Event.Emit("malwareScan.status", IsRunning);
var results = await PerformScanOnServer(server);
if (results.Any())
{
lock (ScanResults)
{
ScanResults.Add(server, results);
}
await Event.Emit("malwareScan.result");
}
i++;
}
IsRunning = false;
await Event.Emit("malwareScan.status", IsRunning);
}
private async Task<Server[]> GetOnlineServers()
{
using var scope = ServiceScopeFactory.CreateScope(); using var scope = ServiceScopeFactory.CreateScope();
// Load services from di scope // Load services from di scope
@@ -102,43 +152,11 @@ public class MalwareScanService
containerServerMapped.Add(server, container); containerServerMapped.Add(server, container);
} }
} }
// Perform scan
var resultsMapped = new Dictionary<Server, MalwareScanResult[]>();
foreach (var mapping in containerServerMapped)
{
Logger.Verbose($"Scanning server {mapping.Key.Name} for malware");
Status = $"Scanning server {mapping.Key.Name} for malware";
await Event.Emit("malwareScan.status", IsRunning);
var results = await PerformScanOnServer(mapping.Key, mapping.Value);
if (results.Any()) return containerServerMapped.Keys.ToArray();
{
resultsMapped.Add(mapping.Key, results);
Logger.Verbose($"{results.Length} findings on server {mapping.Key.Name}");
}
}
Logger.Verbose($"Scan complete. Detected {resultsMapped.Count} servers with findings");
IsRunning = false;
Status = $"Scan complete. Detected {resultsMapped.Count} servers with findings";
await Event.Emit("malwareScan.status", IsRunning);
lock (ScanResults)
{
foreach (var mapping in resultsMapped)
{
ScanResults.Add(mapping.Key, mapping.Value);
}
}
await Event.Emit("malwareScan.result");
} }
private async Task<MalwareScanResult[]> PerformScanOnServer(Server server, Container container) private async Task<MalwareScanResult[]> PerformScanOnServer(Server server)
{ {
var results = new List<MalwareScanResult>(); var results = new List<MalwareScanResult>();
@@ -162,6 +180,29 @@ public class MalwareScanService
} }
} }
async Task ScanMinerJar()
{
var access = await ServerService.CreateFileAccess(server, null!);
var fileElements = await access.Ls();
if (fileElements.Any(x => x.Name == "libraries" && !x.IsFile))
{
await access.Cd("libraries");
fileElements = await access.Ls();
if (fileElements.Any(x => x.Name == "jdk" && !x.IsFile))
{
results.Add(new ()
{
Title = "Found Miner",
Description = "Detected suspicious library directory which may contain a script for miners",
Author = "Marcel Baumgartner"
});
}
}
}
async Task ScanFakePlayerPlugins() async Task ScanFakePlayerPlugins()
{ {
var access = await ServerService.CreateFileAccess(server, null!); var access = await ServerService.CreateFileAccess(server, null!);
@@ -190,6 +231,7 @@ public class MalwareScanService
// Execute scans // Execute scans
await ScanSelfBot(); await ScanSelfBot();
await ScanFakePlayerPlugins(); await ScanFakePlayerPlugins();
await ScanMinerJar();
return results.ToArray(); return results.ToArray();
} }

View File

@@ -16,6 +16,7 @@ public class StorageService
Directory.CreateDirectory(PathBuilder.Dir("storage", "resources")); Directory.CreateDirectory(PathBuilder.Dir("storage", "resources"));
Directory.CreateDirectory(PathBuilder.Dir("storage", "backups")); Directory.CreateDirectory(PathBuilder.Dir("storage", "backups"));
Directory.CreateDirectory(PathBuilder.Dir("storage", "logs")); Directory.CreateDirectory(PathBuilder.Dir("storage", "logs"));
Directory.CreateDirectory(PathBuilder.Dir("storage", "plugins"));
if(IsEmpty(PathBuilder.Dir("storage", "resources"))) if(IsEmpty(PathBuilder.Dir("storage", "resources")))
{ {

View File

@@ -46,7 +46,7 @@ public class MoonlightService
try try
{ {
var client = new GitHubClient(new ProductHeaderValue("Moonlight")); var client = new GitHubClient(new ProductHeaderValue("Moonlight-Panel"));
var pullRequests = await client.PullRequest.GetAllForRepository("Moonlight-Panel", "Moonlight", new PullRequestRequest var pullRequests = await client.PullRequest.GetAllForRepository("Moonlight-Panel", "Moonlight", new PullRequestRequest
{ {

View File

@@ -50,6 +50,11 @@ public class NodeService
return await DaemonApiHelper.Get<DockerMetrics>(node, "metrics/docker"); return await DaemonApiHelper.Get<DockerMetrics>(node, "metrics/docker");
} }
public async Task RebuildFirewall(Node node, string[] ips)
{
await DaemonApiHelper.Post(node, "firewall/rebuild", ips);
}
public async Task Mount(Node node, string server, string serverPath, string path) public async Task Mount(Node node, string server, string serverPath, string path)
{ {
await DaemonApiHelper.Post(node, "mount", new Mount() await DaemonApiHelper.Post(node, "mount", new Mount()

View File

@@ -0,0 +1,100 @@
using System.Reflection;
using System.Runtime.Loader;
using Moonlight.App.Helpers;
using Moonlight.App.Plugin;
using Moonlight.App.Plugin.UI.Servers;
using Moonlight.App.Plugin.UI.Webspaces;
namespace Moonlight.App.Services.Plugins;
public class PluginService
{
public readonly List<MoonlightPlugin> Plugins = new();
public readonly Dictionary<MoonlightPlugin, string> PluginFiles = new();
public PluginService()
{
ReloadPlugins().Wait();
}
public Task ReloadPlugins()
{
PluginFiles.Clear();
Plugins.Clear();
// Try to update all plugins ending with .dll.cache
foreach (var pluginFile in Directory.EnumerateFiles(
PathBuilder.Dir(Directory.GetCurrentDirectory(), "storage", "plugins"))
.Where(x => x.EndsWith(".dll.cache")))
{
try
{
var realPath = pluginFile.Replace(".cache", "");
File.Copy(pluginFile, realPath, true);
File.Delete(pluginFile);
Logger.Info($"Updated plugin {realPath} on startup");
}
catch (Exception)
{
// ignored
}
}
var pluginType = typeof(MoonlightPlugin);
foreach (var pluginFile in Directory.EnumerateFiles(
PathBuilder.Dir(Directory.GetCurrentDirectory(), "storage", "plugins"))
.Where(x => x.EndsWith(".dll")))
{
var assembly = Assembly.LoadFile(pluginFile);
foreach (var type in assembly.GetTypes())
{
if (type.IsSubclassOf(pluginType))
{
var plugin = (Activator.CreateInstance(type) as MoonlightPlugin)!;
Logger.Info($"Loaded plugin '{plugin.Name}' ({plugin.Version}) by {plugin.Author}");
Plugins.Add(plugin);
PluginFiles.Add(plugin, pluginFile);
}
}
}
Logger.Info($"Loaded {Plugins.Count} plugins");
return Task.CompletedTask;
}
public async Task<ServerPageContext> BuildServerPage(ServerPageContext context)
{
foreach (var plugin in Plugins)
{
if (plugin.OnBuildServerPage != null)
await plugin.OnBuildServerPage.Invoke(context);
}
return context;
}
public async Task<WebspacePageContext> BuildWebspacePage(WebspacePageContext context)
{
foreach (var plugin in Plugins)
{
if (plugin.OnBuildWebspacePage != null)
await plugin.OnBuildWebspacePage.Invoke(context);
}
return context;
}
public async Task BuildServices(IServiceCollection serviceCollection)
{
foreach (var plugin in Plugins)
{
if (plugin.OnBuildServices != null)
await plugin.OnBuildServices.Invoke(serviceCollection);
}
}
}

View File

@@ -0,0 +1,63 @@
using System.Text;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Misc;
using Octokit;
namespace Moonlight.App.Services.Plugins;
public class PluginStoreService
{
private readonly GitHubClient Client;
private readonly PluginService PluginService;
public PluginStoreService(PluginService pluginService)
{
PluginService = pluginService;
Client = new(new ProductHeaderValue("Moonlight-Panel"));
}
public async Task<OfficialMoonlightPlugin[]> GetPlugins()
{
var items = await Client.Repository.Content.GetAllContents("Moonlight-Panel", "OfficialPlugins");
if (items == null)
{
Logger.Fatal("Unable to read plugin repo contents");
return Array.Empty<OfficialMoonlightPlugin>();
}
return items
.Where(x => x.Type == ContentType.Dir)
.Select(x => new OfficialMoonlightPlugin()
{
Name = x.Name
})
.ToArray();
}
public async Task<string> GetPluginReadme(OfficialMoonlightPlugin plugin)
{
var rawReadme = await Client.Repository.Content
.GetRawContent("Moonlight-Panel", "OfficialPlugins", $"{plugin.Name}/README.md");
if (rawReadme == null)
return "Error";
return Encoding.UTF8.GetString(rawReadme);
}
public async Task InstallPlugin(OfficialMoonlightPlugin plugin, bool updating = false)
{
var rawPlugin = await Client.Repository.Content
.GetRawContent("Moonlight-Panel", "OfficialPlugins", $"{plugin.Name}/{plugin.Name}.dll");
if (updating)
{
await File.WriteAllBytesAsync(PathBuilder.File("storage", "plugins", $"{plugin.Name}.dll.cache"), rawPlugin);
return;
}
await File.WriteAllBytesAsync(PathBuilder.File("storage", "plugins", $"{plugin.Name}.dll"), rawPlugin);
await PluginService.ReloadPlugins();
}
}

View File

@@ -112,7 +112,7 @@ public class IdentityService
if (user == null) if (user == null)
{ {
Logger.Warn( Logger.Warn(
$"Cannot find user with the id '{userid}' in the database. Maybe the user has been deleted or a token has been successfully faked by a hacker"); $"Cannot find user with the id '{userid}' in the database. Maybe the user has been deleted or a token has been successfully faked by a hacker", "security");
return; return;
} }

View File

@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components.Forms;
using Moonlight.App.Database.Entities;
using Moonlight.App.Models.Misc;
using Moonlight.App.Services.Files;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Services.Tickets;
public class TicketAdminService
{
private readonly TicketServerService TicketServerService;
private readonly IdentityService IdentityService;
private readonly BucketService BucketService;
public Ticket? Ticket { get; set; }
public TicketAdminService(
TicketServerService ticketServerService,
IdentityService identityService,
BucketService bucketService)
{
TicketServerService = ticketServerService;
IdentityService = identityService;
BucketService = bucketService;
}
public async Task<Dictionary<Ticket, TicketMessage?>> GetAssigned()
{
return await TicketServerService.GetUserAssignedTickets(IdentityService.User);
}
public async Task<Dictionary<Ticket, TicketMessage?>> GetUnAssigned()
{
return await TicketServerService.GetUnAssignedTickets();
}
public async Task<Ticket> Create(string issueTopic, string issueDescription, string issueTries,
TicketSubject subject, int subjectId)
{
return await TicketServerService.Create(
IdentityService.User,
issueTopic,
issueDescription,
issueTries,
subject,
subjectId
);
}
public async Task<TicketMessage> Send(string content, IBrowserFile? file = null)
{
string? attachment = null;
if (file != null)
{
attachment = await BucketService.StoreFile(
"tickets",
file.OpenReadStream(1024 * 1024 * 5),
file.Name);
}
return await TicketServerService.SendMessage(
Ticket!,
IdentityService.User,
content,
attachment,
true
);
}
public async Task UpdateStatus(TicketStatus status)
{
await TicketServerService.UpdateStatus(Ticket!, status);
}
public async Task UpdatePriority(TicketPriority priority)
{
await TicketServerService.UpdatePriority(Ticket!, priority);
}
public async Task<TicketMessage[]> GetMessages()
{
return await TicketServerService.GetMessages(Ticket!);
}
public async Task Claim()
{
await TicketServerService.Claim(Ticket!, IdentityService.User);
}
public async Task UnClaim()
{
await TicketServerService.Claim(Ticket!);
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Components.Forms;
using Moonlight.App.Database.Entities;
using Moonlight.App.Models.Misc;
using Moonlight.App.Services.Files;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Services.Tickets;
public class TicketClientService
{
private readonly TicketServerService TicketServerService;
private readonly IdentityService IdentityService;
private readonly BucketService BucketService;
public Ticket? Ticket { get; set; }
public TicketClientService(
TicketServerService ticketServerService,
IdentityService identityService,
BucketService bucketService)
{
TicketServerService = ticketServerService;
IdentityService = identityService;
BucketService = bucketService;
}
public async Task<Dictionary<Ticket, TicketMessage?>> Get()
{
return await TicketServerService.GetUserTickets(IdentityService.User);
}
public async Task<Ticket> Create(string issueTopic, string issueDescription, string issueTries, TicketSubject subject, int subjectId)
{
return await TicketServerService.Create(
IdentityService.User,
issueTopic,
issueDescription,
issueTries,
subject,
subjectId
);
}
public async Task<TicketMessage> Send(string content, IBrowserFile? file = null)
{
string? attachment = null;
if (file != null)
{
attachment = await BucketService.StoreFile(
"tickets",
file.OpenReadStream(1024 * 1024 * 5),
file.Name);
}
return await TicketServerService.SendMessage(
Ticket!,
IdentityService.User,
content,
attachment
);
}
public async Task<TicketMessage[]> GetMessages()
{
return await TicketServerService.GetMessages(Ticket!);
}
}

View File

@@ -0,0 +1,249 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Models.Misc;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.Tickets;
public class TicketServerService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
private readonly EventSystem Event;
private readonly ConfigService ConfigService;
public TicketServerService(
IServiceScopeFactory serviceScopeFactory,
EventSystem eventSystem,
ConfigService configService)
{
ServiceScopeFactory = serviceScopeFactory;
Event = eventSystem;
ConfigService = configService;
}
public async Task<Ticket> Create(User creator, string issueTopic, string issueDescription, string issueTries, TicketSubject subject, int subjectId)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var creatorUser = userRepo
.Get()
.First(x => x.Id == creator.Id);
var ticket = ticketRepo.Add(new()
{
Priority = TicketPriority.Low,
Status = TicketStatus.Open,
AssignedTo = null,
IssueTopic = issueTopic,
IssueDescription = issueDescription,
IssueTries = issueTries,
Subject = subject,
SubjectId = subjectId,
CreatedBy = creatorUser
});
await Event.Emit("tickets.new", ticket);
// Do automatic stuff here
await SendSystemMessage(ticket, ConfigService.Get().Moonlight.Tickets.WelcomeMessage);
//TODO: Check for opening times
return ticket;
}
public async Task SendSystemMessage(Ticket t, string content, string? attachmentUrl = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
var message = new TicketMessage()
{
Content = content,
Sender = null,
AttachmentUrl = attachmentUrl,
IsSystemMessage = true
};
ticket.Messages.Add(message);
ticketRepo.Update(ticket);
await Event.Emit("tickets.message", message);
await Event.Emit($"tickets.{ticket.Id}.message", message);
}
public async Task UpdatePriority(Ticket t, TicketPriority priority)
{
if(t.Priority == priority)
return;
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
ticket.Priority = priority;
ticketRepo.Update(ticket);
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
await SendSystemMessage(ticket, $"The ticket priority has been changed to: {priority}");
}
public async Task UpdateStatus(Ticket t, TicketStatus status)
{
if(t.Status == status)
return;
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
ticket.Status = status;
ticketRepo.Update(ticket);
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
await SendSystemMessage(ticket, $"The ticket status has been changed to: {status}");
}
public async Task<TicketMessage> SendMessage(Ticket t, User sender, string content, string? attachmentUrl = null, bool isSupportMessage = false)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
var user = userRepo.Get().First(x => x.Id == sender.Id);
var message = new TicketMessage()
{
Content = content,
Sender = user,
AttachmentUrl = attachmentUrl,
IsSupportMessage = isSupportMessage
};
ticket.Messages.Add(message);
ticketRepo.Update(ticket);
await Event.Emit("tickets.message", message);
await Event.Emit($"tickets.{ticket.Id}.message", message);
return message;
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUserTickets(User u)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Where(x => x.CreatedBy.Id == u.Id)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUserAssignedTickets(User u)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Where(x => x.Status != TicketStatus.Closed)
.Where(x => x.AssignedTo.Id == u.Id)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUnAssignedTickets()
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Include(x => x.AssignedTo)
.Where(x => x.AssignedTo == null)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<TicketMessage[]> GetMessages(Ticket ticket)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.First(x => x.Id == ticket.Id);
return Task.FromResult(tickets.Messages.ToArray());
}
public async Task Claim(Ticket t, User? u = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var ticket = ticketRepo.Get().Include(x => x.AssignedTo).First(x => x.Id == t.Id);
var user = u == null ? u : userRepo.Get().First(x => x.Id == u.Id);
ticket.AssignedTo = user;
ticketRepo.Update(ticket);
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
}
}

View File

@@ -66,17 +66,15 @@ public class UserService
{ {
throw new DisplayException("The email is already in use"); throw new DisplayException("The email is already in use");
} }
//TODO: Validation
// Add user // Add user
var user = UserRepository.Add(new() var user = UserRepository.Add(new()
{ {
Address = "", Address = "",
Admin = false, Admin = !UserRepository.Get().Any(),
City = "", City = "",
Country = "", Country = "",
Email = email, Email = email.ToLower(),
Password = BCrypt.Net.BCrypt.HashPassword(password), Password = BCrypt.Net.BCrypt.HashPassword(password),
FirstName = firstname, FirstName = firstname,
LastName = lastname, LastName = lastname,

View File

@@ -40,12 +40,12 @@
</PackageReference> </PackageReference>
<PackageReference Include="MineStat" Version="3.1.1" /> <PackageReference Include="MineStat" Version="3.1.1" />
<PackageReference Include="MySqlBackup.NET" Version="2.3.8" /> <PackageReference Include="MySqlBackup.NET" Version="2.3.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3-beta1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Octokit" Version="6.0.0" /> <PackageReference Include="Octokit" Version="6.0.0" />
<PackageReference Include="Otp.NET" Version="1.3.0" /> <PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" />
<PackageReference Include="QRCoder" Version="1.4.3" /> <PackageReference Include="QRCoder" Version="1.4.3" />
<PackageReference Include="RestSharp" Version="109.0.0-preview.1" /> <PackageReference Include="RestSharp" Version="110.2.1-alpha.0.10" />
<PackageReference Include="Sentry.AspNetCore" Version="3.33.1" /> <PackageReference Include="Sentry.AspNetCore" Version="3.33.1" />
<PackageReference Include="Sentry.Serilog" Version="3.33.1" /> <PackageReference Include="Sentry.Serilog" Version="3.33.1" />
<PackageReference Include="Serilog" Version="3.0.0" /> <PackageReference Include="Serilog" Version="3.0.0" />
@@ -90,6 +90,7 @@
<Folder Include="App\ApiClients\CloudPanel\Resources\" /> <Folder Include="App\ApiClients\CloudPanel\Resources\" />
<Folder Include="App\Http\Middleware" /> <Folder Include="App\Http\Middleware" />
<Folder Include="storage\backups\" /> <Folder Include="storage\backups\" />
<Folder Include="storage\plugins\" />
<Folder Include="storage\resources\public\background\" /> <Folder Include="storage\resources\public\background\" />
</ItemGroup> </ItemGroup>
@@ -111,12 +112,4 @@
<AdditionalFiles Include="Shared\Views\Server\Settings\ServerResetSetting.razor" /> <AdditionalFiles Include="Shared\Views\Server\Settings\ServerResetSetting.razor" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Update="storage\configs\config.json.bak">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project> </Project>

View File

@@ -25,9 +25,11 @@ using Moonlight.App.Services.Interop;
using Moonlight.App.Services.Mail; using Moonlight.App.Services.Mail;
using Moonlight.App.Services.Minecraft; using Moonlight.App.Services.Minecraft;
using Moonlight.App.Services.Notifications; using Moonlight.App.Services.Notifications;
using Moonlight.App.Services.Plugins;
using Moonlight.App.Services.Sessions; using Moonlight.App.Services.Sessions;
using Moonlight.App.Services.Statistics; using Moonlight.App.Services.Statistics;
using Moonlight.App.Services.SupportChat; using Moonlight.App.Services.SupportChat;
using Moonlight.App.Services.Tickets;
using Sentry; using Sentry;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
@@ -110,6 +112,9 @@ namespace Moonlight
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var pluginService = new PluginService();
await pluginService.BuildServices(builder.Services);
// Switch to logging.net injection // Switch to logging.net injection
// TODO: Enable in production // TODO: Enable in production
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
@@ -208,6 +213,10 @@ namespace Moonlight
builder.Services.AddScoped<PopupService>(); builder.Services.AddScoped<PopupService>();
builder.Services.AddScoped<SubscriptionService>(); builder.Services.AddScoped<SubscriptionService>();
builder.Services.AddScoped<BillingService>(); builder.Services.AddScoped<BillingService>();
builder.Services.AddSingleton<PluginStoreService>();
builder.Services.AddSingleton<TicketServerService>();
builder.Services.AddScoped<TicketClientService>();
builder.Services.AddScoped<TicketAdminService>();
builder.Services.AddScoped<SessionClientService>(); builder.Services.AddScoped<SessionClientService>();
builder.Services.AddSingleton<SessionServerService>(); builder.Services.AddSingleton<SessionServerService>();
@@ -239,6 +248,8 @@ namespace Moonlight
builder.Services.AddSingleton<MalwareScanService>(); builder.Services.AddSingleton<MalwareScanService>();
builder.Services.AddSingleton<TelemetryService>(); builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddSingleton<TempMailService>(); builder.Services.AddSingleton<TempMailService>();
builder.Services.AddSingleton<DdosProtectionService>();
builder.Services.AddSingleton(pluginService);
// Other // Other
builder.Services.AddSingleton<MoonlightService>(); builder.Services.AddSingleton<MoonlightService>();
@@ -289,7 +300,7 @@ namespace Moonlight
_ = app.Services.GetRequiredService<MalwareScanService>(); _ = app.Services.GetRequiredService<MalwareScanService>();
_ = app.Services.GetRequiredService<TelemetryService>(); _ = app.Services.GetRequiredService<TelemetryService>();
_ = app.Services.GetRequiredService<TempMailService>(); _ = app.Services.GetRequiredService<TempMailService>();
_ = app.Services.GetRequiredService<DdosProtectionService>();
_ = app.Services.GetRequiredService<MoonlightService>(); _ = app.Services.GetRequiredService<MoonlightService>();
// Discord bot service // Discord bot service

View File

@@ -35,6 +35,18 @@
}, },
"applicationUrl": "http://moonlight.testy:5118;https://localhost:7118;http://localhost:5118", "applicationUrl": "http://moonlight.testy:5118;https://localhost:7118;http://localhost:5118",
"dotnetRunMessages": true "dotnetRunMessages": true
},
"Dev DB 2":
{
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ML_DEBUG": "true",
"ML_CONFIG_PATH": "storage\\configs\\dev_2_config.json"
},
"applicationUrl": "http://moonlight.testy:5118;https://localhost:7118;http://localhost:5118",
"dotnetRunMessages": true
} }
} }
} }

View File

@@ -11,22 +11,29 @@
@implements IDisposable @implements IDisposable
<div class="card bg-black rounded"> <div class="card bg-black rounded">
@if (ShowHeader)
{
<div class="card-header">
<span class="card-title">@(Header)</span>
</div>
}
<div class="card-body"> <div class="card-body">
<MonacoEditor CssClass="h-100" @ref="Editor" Id="vseditor" ConstructionOptions="(x) => EditorOptions"/> <MonacoEditor CssClass="h-100" @ref="Editor" Id="vseditor" ConstructionOptions="(x) => EditorOptions"/>
</div> </div>
@if (!HideControls) @if (!HideControls)
{ {
<div class="card-footer"> <div class="card-footer">
<div class="btn-group"> <div class="btn-group">
<WButton <WButton
Text="@(TranslationService.Translate("Save"))" Text="@(TranslationService.Translate("Save"))"
WorkingText="@(TranslationService.Translate("Saving"))" WorkingText="@(TranslationService.Translate("Saving"))"
OnClick="Submit"></WButton> OnClick="Submit"></WButton>
<WButton <WButton
CssClasses="btn-danger" CssClasses="btn-danger"
Text="@(TranslationService.Translate("Cancel"))" Text="@(TranslationService.Translate("Cancel"))"
WorkingText="@(TranslationService.Translate("Canceling"))" WorkingText="@(TranslationService.Translate("Canceling"))"
OnClick="Cancel"></WButton> OnClick="Cancel"></WButton>
</div> </div>
</div> </div>
@@ -43,6 +50,12 @@
[Parameter] [Parameter]
public bool HideControls { get; set; } = false; public bool HideControls { get; set; } = false;
[Parameter]
public bool ShowHeader { get; set; } = false;
[Parameter]
public string Header { get; set; } = "Header.changeme.txt";
// Events // Events
[Parameter] [Parameter]

View File

@@ -17,7 +17,9 @@
Language="@EditorLanguage" Language="@EditorLanguage"
OnCancel="() => Cancel()" OnCancel="() => Cancel()"
OnSubmit="(_) => Save()" OnSubmit="(_) => Save()"
HideControls="false"> HideControls="false"
ShowHeader="true"
Header="@(EditingFile.Name)">
</FileEditor> </FileEditor>
} }
else else

View File

@@ -35,28 +35,31 @@
<tbody class="fw-semibold text-gray-600"> <tbody class="fw-semibold text-gray-600">
<LazyLoader Load="Load"> <LazyLoader Load="Load">
<ContentBlock @ref="ContentBlock" AllowContentOverride="true"> <ContentBlock @ref="ContentBlock" AllowContentOverride="true">
<tr class="even"> @if (Access.CurrentPath != "/")
<td class="w-10px"> {
</td> <tr class="even">
<td> <td class="w-10px">
<div class="d-flex align-items-center"> </td>
<span class="icon-wrapper"> <td>
<i class="bx bx-md bx-up-arrow-alt text-primary"></i> <div class="d-flex align-items-center">
</span> <span class="icon-wrapper">
<a href="#" @onclick:preventDefault @onclick="GoUp" class="ms-3 text-gray-800 text-hover-primary"> <i class="bx bx-md bx-up-arrow-alt text-primary"></i>
<TL>Go up</TL> </span>
</a> <a href="#" @onclick:preventDefault @onclick="GoUp" class="ms-3 text-gray-800 text-hover-primary">
</div> <TL>Go up</TL>
</td> </a>
<td></td>
<td class="text-end">
<div class="d-flex justify-content-end">
<div class="ms-2">
</div> </div>
</div> </td>
</td> <td></td>
</tr> <td class="text-end">
<div class="d-flex justify-content-end">
<div class="ms-2">
</div>
</div>
</td>
</tr>
}
@foreach (var file in Data) @foreach (var file in Data)
{ {
<tr class="even"> <tr class="even">

View File

@@ -26,6 +26,11 @@
<TL>Logs</TL> <TL>Logs</TL>
</a> </a>
</li> </li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 5 ? "active" : "")" href="/admin/security/ddos">
<TL>Ddos protection</TL>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -34,6 +34,11 @@
<TL>Mail</TL> <TL>Mail</TL>
</a> </a>
</li> </li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 10 ? "active" : "")" href="/admin/system/plugins">
<TL>Plugins</TL>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,219 @@
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Services
@using Moonlight.App.Services.Files
@using System.Text.RegularExpressions
@inject ResourceService ResourceService
@inject SmartTranslateService SmartTranslateService
@foreach (var message in Messages.OrderByDescending(x => x.Id)) // Reverse messages to use auto scrolling
{
if (message.IsSupportMessage)
{
if (ViewAsSupport)
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
<div class="ms-3">
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
else if (message.IsSystemMessage)
{
<div class="separator separator-content border-primary my-15">
<span class="w-250px fw-bold">
@(message.Content)
</span>
</div>
}
else
{
if (ViewAsSupport)
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
<div class="ms-3">
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
}
@code
{
[Parameter]
public IEnumerable<TicketMessage> Messages { get; set; }
[Parameter]
public bool ViewAsSupport { get; set; }
}

View File

@@ -1,4 +1,5 @@
@using Moonlight.App.Database.Entities @using Moonlight.App.Database.Entities
@using Moonlight.App.Plugin.UI.Webspaces
@using Moonlight.App.Services @using Moonlight.App.Services
@using Moonlight.App.Services.Interop @using Moonlight.App.Services.Interop
@@ -34,26 +35,14 @@
<div class="card mb-xl-10 mb-5"> <div class="card mb-xl-10 mb-5">
<div class="card-body pt-0 pb-0"> <div class="card-body pt-0 pb-0">
<ul class="nav nav-stretch nav-line-tabs nav-line-tabs-2x border-transparent fs-5 fw-bold"> <ul class="nav nav-stretch nav-line-tabs nav-line-tabs-2x border-transparent fs-5 fw-bold">
<li class="nav-item mt-2"> @foreach (var tab in Context.Tabs)
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 0 ? "active" : "")" href="/webspace/@(WebSpace.Id)"> {
<TL>Dashboard</TL> <li class="nav-item mt-2">
</a> <a class="nav-link text-active-primary ms-0 me-10 py-5 @(Route == tab.Route ? "active" : "")" href="/webspace/@(WebSpace.Id + tab.Route)">
</li> <TL>@(tab.Name)</TL>
<li class="nav-item mt-2"> </a>
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 1 ? "active" : "")" href="/webspace/@(WebSpace.Id)/files"> </li>
<TL>Files</TL> }
</a>
</li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 2 ? "active" : "")" href="/webspace/@(WebSpace.Id)/sftp">
<TL>Sftp</TL>
</a>
</li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 3 ? "active" : "")" href="/webspace/@(WebSpace.Id)/databases">
<TL>Databases</TL>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -61,10 +50,13 @@
@code @code
{ {
[Parameter] [Parameter]
public int Index { get; set; } public string Route { get; set; }
[Parameter] [Parameter]
public WebSpace WebSpace { get; set; } public WebSpace WebSpace { get; set; }
[CascadingParameter]
public WebspacePageContext Context { get; set; }
private async Task Delete() private async Task Delete()
{ {

View File

@@ -22,6 +22,7 @@
@inject IpBanService IpBanService @inject IpBanService IpBanService
@inject DynamicBackgroundService DynamicBackgroundService @inject DynamicBackgroundService DynamicBackgroundService
@inject KeyListenerService KeyListenerService @inject KeyListenerService KeyListenerService
@inject ConfigService ConfigService
@{ @{
var uri = new Uri(NavigationManager.Uri); var uri = new Uri(NavigationManager.Uri);
@@ -245,6 +246,13 @@
RunDelayedMenu(1); RunDelayedMenu(1);
RunDelayedMenu(3); RunDelayedMenu(3);
RunDelayedMenu(5); RunDelayedMenu(5);
if (ConfigService.Get().Moonlight.EnableLatencyCheck)
{
await JsRuntime.InvokeVoidAsync("moonlight.loading.checkConnection",
ConfigService.Get().Moonlight.AppUrl,
ConfigService.Get().Moonlight.LatencyCheckThreshold);
}
} }
catch (Exception) catch (Exception)
{ {

View File

@@ -1,104 +0,0 @@
@page "/admin/nodes/ddos"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Repositories
@using BlazorTable
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Database.Entities
@using Moonlight.App.Events
@using Moonlight.App.Helpers
@using Moonlight.App.Services
@implements IDisposable
@inject DdosAttackRepository DdosAttackRepository
@inject SmartTranslateService SmartTranslateService
@inject EventSystem Event
@attribute [PermissionRequired(nameof(Permissions.AdminNodeDdos))]
<AdminNodesNavigation Index="1"/>
<LazyLoader @ref="LazyLoader" Load="Load">
<div class="card">
<div class="card-body pt-0">
<div class="table-responsive">
<Table TableItem="DdosAttack" Items="DdosAttacks" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true"/>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Status"))" Field="@(x => x.Ongoing)" Sortable="true" Filterable="true">
<Template>
@if (context.Ongoing)
{
<TL>DDos attack started</TL>
}
else
{
<TL>DDos attack stopped</TL>
}
</Template>
</Column>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Node"))" Field="@(x => x.Node)" Sortable="false" Filterable="false">
<Template>
<a href="/admin/nodes/view/@(context.Id)">
@(context.Node.Name)
</a>
</Template>
</Column>
<Column TableItem="DdosAttack" Title="Ip" Field="@(x => x.Ip)" Sortable="true" Filterable="true"/>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Status"))" Field="@(x => x.Ongoing)" Sortable="true" Filterable="true">
<Template>
@if (context.Ongoing)
{
@(context.Data)
<TL> packets</TL>
}
else
{
@(context.Data)
<span> MB</span>
}
</Template>
</Column>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Date"))" Field="@(x => x.Ongoing)" Sortable="true" Filterable="true">
<Template>
@(Formatter.FormatDate(context.CreatedAt))
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</div>
</div>
</LazyLoader>
@code
{
private DdosAttack[] DdosAttacks;
private LazyLoader LazyLoader;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await Event.On<DdosAttack>("node.ddos", this, async attack => await LazyLoader.Reload());
}
}
private Task Load(LazyLoader arg)
{
DdosAttacks = DdosAttackRepository
.Get()
.Include(x => x.Node)
.OrderByDescending(x => x.CreatedAt)
.ToArray();
return Task.CompletedTask;
}
public async void Dispose()
{
await Event.Off("node.ddos", this);
}
//TODO: Move to security
}

View File

@@ -88,7 +88,7 @@ else
else else
{ {
<span> <span>
@(Formatter.FormatSize(MemoryMetrics.Used)) <TL>of</TL> @(Formatter.FormatSize(MemoryMetrics.Total)) <TL>memory used</TL> @(Formatter.FormatSize(ByteSizeValue.FromKiloBytes(MemoryMetrics.Used).Bytes)) <TL>of</TL> @(Formatter.FormatSize(ByteSizeValue.FromKiloBytes(MemoryMetrics.Total).Bytes)) <TL>memory used</TL>
</span> </span>
} }
</span> </span>

View File

@@ -0,0 +1,148 @@
@page "/admin/security/ddos"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities
@using Moonlight.App.Services
@using BlazorTable
@using Moonlight.App.Helpers
@using Moonlight.App.Services.Background
@inject Repository<BlocklistIp> BlocklistIpRepository
@inject Repository<WhitelistIp> WhitelistIpRepository
@inject SmartTranslateService SmartTranslateService
@inject DdosProtectionService DdosProtectionService
@attribute [PermissionRequired(nameof(Permissions.AdminSecurityDdos))]
<AdminSecurityNavigation Index="5"/>
<div class="card card-body mb-5">
<div class="d-flex justify-content-center fs-4">
<span class="me-3">
@(BlocklistIps.Length) <TL>blocked IPs</TL>
</span>
<span>
@(WhitelistIps.Length) <TL>whitelisted IPs</TL>
</span>
</div>
</div>
<div class="card card-body mb-5">
<div class="input-group input-group-lg">
<input @bind="Ip" type="text" class="form-control">
<WButton CssClasses="btn-secondary" OnClick="BlockIp" Text="@(SmartTranslateService.Translate("Block"))"/>
<WButton CssClasses="btn-secondary" OnClick="WhitelistIp" Text="@(SmartTranslateService.Translate("Whitelist"))"/>
</div>
</div>
<div class="card card-body mb-5">
<LazyLoader @ref="BlocklistLazyLoader" Load="LoadBlocklist">
<div class="table-responsive">
<Table TableItem="BlocklistIp" Items="BlocklistIps" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="BlocklistIp" Width="30%" Title="@(SmartTranslateService.Translate("Ip"))" Field="@(x => x.Ip)" Filterable="true" Sortable="false"/>
<Column TableItem="BlocklistIp" Width="30%" Title="@(SmartTranslateService.Translate("Packets"))" Field="@(x => x.Packets)" Filterable="true" Sortable="true">
<Template>
@(context.Packets) <TL>packets</TL>
</Template>
</Column>
<Column TableItem="BlocklistIp" Width="30%" Title="" Field="@(x => x.ExpiresAt)" Filterable="true" Sortable="true">
<Template>
@Formatter.FormatUptime(context.ExpiresAt - DateTime.UtcNow) <TL>remaining</TL>
</Template>
</Column>
<Column TableItem="BlocklistIp" Width="15%" Title="" Field="@(x => x.Id)" Filterable="false" Sortable="false">
<Template>
<div class="text-end">
<WButton Text="@(SmartTranslateService.Translate("Details"))" />
<DeleteButton Confirm="true" OnClick="() => RevokeBlocklistIp(context)" />
</div>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</LazyLoader>
</div>
<div class="card card-body">
<LazyLoader @ref="WhitelistLazyLoader" Load="LoadWhitelist">
<div class="table-responsive">
<Table TableItem="WhitelistIp" Items="WhitelistIps" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="WhitelistIp" Width="85%" Title="@(SmartTranslateService.Translate("Ip"))" Field="@(x => x.Ip)" Filterable="true" Sortable="false"/>
<Column TableItem="WhitelistIp" Width="15%" Title="" Field="@(x => x.Id)" Filterable="false" Sortable="false">
<Template>
<div class="text-end">
<DeleteButton Confirm="true" OnClick="() => RevokeWhitelistIp(context)" />
</div>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</LazyLoader>
</div>
@code
{
private BlocklistIp[] BlocklistIps = Array.Empty<BlocklistIp>();
private WhitelistIp[] WhitelistIps = Array.Empty<WhitelistIp>();
private LazyLoader BlocklistLazyLoader;
private LazyLoader WhitelistLazyLoader;
private string Ip = "";
private async Task LoadBlocklist(LazyLoader _)
{
BlocklistIps = BlocklistIpRepository
.Get()
.ToArray();
await InvokeAsync(StateHasChanged);
}
private async Task LoadWhitelist(LazyLoader _)
{
WhitelistIps = WhitelistIpRepository
.Get()
.ToArray();
await InvokeAsync(StateHasChanged);
}
private async Task BlockIp()
{
await DdosProtectionService.BlocklistIp(Ip, -1);
Ip = "";
await BlocklistLazyLoader.Reload();
}
private async Task RevokeBlocklistIp(BlocklistIp blocklistIp)
{
await DdosProtectionService.UnBlocklistIp(blocklistIp.Ip);
await BlocklistLazyLoader.Reload();
}
private async Task WhitelistIp()
{
WhitelistIpRepository.Add(new()
{
Ip = Ip
});
Ip = "";
await WhitelistLazyLoader.Reload();
}
private async Task RevokeWhitelistIp(WhitelistIp whitelistIp)
{
WhitelistIpRepository.Delete(whitelistIp);
await WhitelistLazyLoader.Reload();
}
}

View File

@@ -40,6 +40,8 @@
{ {
SecurityLogs = SecurityLogRepository SecurityLogs = SecurityLogRepository
.Get() .Get()
.ToArray()
.OrderByDescending(x => x.CreatedAt)
.ToArray(); .ToArray();
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -38,6 +38,15 @@
} }
else else
{ {
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="scanAllServers" @bind="MalwareScanService.ScanAllServers">
<label class="form-check-label" for="scanAllServers">
<TL>Scan all servers</TL>
</label>
</div>
</div>
<WButton Text="@(SmartTranslateService.Translate("Start scan"))" <WButton Text="@(SmartTranslateService.Translate("Start scan"))"
CssClasses="btn-success" CssClasses="btn-success"
OnClick="MalwareScanService.Start"> OnClick="MalwareScanService.Start">

View File

@@ -1,101 +1,379 @@
@page "/admin/support" @page "/admin/support"
@page "/admin/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities @using Moonlight.App.Database.Entities
@using Moonlight.App.Events @using Moonlight.App.Events
@using Moonlight.App.Services.SupportChat @using Moonlight.App.Helpers
@using Moonlight.App.Models.Misc
@using Moonlight.App.Services
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject SupportChatServerService ServerService @inject TicketAdminService AdminService
@inject EventSystem Event @inject SmartTranslateService SmartTranslateService
@inject EventSystem EventSystem
@implements IDisposable @inject IdentityService IdentityService
@attribute [PermissionRequired(nameof(Permissions.AdminSupport))] @attribute [PermissionRequired(nameof(Permissions.AdminSupport))]
<LazyLoader @ref="LazyLoader" Load="Load"> @implements IDisposable
<div class="card">
<div class="card-body"> <div class="d-flex flex-column flex-lg-row">
<div class="d-flex flex-column flex-xl-row p-5 pb-0"> <div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="flex-lg-row-fluid me-xl-15 mb-20 mb-xl-0"> <div class="card card-flush">
<div class="mb-0"> <div class="card-body pt-5">
<h1 class="text-dark mb-6"> <div class="scroll-y me-n5 pe-5 h-200px h-lg-auto" data-kt-scroll="true" data-kt-scroll-activate="{default: false, lg: true}" data-kt-scroll-max-height="auto" data-kt-scroll-dependencies="#kt_header, #kt_app_header, #kt_toolbar, #kt_app_toolbar, #kt_footer, #kt_app_footer, #kt_chat_contacts_header" data-kt-scroll-wrappers="#kt_content, #kt_app_content, #kt_chat_contacts_body" data-kt-scroll-offset="5px" style="max-height: 601px;">
<TL>Open chats</TL> <div class="separator separator-content border-primary mb-10 mt-5">
</h1> <span class="w-250px fw-bold fs-5">
<div class="separator"></div> <TL>Unassigned tickets</TL>
<div class="mb-5"> </span>
@if (OpenChats.Any()) </div>
@foreach (var ticket in UnAssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{ {
foreach (var chat in OpenChats) <div class="fw-semibold text-muted">
{ @(ticket.Value.Content)
<div class="d-flex mt-3 mb-3 ms-2 me-2">
<table>
<tr>
<td rowspan="2">
<span class="svg-icon svg-icon-2x me-5 ms-n1 svg-icon-success">
<i class="text-primary bx bx-md bx-message-dots"></i>
</span>
</td>
<td>
<a href="/admin/support/view/@(chat.Key.Id)" class="text-dark text-hover-primary fs-4 me-3 fw-semibold">
@(chat.Key.FirstName) @(chat.Key.LastName)
</a>
</td>
</tr>
<tr>
<td>
<span class="text-muted fw-semibold fs-6">
@if (chat.Value == null)
{
<TL>No message sent yet</TL>
}
else
{
@(chat.Value.Content)
}
</span>
</td>
</tr>
</table>
</div>
<div class="separator"></div>
}
}
else
{
<div class="alert alert-info">
<TL>No support chat is currently open</TL>
</div> </div>
} }
</div> </div>
</div> </div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div> </div>
</div>
if (ticket.Key != UnAssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
@if (AssignedTickets.Any())
{
<div class="separator separator-content border-primary mb-5 mt-8">
<span class="w-250px fw-bold fs-5">
<TL>Assigned tickets</TL>
</span>
</div>
}
@foreach (var ticket in AssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != AssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
</div> </div>
</div> </div>
</LazyLoader> </div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (AdminService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(AdminService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (AdminService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(AdminService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (AdminService.Ticket.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(AdminService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="input-group">
<div class="me-3">
@if (AdminService.Ticket!.AssignedTo == null)
{
<WButton Text="@(SmartTranslateService.Translate("Claim"))" OnClick="AdminService.Claim"/>
}
else
{
<WButton Text="@(SmartTranslateService.Translate("Unclaim"))" OnClick="AdminService.UnClaim"/>
}
</div>
<select @bind="Priority" class="form-select rounded-start">
@foreach (var priority in (TicketPriority[])Enum.GetValues(typeof(TicketPriority)))
{
if (Priority == priority)
{
<option value="@(priority)" selected="">@(priority)</option>
}
else
{
<option value="@(priority)">@(priority)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update priority"))"
CssClasses="btn-primary"
OnClick="UpdatePriority">
</WButton>
<select @bind="Status" class="form-select">
@foreach (var status in (TicketStatus[])Enum.GetValues(typeof(TicketStatus)))
{
if (Status == status)
{
<option value="@(status)" selected="">@(status)</option>
}
else
{
<option value="@(status)">@(status)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update status"))"
CssClasses="btn-primary"
OnClick="UpdateStatus">
</WButton>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (AdminService.Ticket == null)
{
}
else
{
<TicketMessageView Messages="Messages" ViewAsSupport="true"/>
}
</div>
</div>
@if (AdminService.Ticket != null)
{
<div class="card-footer pt-4" id="kt_chat_messenger_footer">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</div>
</div>
}
</div>
</div>
</div>
@code @code
{ {
private LazyLoader? LazyLoader; [Parameter]
private Dictionary<User, SupportChatMessage?> OpenChats = new(); public int Id { get; set; }
protected override async Task OnInitializedAsync() private Dictionary<Ticket, TicketMessage?> AssignedTickets;
private Dictionary<Ticket, TicketMessage?> UnAssignedTickets;
private List<TicketMessage> Messages = new();
private string MessageText;
private SmartFileSelect FileSelect;
private TicketPriority Priority;
private TicketStatus Status;
protected override async Task OnParametersSetAsync()
{ {
await Event.On<User>("supportChat.new", this, async user => await Unsubscribe();
{ await ReloadTickets();
//TODO: Play sound or smth. Add a config option await Subscribe();
OpenChats = await ServerService.GetOpenChats(); await InvokeAsync(StateHasChanged);
await InvokeAsync(StateHasChanged);
});
} }
private async Task Load(LazyLoader arg) // Only for initial load private async Task UpdatePriority()
{ {
OpenChats = await ServerService.GetOpenChats(); await AdminService.UpdatePriority(Priority);
}
private async Task UpdateStatus()
{
await AdminService.UpdateStatus(Status);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if (string.IsNullOrEmpty(MessageText))
return;
var msg = await AdminService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (AdminService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{AdminService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{AdminService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
}
}
private async Task Unsubscribe()
{
await EventSystem.Off("tickets.new", this);
if (AdminService.Ticket != null)
{
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
AdminService.Ticket = null;
AssignedTickets = await AdminService.GetAssigned();
UnAssignedTickets = await AdminService.GetUnAssigned();
if (Id != 0)
{
AdminService.Ticket = AssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (AdminService.Ticket == null)
{
AdminService.Ticket = UnAssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
}
if (AdminService.Ticket == null)
return;
Status = AdminService.Ticket.Status;
Priority = AdminService.Ticket.Priority;
if (reloadMessages)
{
var msgs = await AdminService.GetMessages();
Messages = msgs.ToList();
}
}
} }
public async void Dispose() public async void Dispose()
{ {
await Event.Off("supportChat.new", this); await Unsubscribe();
} }
} }

View File

@@ -1,313 +0,0 @@
@page "/admin/support/view/{Id:int}"
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Moonlight.App.Services.SupportChat
@using System.Text.RegularExpressions
@using Moonlight.App.Services.Files
@inject SupportChatAdminService AdminService
@inject UserRepository UserRepository
@inject SmartTranslateService SmartTranslateService
@inject ResourceService ResourceService
@implements IDisposable
@attribute [PermissionRequired(nameof(Permissions.AdminSupportView))]
<LazyLoader Load="Load">
@if (User == null)
{
<div class="alert alert-danger">
<TL>User not found</TL>
</div>
}
else
{
<div class="row">
<div class="d-flex flex-column flex-xl-row p-7">
<div class="flex-lg-row-fluid me-6 mb-20 mb-xl-0">
<div class="card">
<div class="card-body">
<LazyLoader Load="LoadMessages">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages)
{
if (message.Sender == null || message.Sender.Id != User.Id)
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">
@if (message.Sender != null)
{
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
}
else
{
<span>
<TL>System</TL>
</span>
}
</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@if (message.Sender == null)
{
<TL>@(message.Content)</TL>
}
else
{
foreach (var line in message.Content.Split("\n"))
{
@(line)<br/>
}
if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br />
}
}
@if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
</div>
</LazyLoader>
</div>
<div class="card-footer">
@if (Typing.Any())
{
<span class="mb-5 fs-5 d-flex flex-row">
<div class="wave me-1">
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px"></div>
</div>
@if (Typing.Length > 1)
{
<span>
@(Typing.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
</span>
}
else
{
<span>
@(Typing.First()) <TL>is typing</TL>
</span>
}
</span>
}
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="SmartFileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="Content" @oninput="OnTyping" class="form-control mb-3 form-control-flush" rows="1" placeholder="Type a message">
</textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="Send">
</WButton>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="flex-column flex-lg-row-auto w-100 mw-lg-300px mw-xxl-350px">
<div class="card p-10 mb-15 pb-8">
<h2 class="text-dark fw-bold mb-2">
<TL>User information</TL>
</h2>
<div class="d-flex align-items-center mb-1">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<TL>Name</TL>: @(User.FirstName) @User.LastName
</span>
</div>
<div class="d-flex align-items-center mb-2">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<TL>Email</TL>: <a href="/admin/users/view/@User.Id">@(User.Email)</a>
</span>
</div>
<div class="align-items-center mt-3">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<WButton Text="@(SmartTranslateService.Translate("Close ticket"))"
WorkingText="@(SmartTranslateService.Translate("Closing"))"
CssClasses="btn-danger float-end"
OnClick="CloseTicket">
</WButton>
</span>
</div>
</div>
</div>
</div>
</div>
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
private User? User;
private List<SupportChatMessage> Messages = new();
private string[] Typing = Array.Empty<string>();
private string Content = "";
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
private SmartFileSelect SmartFileSelect;
private async Task Load(LazyLoader arg)
{
User = UserRepository
.Get()
.FirstOrDefault(x => x.Id == Id);
if (User != null)
{
AdminService.OnMessage += OnMessage;
AdminService.OnTypingChanged += OnTypingChanged;
await AdminService.Start(User);
}
}
private async Task LoadMessages(LazyLoader arg)
{
Messages = (await AdminService.GetMessages()).ToList();
}
private async Task OnTypingChanged(string[] typing)
{
Typing = typing;
await InvokeAsync(StateHasChanged);
}
private async Task OnMessage(SupportChatMessage arg)
{
Messages.Insert(0, arg);
//TODO: Sound when message from system or admin
await InvokeAsync(StateHasChanged);
}
private async Task Send()
{
if (SmartFileSelect.SelectedFile != null && string.IsNullOrEmpty(Content))
Content = "File upload";
if (string.IsNullOrEmpty(Content))
return;
var message = await AdminService.SendMessage(Content, SmartFileSelect.SelectedFile);
Content = "";
await SmartFileSelect.RemoveSelection();
Messages.Insert(0, message);
await InvokeAsync(StateHasChanged);
}
private async Task CloseTicket()
{
await AdminService.Close();
}
private async Task OnTyping()
{
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
{
LastTypingTimestamp = DateTime.UtcNow;
await AdminService.SendTyping();
}
}
public void Dispose()
{
AdminService?.Dispose();
}
}

View File

@@ -0,0 +1,138 @@
@page "/admin/system/plugins"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Services.Plugins
@using BlazorTable
@using Moonlight.App.Models.Misc
@using Moonlight.App.Plugin
@using Moonlight.App.Services
@using Moonlight.App.Services.Interop
@inject PluginStoreService PluginStoreService
@inject SmartTranslateService SmartTranslateService
@inject PluginService PluginService
@inject ToastService ToastService
@inject ModalService ModalService
@attribute [PermissionRequired(nameof(Permissions.AdminSysPlugins))]
<AdminSystemNavigation Index="10"/>
<div class="card mb-5">
<div class="card-header">
<span class="card-title">
<TL>Installed plugins</TL>
</span>
</div>
<div class="card-body">
<div class="table-responsive">
<Table TableItem="MoonlightPlugin" Items="PluginService.Plugins" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="MoonlightPlugin" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Filterable="true" Sortable="false"/>
<Column TableItem="MoonlightPlugin" Title="@(SmartTranslateService.Translate("Author"))" Field="@(x => x.Author)" Filterable="true" Sortable="false"/>
<Column TableItem="MoonlightPlugin" Title="@(SmartTranslateService.Translate("Version"))" Field="@(x => x.Version)" Filterable="true" Sortable="false"/>
<Column TableItem="MoonlightPlugin" Title="@(SmartTranslateService.Translate("Path"))" Field="@(x => x.Name)" Filterable="false" Sortable="false">
<Template>
@{
var path = PluginService.PluginFiles[context];
}
<span>@(path)</span>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">
<TL>Official plugins</TL>
</span>
</div>
<div class="card-body">
<LazyLoader @ref="PluginsLazyLoader" Load="LoadOfficialPlugins">
<div class="table-responsive">
<Table TableItem="OfficialMoonlightPlugin" Items="PluginList" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="OfficialMoonlightPlugin" Width="80%" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Filterable="true" Sortable="false"/>
<Column TableItem="OfficialMoonlightPlugin" Width="10%" Title="" Field="@(x => x.Name)" Filterable="false" Sortable="false">
<Template>
<WButton Text="@(SmartTranslateService.Translate("Show readme"))"
CssClasses="btn-secondary"
OnClick="() => ShowOfficialPluginReadme(context)"/>
</Template>
</Column>
<Column TableItem="OfficialMoonlightPlugin" Width="10%" Title="" Field="@(x => x.Name)" Filterable="false" Sortable="false">
<Template>
@if (PluginService.PluginFiles.Values.Any(x =>
Path.GetFileName(x).Replace(".dll", "") == context.Name))
{
<WButton Text="@(SmartTranslateService.Translate("Update"))"
WorkingText="@(SmartTranslateService.Translate("Updating"))"
CssClasses="btn-primary"
OnClick="() => UpdateOfficialPlugin(context)"/>
}
else
{
<WButton Text="@(SmartTranslateService.Translate("Install"))"
WorkingText="@(SmartTranslateService.Translate("Installing"))"
CssClasses="btn-primary"
OnClick="() => InstallOfficialPlugin(context)"/>
}
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</LazyLoader>
</div>
</div>
<div id="pluginReadme" class="modal" style="display: none">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<TL>Plugin readme</TL>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@((MarkupString)Markdig.Markdown.ToHtml(PluginReadme))
</div>
</div>
</div>
</div>
@code
{
private LazyLoader PluginsLazyLoader;
private OfficialMoonlightPlugin[] PluginList;
private string PluginReadme = "";
private async Task LoadOfficialPlugins(LazyLoader lazyLoader)
{
PluginList = await PluginStoreService.GetPlugins();
}
private async Task ShowOfficialPluginReadme(OfficialMoonlightPlugin plugin)
{
PluginReadme = await PluginStoreService.GetPluginReadme(plugin);
await InvokeAsync(StateHasChanged);
await ModalService.Show("pluginReadme");
}
private async Task InstallOfficialPlugin(OfficialMoonlightPlugin plugin)
{
await PluginStoreService.InstallPlugin(plugin);
await ToastService.Success(SmartTranslateService.Translate("Successfully installed plugin"));
await InvokeAsync(StateHasChanged);
}
private async Task UpdateOfficialPlugin(OfficialMoonlightPlugin plugin)
{
await PluginStoreService.InstallPlugin(plugin, true);
await ToastService.Success(SmartTranslateService.Translate("Successfully installed plugin. You need to reboot to apply changes"));
}
}

View File

@@ -4,6 +4,7 @@
@using Moonlight.App.Repositories @using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities @using Moonlight.App.Database.Entities
@using BlazorTable @using BlazorTable
@using Moonlight.App.Helpers
@using Moonlight.App.Services @using Moonlight.App.Services
@inject UserRepository UserRepository @inject UserRepository UserRepository
@@ -39,7 +40,11 @@
</Column> </Column>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("First name"))" Field="@(x => x.FirstName)" Sortable="true" Filterable="true"/> <Column TableItem="User" Title="@(SmartTranslateService.Translate("First name"))" Field="@(x => x.FirstName)" Sortable="true" Filterable="true"/>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Last name"))" Field="@(x => x.LastName)" Sortable="true" Filterable="true"/> <Column TableItem="User" Title="@(SmartTranslateService.Translate("Last name"))" Field="@(x => x.LastName)" Sortable="true" Filterable="true"/>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Created at"))" Field="@(x => x.CreatedAt)" Sortable="true" Filterable="true"/> <Column TableItem="User" Title="@(SmartTranslateService.Translate("Created at"))" Field="@(x => x.CreatedAt)" Sortable="true" Filterable="true">
<Template>
<span>@(Formatter.FormatDate(context.CreatedAt))</span>
</Template>
</Column>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false"> <Column TableItem="User" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false">
<Template> <Template>
<a href="/admin/users/edit/@(context.Id)/"> <a href="/admin/users/edit/@(context.Id)/">

View File

@@ -6,12 +6,14 @@
@using Moonlight.App.Services @using Moonlight.App.Services
@using CloudFlare.Client.Enumerators @using CloudFlare.Client.Enumerators
@using Moonlight.App.Services.Interop @using Moonlight.App.Services.Interop
@using Moonlight.App.Services.Sessions
@inject DomainRepository DomainRepository @inject DomainRepository DomainRepository
@inject DomainService DomainService @inject DomainService DomainService
@inject SmartTranslateService SmartTranslateService @inject SmartTranslateService SmartTranslateService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AlertService AlertService @inject AlertService AlertService
@inject IdentityService IdentityService
<LazyLoader Load="Load"> <LazyLoader Load="Load">
@if (Domain == null) @if (Domain == null)
@@ -180,9 +182,6 @@
{ {
[Parameter] [Parameter]
public int Id { get; set; } public int Id { get; set; }
[CascadingParameter]
public User? User { get; set; }
private Domain? Domain; private Domain? Domain;
private DnsRecord[] DnsRecords; private DnsRecord[] DnsRecords;
@@ -205,13 +204,13 @@
if (Domain == null) if (Domain == null)
return; return;
if (User == null) if (IdentityService.User == null)
{ {
Domain = null; Domain = null;
return; return;
} }
if (Domain.Owner.Id != User.Id && !User.Admin) if (Domain.Owner.Id != IdentityService.User.Id && !IdentityService.User.Admin)
{ {
Domain = null; Domain = null;
return; return;

View File

@@ -3,8 +3,10 @@
@using Moonlight.App.Repositories.Domains @using Moonlight.App.Repositories.Domains
@using Moonlight.App.Database.Entities @using Moonlight.App.Database.Entities
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Moonlight.App.Services.Sessions
@inject DomainRepository DomainRepository @inject DomainRepository DomainRepository
@inject IdentityService IdentityService
<LazyLoader Load="Load"> <LazyLoader Load="Load">
@if (Domains.Any()) @if (Domains.Any())
@@ -49,11 +51,8 @@
} }
</LazyLoader> </LazyLoader>
@code { @code
{
[CascadingParameter]
public User? User { get; set; }
private Domain[] Domains { get; set; } private Domain[] Domains { get; set; }
private Task Load(LazyLoader loader) private Task Load(LazyLoader loader)
@@ -62,7 +61,7 @@
.Get() .Get()
.Include(x => x.SharedDomain) .Include(x => x.SharedDomain)
.Include(x => x.Owner) .Include(x => x.Owner)
.Where(x => x.Owner == User) .Where(x => x.Owner == IdentityService.User)
.ToArray(); .ToArray();
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -4,12 +4,17 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Moonlight.App.Database.Entities @using Moonlight.App.Database.Entities
@using Moonlight.App.Events @using Moonlight.App.Events
@using Moonlight.App.Helpers
@using Moonlight.App.Helpers.Wings @using Moonlight.App.Helpers.Wings
@using Moonlight.App.Helpers.Wings.Enums @using Moonlight.App.Helpers.Wings.Enums
@using Moonlight.App.Plugin.UI
@using Moonlight.App.Plugin.UI.Servers
@using Moonlight.App.Repositories @using Moonlight.App.Repositories
@using Moonlight.App.Services @using Moonlight.App.Services
@using Moonlight.App.Services.Plugins
@using Moonlight.App.Services.Sessions @using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Xterm @using Moonlight.Shared.Components.Xterm
@using Moonlight.Shared.Views.Server.Settings
@using Newtonsoft.Json @using Newtonsoft.Json
@inject ImageRepository ImageRepository @inject ImageRepository ImageRepository
@@ -21,10 +26,11 @@
@inject DynamicBackgroundService DynamicBackgroundService @inject DynamicBackgroundService DynamicBackgroundService
@inject SmartTranslateService SmartTranslateService @inject SmartTranslateService SmartTranslateService
@inject IdentityService IdentityService @inject IdentityService IdentityService
@inject PluginService PluginService
@implements IDisposable @implements IDisposable
<LazyLoader Load="LoadData"> <LazyLoader Load="Load">
@if (CurrentServer == null) @if (CurrentServer == null)
{ {
<NotFoundAlert/> <NotFoundAlert/>
@@ -33,7 +39,7 @@
{ {
if (NodeOnline) if (NodeOnline)
{ {
if (Console.ConsoleState == ConsoleState.Connected) if (Console != null && Console.ConsoleState == ConsoleState.Connected)
{ {
if (Console.ServerState == ServerState.Installing || CurrentServer.Installing) if (Console.ServerState == ServerState.Installing || CurrentServer.Installing)
{ {
@@ -75,38 +81,18 @@
<CascadingValue Value="Console"> <CascadingValue Value="Console">
<CascadingValue Value="CurrentServer"> <CascadingValue Value="CurrentServer">
<CascadingValue Value="Tags"> <CascadingValue Value="Tags">
<SmartRouter Route="@Route"> <CascadingValue Value="Context">
<Route Path="/"> <SmartRouter Route="@Route">
<ServerNavigation Index="0"> @foreach (var tab in Context.Tabs)
<ServerConsole/> {
</ServerNavigation> <Route Path="@(tab.Route)">
</Route> <ServerNavigation Route="@(tab.Route)">
<Route Path="/files"> @(tab.Component)
<ServerNavigation Index="1"> </ServerNavigation>
<ServerFiles/> </Route>
</ServerNavigation> }
</Route> </SmartRouter>
<Route Path="/backups"> </CascadingValue>
<ServerNavigation Index="2">
<ServerBackups/>
</ServerNavigation>
</Route>
<Route Path="/network">
<ServerNavigation Index="3">
<ServerNetwork/>
</ServerNavigation>
</Route>
<Route Path="/addons">
<ServerNavigation Index="4">
<ServerAddons/>
</ServerNavigation>
</Route>
<Route Path="/settings">
<ServerNavigation Index="5">
<ServerSettings/>
</ServerNavigation>
</Route>
</SmartRouter>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
@@ -114,8 +100,17 @@
} }
else else
{ {
<div class="alert alert-info"> <div class="d-flex justify-content-center flex-center">
<TL>Connecting</TL> <div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<TL>Connecting</TL>
</h1>
<p class="card-text fs-4">
<TL>Connecting to the servers console to stream logs and the current resource usage</TL>
</p>
</div>
</div>
</div> </div>
} }
} }
@@ -144,21 +139,19 @@
[Parameter] [Parameter]
public string ServerUuid { get; set; } public string ServerUuid { get; set; }
[Parameter] [Parameter]
public string? Route { get; set; } public string? Route { get; set; }
private WingsConsole? Console; private WingsConsole? Console;
private Server? CurrentServer; private Server? CurrentServer;
private Node Node;
private bool NodeOnline = false; private bool NodeOnline = false;
private Image Image;
private NodeAllocation NodeAllocation; private NodeAllocation NodeAllocation;
private string[] Tags; private string[] Tags;
private Terminal? InstallConsole; private Terminal? InstallConsole;
private ServerPageContext Context;
protected override void OnInitialized() protected override void OnInitialized()
{ {
Console = new(); Console = new();
@@ -171,109 +164,167 @@
Console.OnMessage += async (_, s) => Console.OnMessage += async (_, s) =>
{ {
if (Console.ServerState == ServerState.Installing) if (Console.ServerState == ServerState.Installing && InstallConsole != null)
{ {
if (InstallConsole != null) if (s.IsInternal)
{ await InstallConsole.WriteLine("\x1b[38;5;16;48;5;135m\x1b[39m\x1b[1m Moonlight \x1b[0m " + s.Content + "\x1b[0m");
if (s.IsInternal) else
await InstallConsole.WriteLine("\x1b[38;5;16;48;5;135m\x1b[39m\x1b[1m Moonlight \x1b[0m " + s.Content + "\x1b[0m"); await InstallConsole.WriteLine(s.Content);
else
await InstallConsole.WriteLine(s.Content);
}
} }
}; };
} }
private async Task LoadData(LazyLoader lazyLoader) private async Task Load(LazyLoader lazyLoader)
{ {
await lazyLoader.SetText("Requesting server data"); await lazyLoader.SetText("Requesting server data");
try if (!Guid.TryParse(ServerUuid, out var uuid))
{ return;
var uuid = Guid.Parse(ServerUuid);
CurrentServer = ServerRepository CurrentServer = ServerRepository
.Get() .Get()
.Include(x => x.Allocations) .Include(x => x.Allocations)
.Include(x => x.Image) .Include(x => x.Image)
.Include(x => x.Node) .Include("Image.Variables")
.Include(x => x.Variables) .Include(x => x.Node)
.Include(x => x.MainAllocation) .Include(x => x.Variables)
.Include(x => x.Owner) .Include(x => x.MainAllocation)
.First(x => x.Uuid == uuid); .Include(x => x.Owner)
} .First(x => x.Uuid == uuid);
catch (Exception)
{
// ignored
}
if (CurrentServer != null) if (CurrentServer != null)
{ {
if (CurrentServer.Owner.Id != IdentityService.User.Id && !IdentityService.User.Admin) if (CurrentServer.Owner.Id != IdentityService.User.Id && !IdentityService.User.Admin)
{
CurrentServer = null; CurrentServer = null;
} return;
}
if (string.IsNullOrEmpty(CurrentServer.Image.BackgroundImageUrl))
await DynamicBackgroundService.Reset();
else
await DynamicBackgroundService.Change(CurrentServer.Image.BackgroundImageUrl);
if (CurrentServer != null)
{
await lazyLoader.SetText("Checking node online status"); await lazyLoader.SetText("Checking node online status");
NodeOnline = await ServerService.IsHostUp(CurrentServer); NodeOnline = await ServerService.IsHostUp(CurrentServer);
if (NodeOnline) if (!NodeOnline)
{ return;
await lazyLoader.SetText("Checking server variables");
var image = ImageRepository await lazyLoader.SetText("Checking server variables");
.Get()
.Include(x => x.Variables)
.First(x => x.Id == CurrentServer.Image.Id);
// Live variable migration // Live variable migration
foreach (var variable in image.Variables) foreach (var variable in CurrentServer.Image.Variables)
{
if (CurrentServer.Variables.All(x => x.Key != variable.Key))
{ {
if (!CurrentServer.Variables.Any(x => x.Key == variable.Key)) CurrentServer.Variables.Add(new ServerVariable()
{ {
CurrentServer.Variables.Add(new ServerVariable() Key = variable.Key,
{ Value = variable.DefaultValue
Key = variable.Key, });
Value = variable.DefaultValue
});
ServerRepository.Update(CurrentServer); ServerRepository.Update(CurrentServer);
}
} }
}
// Tags // Tags
await lazyLoader.SetText("Requesting tags"); await lazyLoader.SetText("Reading tags");
Tags = JsonConvert.DeserializeObject<string[]>(image.TagsJson) ?? Array.Empty<string>(); Tags = JsonConvert.DeserializeObject<string[]>(CurrentServer.Image.TagsJson) ?? Array.Empty<string>();
Image = image;
await lazyLoader.SetText("Connecting to console"); // Build server pages and settings
await ReconnectConsole(); Context = new ServerPageContext()
{
Server = CurrentServer,
User = IdentityService.User,
ImageTags = Tags
};
await Event.On<Server>($"server.{CurrentServer.Uuid}.installComplete", this, server => Context.Tabs.Add(new()
{ {
NavigationManager.NavigateTo(NavigationManager.Uri, true); Name = "Console",
Route = "/",
Icon = "terminal",
Component = ComponentHelper.FromType(typeof(ServerConsole))
});
return Task.CompletedTask; Context.Tabs.Add(new()
}); {
Name = "Files",
Route = "/files",
Icon = "folder",
Component = ComponentHelper.FromType(typeof(ServerFiles))
});
await Event.On<Server>($"server.{CurrentServer.Uuid}.archiveStatusChanged", this, server => Context.Tabs.Add(new()
{ {
NavigationManager.NavigateTo(NavigationManager.Uri, true); Name = "Backups",
Route = "/backups",
Icon = "box",
Component = ComponentHelper.FromType(typeof(ServerBackups))
});
return Task.CompletedTask; Context.Tabs.Add(new()
}); {
Name = "Network",
Route = "/network",
Icon = "wifi",
Component = ComponentHelper.FromType(typeof(ServerNetwork))
});
if (string.IsNullOrEmpty(Image.BackgroundImageUrl)) Context.Tabs.Add(new()
await DynamicBackgroundService.Reset(); {
else Name = "Settings",
await DynamicBackgroundService.Change(Image.BackgroundImageUrl); Route = "/settings",
} Icon = "cog",
Component = ComponentHelper.FromType(typeof(ServerSettings))
});
// Add default settings
Context.Settings.Add(new()
{
Name = "Rename",
Component = ComponentHelper.FromType(typeof(ServerRenameSetting))
});
Context.Settings.Add(new()
{
Name = "Reset",
Component = ComponentHelper.FromType(typeof(ServerResetSetting))
});
Context.Settings.Add(new()
{
Name = "Delete",
Component = ComponentHelper.FromType(typeof(ServerDeleteSetting))
});
Context = await PluginService.BuildServerPage(Context);
await lazyLoader.SetText("Connecting to console");
await ReconnectConsole();
// Register event system
await Event.On<Server>($"server.{CurrentServer.Uuid}.installComplete", this, server =>
{
NavigationManager.NavigateTo(NavigationManager.Uri, true);
return Task.CompletedTask;
});
await Event.On<Server>($"server.{CurrentServer.Uuid}.archiveStatusChanged", this, server =>
{
NavigationManager.NavigateTo(NavigationManager.Uri, true);
return Task.CompletedTask;
});
} }
} }

View File

@@ -3,6 +3,8 @@
@using Moonlight.App.Helpers @using Moonlight.App.Helpers
@using Moonlight.App.Helpers.Wings @using Moonlight.App.Helpers.Wings
@using Moonlight.App.Helpers.Wings.Enums @using Moonlight.App.Helpers.Wings.Enums
@using Moonlight.App.Plugin.UI
@using Moonlight.App.Plugin.UI.Servers
@using Moonlight.App.Services.Sessions @using Moonlight.App.Services.Sessions
@inject SmartTranslateService TranslationService @inject SmartTranslateService TranslationService
@@ -26,22 +28,22 @@
</div> </div>
</div> </div>
<div class="col-4 d-flex flex-column flex-end mb-1"> <div class="col-4 d-flex flex-column flex-end mb-1">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-md">
<button class="w-100 nav-link btn btn-sm btn-success fw-bold px-4 me-1 @(Console.ServerState == ServerState.Offline ? "" : "disabled")" aria-selected="true" role="tab" @onclick="Start"> <button class="nav-link btn btn-md btn-success fw-bold px-4 me-1 @(Console.ServerState == ServerState.Offline ? "" : "disabled")" aria-selected="true" role="tab" @onclick="Start">
<TL>Start</TL> <TL>Start</TL>
</button> </button>
<button class="w-100 nav-link btn btn-sm btn-primary fw-bold px-4 me-1 @(Console.ServerState == ServerState.Running ? "" : "disabled")" aria-selected="true" role="tab" @onclick="Restart"> <button class="nav-link btn btn-md btn-primary fw-bold px-4 me-1 @(Console.ServerState == ServerState.Running ? "" : "disabled")" aria-selected="true" role="tab" @onclick="Restart">
<TL>Restart</TL> <TL>Restart</TL>
</button> </button>
@if (Console.ServerState == ServerState.Stopping) @if (Console.ServerState == ServerState.Stopping)
{ {
<button class="w-100 nav-link btn btn-sm btn-danger fw-bold px-4 me-1" aria-selected="true" role="tab" @onclick="Kill"> <button class="nav-link btn btn-md btn-danger fw-bold px-4 me-1" aria-selected="true" role="tab" @onclick="Kill">
<TL>Kill</TL> <TL>Kill</TL>
</button> </button>
} }
else else
{ {
<button class="w-100 nav-link btn btn-sm btn-danger fw-bold px-4 me-1 @(Console.ServerState == ServerState.Running || Console.ServerState == ServerState.Starting ? "" : "disabled")" <button class="nav-link btn btn-md btn-danger fw-bold px-4 me-1 @(Console.ServerState == ServerState.Running || Console.ServerState == ServerState.Starting ? "" : "disabled")"
aria-selected="true" role="tab" @onclick="Stop"> aria-selected="true" role="tab" @onclick="Stop">
<TL>Stop</TL> <TL>Stop</TL>
</button> </button>
@@ -53,11 +55,11 @@
<div class="row mt-3"> <div class="row mt-3">
<div class="card card-body"> <div class="card card-body">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col fs-5"> <div class="col fs-5 text-nowrap py-2">
<span class="fw-bold"><TL>Shared IP</TL>:</span> <span class="fw-bold"><TL>Shared IP</TL>:</span>
<span class="ms-1 text-muted @(IdentityService.User.StreamerMode ? "blur" : "")">@($"{CurrentServer.Node.Fqdn}:{CurrentServer.MainAllocation?.Port ?? 0}")</span> <span class="ms-1 text-muted @(IdentityService.User.StreamerMode ? "blur" : "")">@($"{CurrentServer.Node.Fqdn}:{CurrentServer.MainAllocation?.Port ?? 0}")</span>
</div> </div>
<div class="col fs-5"> <div class="col fs-5 py-2">
<span class="fw-bold"><TL>Server ID</TL>:</span> <span class="fw-bold"><TL>Server ID</TL>:</span>
<span class="ms-1 text-muted"> <span class="ms-1 text-muted">
@if (IdentityService.User.Admin) @if (IdentityService.User.Admin)
@@ -70,7 +72,7 @@
} }
</span> </span>
</div> </div>
<div class="col fs-5"> <div class="col fs-5 py-2">
<span class="fw-bold"><TL>Status</TL>:</span> <span class="fw-bold"><TL>Status</TL>:</span>
<span class="ms-1 text-muted"> <span class="ms-1 text-muted">
@switch (Console.ServerState) @switch (Console.ServerState)
@@ -104,15 +106,15 @@
} }
</span> </span>
</div> </div>
<div class="col fs-5"> <div class="col fs-5 py-2">
<span class="fw-bold"><TL>Cpu</TL>:</span> <span class="fw-bold"><TL>Cpu</TL>:</span>
<span class="ms-1 text-muted">@(Math.Round(Console.Resource.CpuAbsolute / (CurrentServer.Cpu / 100f), 2))%</span> <span class="ms-1 text-muted">@(Math.Round(Console.Resource.CpuAbsolute / (CurrentServer.Cpu / 100f), 2))%</span>
</div> </div>
<div class="col fs-5"> <div class="col fs-5 py-2">
<span class="fw-bold"><TL>Memory</TL>:</span> <span class="fw-bold"><TL>Memory</TL>:</span>
<span class="ms-1 text-muted">@(Formatter.FormatSize(Console.Resource.MemoryBytes)) / @(Formatter.FormatSize(Console.Resource.MemoryLimitBytes))</span> <span class="ms-1 text-muted">@(Formatter.FormatSize(Console.Resource.MemoryBytes)) / @Math.Round(CurrentServer.Memory / 1024f, 2) GB</span>
</div> </div>
<div class="col fs-5"> <div class="col fs-5 py-2">
<span class="fw-bold"><TL>Disk</TL>:</span> <span class="fw-bold"><TL>Disk</TL>:</span>
<span class="ms-1 text-muted">@(Formatter.FormatSize(Console.Resource.DiskBytes)) / @(Math.Round(CurrentServer.Disk / 1024f, 2)) GB</span> <span class="ms-1 text-muted">@(Formatter.FormatSize(Console.Resource.DiskBytes)) / @(Math.Round(CurrentServer.Disk / 1024f, 2)) GB</span>
</div> </div>
@@ -122,66 +124,19 @@
<div class="mt-5 row"> <div class="mt-5 row">
<div class="d-flex flex-column flex-md-row card card-body p-5"> <div class="d-flex flex-column flex-md-row card card-body p-5">
<ul class="nav nav-tabs nav-pills flex-row border-0 flex-md-column fs-6 pe-5 mb-5"> <ul class="nav nav-tabs nav-pills flex-row border-0 flex-md-column fs-6 pe-5 mb-5">
<li class="nav-item w-100 me-0 mb-md-2"> @foreach (var tab in Context.Tabs)
<a href="/server/@(CurrentServer.Uuid)/" class="nav-link w-100 btn btn-flex @(Index == 0 ? "active" : "") btn-active-light-primary"> {
<i class="bx bx-terminal bx-sm me-2"></i> <li class="nav-item w-100 me-0 mb-md-2">
<span class="d-flex flex-column align-items-start"> <a href="/server/@(CurrentServer.Uuid + tab.Route)" class="nav-link w-100 btn btn-flex @(Route == tab.Route ? "active" : "") btn-active-light-primary">
<span class="fs-5"> <i class="bx bx-@(tab.Icon) bx-sm me-2"></i>
<TL>Console</TL> <span class="d-flex flex-column align-items-start">
<span class="fs-5">
<TL>@(tab.Name)</TL>
</span>
</span> </span>
</span> </a>
</a> </li>
</li> }
<li class="nav-item w-100 me-0 mb-md-2">
<a href="/server/@(CurrentServer.Uuid)/files" class="nav-link w-100 btn btn-flex @(Index == 1 ? "active" : "") btn-active-light-primary">
<i class="bx bx-folder bx-sm me-2"></i>
<span class="d-flex flex-column align-items-start">
<span class="fs-5">
<TL>Files</TL>
</span>
</span>
</a>
</li>
<li class="nav-item w-100 me-0 mb-md-2">
<a href="/server/@(CurrentServer.Uuid)/backups" class="nav-link w-100 btn btn-flex @(Index == 2 ? "active" : "") btn-active-light-primary">
<i class="bx bx-box bx-sm me-2"></i>
<span class="d-flex flex-column align-items-start">
<span class="fs-5">
<TL>Backups</TL>
</span>
</span>
</a>
</li>
<li class="nav-item w-100 me-0 mb-md-2">
<a href="/server/@(CurrentServer.Uuid)/network" class="nav-link w-100 btn btn-flex @(Index == 3 ? "active" : "") btn-active-light-primary">
<i class="bx bx-wifi bx-sm me-2"></i>
<span class="d-flex flex-column align-items-start">
<span class="fs-5">
<TL>Network</TL>
</span>
</span>
</a>
</li>
<li class="nav-item w-100 me-0 mb-md-2">
<a href="/server/@(CurrentServer.Uuid)/addons" class="nav-link w-100 btn btn-flex @(Index == 4 ? "active" : "") btn-active-light-primary">
<i class="bx bx-plug bx-sm me-2"></i>
<span class="d-flex flex-column align-items-start">
<span class="fs-5">
<TL>Addons</TL>
</span>
</span>
</a>
</li>
<li class="nav-item w-100 me-0 mb-md-2">
<a href="/server/@(CurrentServer.Uuid)/settings" class="nav-link w-100 btn btn-flex @(Index == 5 ? "active" : "") btn-active-light-primary">
<i class="bx bx-cog bx-sm me-2"></i>
<span class="d-flex flex-column align-items-start">
<span class="fs-5">
<TL>Settings</TL>
</span>
</span>
</a>
</li>
</ul> </ul>
<div class="tab-content w-100"> <div class="tab-content w-100">
<div class="tab-pane fade show active"> <div class="tab-pane fade show active">
@@ -198,16 +153,17 @@
[CascadingParameter] [CascadingParameter]
public Server CurrentServer { get; set; } public Server CurrentServer { get; set; }
[CascadingParameter] [CascadingParameter]
public WingsConsole Console { get; set; } public WingsConsole Console { get; set; }
[CascadingParameter]
public ServerPageContext Context { get; set; }
[Parameter] [Parameter]
public RenderFragment ChildContent { get; set; } public RenderFragment ChildContent { get; set; }
[Parameter] [Parameter]
public int Index { get; set; } = 0; public string Route { get; set; } = "/";
//TODO: NodeIpService which loads and caches raw ips for nodes (maybe) //TODO: NodeIpService which loads and caches raw ips for nodes (maybe)

View File

@@ -1,91 +1,29 @@
@using Moonlight.App.Database.Entities @using Moonlight.App.Plugin.UI.Servers
@using Moonlight.Shared.Views.Server.Settings
@using Microsoft.AspNetCore.Components.Rendering
<LazyLoader Load="Load"> <div class="row">
<div class="row"> @foreach (var setting in Context.Settings)
@foreach (var setting in Settings) {
{ <div class="col-12 col-md-6 p-3">
<div class="col-12 col-md-6 p-3"> <div class="accordion" id="serverSetting@(setting.GetHashCode())">
<div class="accordion" id="serverSetting@(setting.GetHashCode())"> <div class="accordion-item">
<div class="accordion-item"> <h2 class="accordion-header" id="serverSetting-header@(setting.GetHashCode())">
<h2 class="accordion-header" id="serverSetting-header@(setting.GetHashCode())"> <button class="accordion-button fs-4 fw-semibold collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#serverSetting-body@(setting.GetHashCode())" aria-expanded="false" aria-controls="serverSetting-body@(setting.GetHashCode())">
<button class="accordion-button fs-4 fw-semibold collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#serverSetting-body@(setting.GetHashCode())" aria-expanded="false" aria-controls="serverSetting-body@(setting.GetHashCode())"> <TL>@(setting.Name)</TL>
<TL>@(setting.Key)</TL> </button>
</button> </h2>
</h2> <div id="serverSetting-body@(setting.GetHashCode())" class="accordion-collapse collapse" aria-labelledby="serverSetting-header@(setting.GetHashCode())" data-bs-parent="#serverSetting">
<div id="serverSetting-body@(setting.GetHashCode())" class="accordion-collapse collapse" aria-labelledby="serverSetting-header@(setting.GetHashCode())" data-bs-parent="#serverSetting"> <div class="accordion-body">
<div class="accordion-body"> @(setting.Component)
@(GetComponent(setting.Value))
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} </div>
</div> }
</LazyLoader> </div>
@code @code
{ {
[CascadingParameter] [CascadingParameter]
public Server CurrentServer { get; set; } public ServerPageContext Context { get; set; }
[CascadingParameter]
public string[] Tags { get; set; }
private Dictionary<string, Type> Settings = new();
private Task Load(LazyLoader lazyLoader)
{
if (Tags.Contains("paperversion"))
Settings.Add("Paper version", typeof(PaperVersionSetting));
if (Tags.Contains("forgeversion"))
Settings.Add("Forge version", typeof(ForgeVersionSetting));
if (Tags.Contains("fabricversion"))
Settings.Add("Fabric version", typeof(FabricVersionSetting));
if (Tags.Contains("join2start"))
Settings.Add("Join2Start", typeof(Join2StartSetting));
if (Tags.Contains("javascriptversion"))
Settings.Add("Javascript version", typeof(JavascriptVersionSetting));
if (Tags.Contains("javascriptfile"))
Settings.Add("Javascript file", typeof(JavascriptFileSetting));
if (Tags.Contains("pythonversion"))
Settings.Add("Python version", typeof(PythonVersionSetting));
if (Tags.Contains("javaversion"))
Settings.Add("Java version", typeof(JavaRuntimeVersionSetting));
if (Tags.Contains("dotnetversion"))
Settings.Add("Dotnet version", typeof(DotnetVersionSetting));
if (Tags.Contains("pythonfile"))
Settings.Add("Python file", typeof(PythonFileSetting));
if (Tags.Contains("javafile"))
Settings.Add("Jar file", typeof(JavaFileSetting));
if (Tags.Contains("dotnetfile"))
Settings.Add("Dll file", typeof(DotnetFileSetting));
Settings.Add("Rename", typeof(ServerRenameSetting));
Settings.Add("Reset", typeof(ServerResetSetting));
Settings.Add("Delete", typeof(ServerDeleteSetting));
return Task.CompletedTask;
}
private RenderFragment GetComponent(Type type) => builder =>
{
builder.OpenComponent(0, type);
builder.CloseComponent();
};
} }

View File

@@ -1,275 +0,0 @@
@page "/support"
@using Moonlight.App.Services
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Services.SupportChat
@using System.Text.RegularExpressions
@using Moonlight.App.Services.Files
@using Moonlight.App.Services.Sessions
@inject ResourceService ResourceService
@inject SupportChatClientService ClientService
@inject SmartTranslateService SmartTranslateService
@inject IdentityService IdentityService
@implements IDisposable
<LazyLoader Load="Load">
<div class="row">
<div class="card">
<div class="card-body">
<LazyLoader Load="LoadMessages">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages.ToArray())
{
if (message.Sender == null || message.Sender.Id != IdentityService.User.Id)
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
@if (message.Sender != null)
{
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
}
else
{
<span>
<TL>System</TL>
</span>
}
</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@if (message.Sender == null)
{
<TL>@(message.Content)</TL>
}
else
{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br />
}
}
if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@foreach (var line in message.Content.Split("\n"))
{
@(line)<br/>
}
@if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
<span>
<TL>System</TL>
</span>
</a>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
<TL>Welcome to the support chat. Ask your question here and we will help you</TL>
</div>
</div>
</div>
</div>
</LazyLoader>
</div>
<div class="card-footer">
@if (Typing.Any())
{
<span class="mb-5 fs-5 d-flex flex-row">
<div class="wave me-1">
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px"></div>
</div>
@if (Typing.Length > 1)
{
<span>
@(Typing.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
</span>
}
else
{
<span>
@(Typing.First()) <TL>is typing</TL>
</span>
}
</span>
}
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="SmartFileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="Content" @oninput="OnTyping" class="form-control mb-3 form-control-flush" rows="1" placeholder="Type a message">
</textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="Send">
</WButton>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</LazyLoader>
@code
{
private List<SupportChatMessage> Messages = new();
private string[] Typing = Array.Empty<string>();
private string Content = "";
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
private SmartFileSelect SmartFileSelect;
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Starting chat client");
ClientService.OnMessage += OnMessage;
ClientService.OnTypingChanged += OnTypingChanged;
await ClientService.Start();
}
private async Task LoadMessages(LazyLoader arg)
{
Messages = (await ClientService.GetMessages()).ToList();
}
private async Task OnTypingChanged(string[] typing)
{
Typing = typing;
await InvokeAsync(StateHasChanged);
}
private async Task OnMessage(SupportChatMessage message)
{
Messages.Insert(0, message);
//TODO: Sound when message from system or admin
await InvokeAsync(StateHasChanged);
}
private async Task Send()
{
if (SmartFileSelect.SelectedFile != null && string.IsNullOrEmpty(Content))
Content = "File upload";
var message = await ClientService.SendMessage(Content, SmartFileSelect.SelectedFile);
Content = "";
await SmartFileSelect.RemoveSelection();
Messages.Insert(0, message);
await InvokeAsync(StateHasChanged);
}
private async void OnTyping()
{
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
{
LastTypingTimestamp = DateTime.UtcNow;
await ClientService.SendTyping();
}
}
public void Dispose()
{
ClientService?.Dispose();
}
private void OnFileChange(InputFileChangeEventArgs obj)
{
}
}

View File

@@ -0,0 +1,468 @@
@page "/support"
@page "/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Forms
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Events
@using Moonlight.App.Services.Files
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject TicketClientService ClientService
@inject Repository<Server> ServerRepository
@inject Repository<WebSpace> WebSpaceRepository
@inject Repository<Domain> DomainRepository
@inject SmartTranslateService SmartTranslateService
@inject IdentityService IdentityService
@inject NavigationManager NavigationManager
@inject ResourceService ResourceService
@inject EventSystem EventSystem
<div class="d-flex flex-column flex-lg-row">
<div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="card card-flush">
<div class="card-body pt-5">
<div class="scroll-y me-n5 pe-5 h-200px h-lg-auto">
<div class="d-flex flex-stack d-flex justify-content-center mb-5">
<a href="/support" class="btn btn-primary">
<TL>Create new ticket</TL>
</a>
</div>
<div class="separator"></div>
@foreach (var ticket in Tickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != Tickets.Last().Key)
{
<div class="separator"></div>
}
}
</div>
</div>
</div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (ClientService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(ClientService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (ClientService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(ClientService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (ClientService.Ticket.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(ClientService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="me-n3">
<button class="btn btn-sm btn-icon btn-active-light-primary" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
<i class="ki-duotone ki-dots-square fs-2">
<span class="path1"></span><span class="path2"></span><span class="path3"></span><span class="path4"></span>
</i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-200px py-3" data-kt-menu="true">
<div class="menu-item px-3">
<div class="menu-content text-muted pb-2 px-3 fs-7 text-uppercase">
Contacts
</div>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="modal" data-bs-target="#kt_modal_users_search">
Add Contact
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link flex-stack px-3" data-bs-toggle="modal" data-bs-target="#kt_modal_invite_friends">
Invite Contacts
<span class="ms-2" data-bs-toggle="tooltip" aria-label="Specify a contact email to send an invitation" data-bs-original-title="Specify a contact email to send an invitation" data-kt-initialized="1">
<i class="ki-duotone ki-information fs-7">
<span class="path1"></span><span class="path2"></span><span class="path3"></span>
</i>
</span>
</a>
</div>
<div class="menu-item px-3" data-kt-menu-trigger="hover" data-kt-menu-placement="right-start">
<a href="#" class="menu-link px-3">
<span class="menu-title">Groups</span>
<span class="menu-arrow"></span>
</a>
<div class="menu-sub menu-sub-dropdown w-175px py-4">
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Create Group
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Invite Members
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Settings
</a>
</div>
</div>
</div>
<div class="menu-item px-3 my-1">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Settings
</a>
</div>
</div>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
<TL>Create a new ticket</TL>
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (ClientService.Ticket == null)
{
<LazyLoader Load="LoadTicketCreate">
<SmartForm Model="Model" OnValidSubmit="OnValidSubmit">
<div class="mb-3">
<InputText @bind-Value="Model.IssueTopic"
placeholder="@(SmartTranslateService.Translate("Enter a title for your ticket"))"
class="form-control">
</InputText>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueDescription"
placeholder="@(SmartTranslateService.Translate("Describe the issue you are experiencing"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueTries"
placeholder="@(SmartTranslateService.Translate("Describe what you have tried to solve this issue"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<select @bind="Model.Subject" class="form-select">
@foreach (var subject in (TicketSubject[])Enum.GetValues(typeof(TicketSubject)))
{
if (Model.Subject == subject)
{
<option value="@(subject)" selected="">@(subject)</option>
}
else
{
<option value="@(subject)">@(subject)</option>
}
}
</select>
</div>
<div class="mb-3">
@if (Model.Subject == TicketSubject.Domain)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var domain in Domains)
{
if (Model.SubjectId == domain.Id)
{
<option value="@(domain.Id)" selected="">@(domain.Name).@(domain.SharedDomain.Name)</option>
}
else
{
<option value="@(domain.Id)">@(domain.Name).@(domain.SharedDomain.Name)</option>
}
}
</select>
}
else if (Model.Subject == TicketSubject.Server)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var server in Servers)
{
if (Model.SubjectId == server.Id)
{
<option value="@(server.Id)" selected="">@(server.Name)</option>
}
else
{
<option value="@(server.Id)">@(server.Name)</option>
}
}
</select>
}
else if (Model.Subject == TicketSubject.Webspace)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var webSpace in WebSpaces)
{
if (Model.SubjectId == webSpace.Id)
{
<option value="@(webSpace.Id)" selected="">@(webSpace.Domain)</option>
}
else
{
<option value="@(webSpace.Id)">@(webSpace.Domain)</option>
}
}
</select>
}
</div>
<div class="text-end">
<button class="btn btn-primary" type="submit">
<TL>Create ticket</TL>
</button>
</div>
</SmartForm>
</LazyLoader>
}
else
{
<TicketMessageView Messages="Messages"/>
}
</div>
</div>
@if (ClientService.Ticket != null)
{
<div class="card-footer pt-4">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</div>
</div>
}
</div>
</div>
</div>
@code
{
[Parameter]
public int Id { get; set; }
private Dictionary<Ticket, TicketMessage?> Tickets;
private List<TicketMessage> Messages = new();
private CreateTicketDataModel Model = new();
private string MessageText;
private SmartFileSelect FileSelect;
private Server[] Servers;
private WebSpace[] WebSpaces;
private Domain[] Domains;
protected override async Task OnParametersSetAsync()
{
await Unsubscribe();
await ReloadTickets();
await Subscribe();
await InvokeAsync(StateHasChanged);
}
private Task LoadTicketCreate(LazyLoader _)
{
Servers = ServerRepository
.Get()
.Include(x => x.Owner)
.Where(x => x.Owner.Id == IdentityService.User.Id)
.ToArray();
WebSpaces = WebSpaceRepository
.Get()
.Include(x => x.Owner)
.Where(x => x.Owner.Id == IdentityService.User.Id)
.ToArray();
Domains = DomainRepository
.Get()
.Include(x => x.SharedDomain)
.Include(x => x.Owner)
.Where(x => x.Owner.Id == IdentityService.User.Id)
.ToArray();
return Task.CompletedTask;
}
private async Task OnValidSubmit()
{
var ticket = await ClientService.Create(
Model.IssueTopic,
Model.IssueDescription,
Model.IssueTries,
Model.Subject,
Model.SubjectId
);
Model = new();
NavigationManager.NavigateTo("/support/" + ticket.Id);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if(string.IsNullOrEmpty(MessageText))
return;
var msg = await ClientService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async ticket =>
{
if (ticket.CreatedBy != null && ticket.CreatedBy.Id != IdentityService.User.Id)
return;
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (ClientService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{ClientService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && !message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{ClientService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
}
}
private async Task Unsubscribe()
{
await EventSystem.Off("tickets.new", this);
if (ClientService.Ticket != null)
{
await EventSystem.Off($"tickets.{ClientService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{ClientService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
ClientService.Ticket = null;
Tickets = await ClientService.Get();
if (Id != 0)
{
ClientService.Ticket = Tickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (ClientService.Ticket == null)
return;
if (reloadMessages)
{
var msgs = await ClientService.GetMessages();
Messages = msgs.ToList();
}
}
}
}

View File

@@ -4,10 +4,14 @@
@using Moonlight.App.Services @using Moonlight.App.Services
@using Moonlight.Shared.Components.WebsiteControl @using Moonlight.Shared.Components.WebsiteControl
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Moonlight.App.Helpers
@using Moonlight.App.Plugin.UI.Webspaces
@using Moonlight.App.Services.Plugins
@using Moonlight.App.Services.Sessions @using Moonlight.App.Services.Sessions
@inject Repository<WebSpace> WebSpaceRepository @inject Repository<WebSpace> WebSpaceRepository
@inject WebSpaceService WebSpaceService @inject WebSpaceService WebSpaceService
@inject PluginService PluginService
@inject IdentityService IdentityService @inject IdentityService IdentityService
<LazyLoader Load="Load"> <LazyLoader Load="Load">
@@ -32,43 +36,17 @@
if (HostOnline) if (HostOnline)
{ {
<CascadingValue Value="CurrentWebspace"> <CascadingValue Value="CurrentWebspace">
@{ <CascadingValue Value="Context">
var index = 0; <SmartRouter Route="@(Route)">
@foreach (var tab in Context.Tabs)
switch (Route) {
{ <Route Path="@(tab.Route)">
case "files": <WebSpaceNavigation Route="@(tab.Route)" WebSpace="CurrentWebspace"/>
index = 1; @(tab.Component)
break; </Route>
case "sftp": }
index = 2; </SmartRouter>
break; </CascadingValue>
case "databases":
index = 3;
break;
default:
index = 0;
break;
}
<WebSpaceNavigation Index="index" WebSpace="CurrentWebspace" />
@switch (Route)
{
case "files":
<WebSpaceFiles />
break;
case "sftp":
<WebSpaceSftp />
break;
case "databases":
<WebSpaceDatabases />
break;
default:
<WebSpaceDashboard />
break;
}
}
</CascadingValue> </CascadingValue>
} }
else else
@@ -101,6 +79,8 @@
private WebSpace? CurrentWebspace; private WebSpace? CurrentWebspace;
private bool HostOnline = false; private bool HostOnline = false;
private WebspacePageContext Context;
private async Task Load(LazyLoader lazyLoader) private async Task Load(LazyLoader lazyLoader)
{ {
CurrentWebspace = WebSpaceRepository CurrentWebspace = WebSpaceRepository
@@ -112,14 +92,53 @@
if (CurrentWebspace != null) if (CurrentWebspace != null)
{ {
if (CurrentWebspace.Owner.Id != IdentityService.User.Id && !IdentityService.User.Admin) if (CurrentWebspace.Owner.Id != IdentityService.User.Id && !IdentityService.User.Admin)
{
CurrentWebspace = null; CurrentWebspace = null;
} return;
}
if (CurrentWebspace != null)
{
await lazyLoader.SetText("Checking host system online status"); await lazyLoader.SetText("Checking host system online status");
HostOnline = await WebSpaceService.IsHostUp(CurrentWebspace); HostOnline = await WebSpaceService.IsHostUp(CurrentWebspace);
if (!HostOnline)
return;
Context = new WebspacePageContext()
{
WebSpace = CurrentWebspace,
User = IdentityService.User
};
Context.Tabs.Add(new()
{
Route = "/",
Name = "Dashboard",
Component = ComponentHelper.FromType(typeof(WebSpaceDashboard))
});
Context.Tabs.Add(new()
{
Route = "/files",
Name = "Files",
Component = ComponentHelper.FromType(typeof(WebSpaceFiles))
});
Context.Tabs.Add(new()
{
Route = "/sftp",
Name = "SFTP",
Component = ComponentHelper.FromType(typeof(WebSpaceSftp))
});
Context.Tabs.Add(new()
{
Route = "/databases",
Name = "Databases",
Component = ComponentHelper.FromType(typeof(WebSpaceDatabases))
});
Context = await PluginService.BuildWebspacePage(Context);
} }
} }
} }

View File

@@ -85,7 +85,7 @@ Create a website;Eine Website erstellen
Make your own websites with a webspace;Mit einem Webspace eine Website erstellen Make your own websites with a webspace;Mit einem Webspace eine Website erstellen
Create a domain;Eine Domain erstellen Create a domain;Eine Domain erstellen
Make your servvices accessible throught your own domain;Mache deine Dienste mit einer Domain erreichbar Make your servvices accessible throught your own domain;Mache deine Dienste mit einer Domain erreichbar
Manage your websites;Deine Websiten verwalten Manage your websites;Deine Webseiten verwalten
Modify the content of your websites;Den Inhalt deiner Websiten verwalten Modify the content of your websites;Den Inhalt deiner Websiten verwalten
Manage your domains;Deine Domains verwalten Manage your domains;Deine Domains verwalten
Add, edit and delete dns records;DNS-Records hinzufügen, entfernen oder bearbeiten Add, edit and delete dns records;DNS-Records hinzufügen, entfernen oder bearbeiten
@@ -156,7 +156,7 @@ UuidIdentifier;UUIDIdentifier
Override startup command;Startup Befehl überschreiben Override startup command;Startup Befehl überschreiben
Loading;Wird geladen... Loading;Wird geladen...
Offline;Offline Offline;Offline
Connecting;Verbiden... Connecting;Verbinden...
Start;Start Start;Start
Restart;Neustarten Restart;Neustarten
Stop;Stoppen Stop;Stoppen

View File

@@ -313,6 +313,21 @@
'editor.background': '#000000' 'editor.background': '#000000'
} }
}); });
},
checkConnection: async function(url, threshold) {
const start = performance.now();
try
{
const response = await fetch(url, { mode: 'no-cors' });
const latency = performance.now() - start;
if (latency > threshold)
{
moonlight.toasts.warning(`High latency detected: ${latency}ms. Moonlight might feel laggy. Please check your internet connection`);
}
}
catch (error) {}
} }
}, },
flashbang: { flashbang: {