64 Commits

Author SHA1 Message Date
Samuele Lorefice
01a9fd654d Merge branch 'feature/frontend' into develop 2025-09-01 11:19:18 +02:00
Samuele Lorefice
e296b142a3 misc. 2025-08-31 23:46:31 +02:00
Samuele Lorefice
4cab2988e1 fix(API): solved filesystem scanner not scanning 2025-08-31 23:43:03 +02:00
Samuele Lorefice
0a57919271 filesystem scanner troubleshooting 2025-08-31 22:16:17 +02:00
Samuele Lorefice
1fd6ebc570 feat(frontend): added folder removal in settings 2025-08-31 20:19:28 +02:00
Samuele Lorefice
5827f88956 Attempts to speedup user login retrieval from localstorage 2025-08-31 20:08:16 +02:00
Samuele Lorefice
ca290bcba7 feat(frontend): editing of settings and addition/editing of folder paths 2025-08-31 19:35:15 +02:00
Samuele Lorefice
fe544ebf24 fix(API): Added SaveChanges to Settings repository, reflecting changes to he database 2025-08-31 17:18:57 +02:00
Samuele Lorefice
21d99ef14f Added save logic for Settings (folders are still not saved) 2025-08-31 16:33:57 +02:00
Samuele Lorefice
cec2d805ca Moved setting cards to components. Only saving left. 2025-08-31 15:14:09 +02:00
Samuele Lorefice
0b9acbf7b3 Bunch of fucking stuff, mainly docker fixes and settings fixes 2025-08-30 22:43:57 +02:00
Samuele Lorefice
0a814ec6c0 More docker shenanighans 2025-08-30 20:54:14 +02:00
Samuele Lorefice
597533d70c Fixes docker deployments 2025-08-30 19:58:03 +02:00
REDCODE
8ead1d0ee1 WIP 2025-08-30 18:56:39 +02:00
Samuele Lorefice
1db0ea89cf Fixes settings page layout 2025-08-29 20:14:20 +02:00
Samuele Lorefice
c6a61879d9 Versioned bootstrap 2025-08-21 18:16:03 +02:00
Samuele Lorefice
2e882e7c13 Fixed JSON Default settings not being serialized, explicitly defined enum 2025-08-21 17:58:01 +02:00
MrFastwind
6a47f84fcc feat(API): Edits SettingsRepository to manage FileWatcher Service 2025-07-30 20:44:59 +02:00
MrFastwind
b0e84119ac feat(API): Adds method to modify the FileWatcherService: folders, time 2025-07-30 20:39:56 +02:00
MrFastwind
597b20483c Fix(API): Refresh token being changed the last 10 minutes instead of every moment 2025-07-22 17:04:33 +02:00
Samuele Lorefice
e411c0ce27 feat(API): Settings rework
feat(db): extended amount of settings informations stored on the database
feat(frontend): added support for updating the refreshToken after a refresh request.
feat(backend): added a defaultSettings.json file that defines how the settings are initialized.
2025-07-18 20:40:42 +02:00
MrFastwind
8f18978ad9 WIP Generate new Refresh token 2025-07-18 19:32:11 +02:00
REDCODE
d99cc61056 WIP 2025-07-18 02:21:52 +02:00
Samuele Lorefice
0ae0f4ec35 Initial Settings page implementation 2025-07-17 21:18:45 +02:00
Samuele Lorefice
5a05822a9f feat(frontend): nav menu restyle 2025-07-17 20:49:09 +02:00
Samuele Lorefice
445ae26360 feat(frontend): added folders service 2025-07-17 20:48:39 +02:00
Samuele Lorefice
adad2dcfd9 fix(frontend): adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object. 2025-07-17 19:35:32 +02:00
Samuele Lorefice
7c58a270b3 feat(db): limited strings length in Settings Table
feat(API): removed SettingsDto in favor of List<SettingDto> instead.
2025-07-17 19:34:52 +02:00
Samuele Lorefice
83ecd83f83 Syncronized logged user account into LoginService, added state sync across navbar and home (and laid groundwork for statesync across all of the application) 2025-07-17 15:27:15 +02:00
Samuele Lorefice
c07332657b feat(frontend): Login and logout now properly work 2025-07-17 15:08:50 +02:00
Samuele Lorefice
860ed07429 WIP 2025-07-17 12:53:04 +02:00
Samuele Lorefice
1d7bb08b79 JWT Token refresher improved performance in logging strings 2025-07-17 12:52:41 +02:00
Samuele Lorefice
bcd85b8f61 Made all services derive from AuthServiceBase/ServiceBase 2025-07-17 12:52:12 +02:00
Samuele Lorefice
d52f4da3bb feat(frontend): adds ServiceBase and AuthServiceBase abstract classes 2025-07-17 10:42:20 +02:00
Samuele Lorefice
82c6394c82 Added Event for Auth info change in loginService 2025-07-17 10:21:33 +02:00
Samuele Lorefice
330071a864 Added dropdown to user icon 2025-07-17 10:16:59 +02:00
Samuele Lorefice
040e590802 Join searchbox and button 2025-07-17 10:16:11 +02:00
Samuele Lorefice
c062b4073c Generic cleanup 2025-07-16 22:10:24 +02:00
MrFastwind
f1f3abb05a feat(API): Added Access level to the data returned from authentication actions
Adds featrure from forntend branch
2025-07-16 21:40:10 +02:00
Samuele Lorefice
3ade5c8c64 More logging 2025-07-16 19:44:27 +02:00
Samuele Lorefice
7b472f337f JWT Token refresher rewrite 2025-07-16 19:43:52 +02:00
Samuele Lorefice
3630291ad9 Lazy loading for NavMenu 2025-07-16 19:43:06 +02:00
Samuele Lorefice
8265c7c051 increased logging for hotreload configurations 2025-07-16 19:42:51 +02:00
Samuele Lorefice
f4773ec096 removed get-all method from settings controller 2025-07-16 19:42:40 +02:00
Samuele Lorefice
c00f9f505e Merge branch 'develop' into feature/frontend 2025-07-16 18:04:50 +02:00
Samuele Lorefice
5780aaec73 feat: added disposability to some repositories, changed the FileSystemScanner service to be a singleton. 2025-07-16 18:03:15 +02:00
Samuele Lorefice
781c594f37 Start implementing settings page 2025-07-16 17:10:32 +02:00
Samuele Lorefice
c31d698bdf Minor styling and usability improvements 2025-07-16 16:17:54 +02:00
Samuele Lorefice
c3c2a5810f Login service rework 2025-07-16 16:17:25 +02:00
Samuele Lorefice
6c1bd7d45f styling 2025-07-16 15:18:08 +02:00
Samuele Lorefice
e95619c969 fix(frontend): added correct headers and response format to LoginService 2025-07-16 15:15:57 +02:00
Samuele Lorefice
c52ddb9820 Added Iconfont 2025-07-16 15:06:58 +02:00
REDCODE
6994dbab7a feat(frontend): added login and JWT token auto-refresh 2025-07-16 02:48:46 +02:00
REDCODE
21299543ec feat(API): Added Access level to the data returned from authentication actions 2025-07-16 01:47:14 +02:00
REDCODE
b80f6eb0a3 Re-enabled page pre-render, fixed login form (do not use form) 2025-07-16 00:32:24 +02:00
Samuele Lorefice
2033877fe9 Axed 3rd party storage solutions for built in storage 2025-07-15 20:23:02 +02:00
Samuele Lorefice
2018fed2c9 More layout work 2025-07-15 16:52:08 +02:00
REDCODE
fc086e63de Register page done aswell, removed useless css override files 2025-07-15 04:34:46 +02:00
REDCODE
278eaed931 Login form refactored 2025-07-15 04:20:14 +02:00
REDCODE
020d8618aa Added navbar logic for login and register, extra cleanup 2025-07-15 03:50:49 +02:00
REDCODE
89c0933f45 More styling passes 2025-07-15 03:33:40 +02:00
REDCODE
b0318c4d8b main layout setup 2025-07-15 03:12:51 +02:00
REDCODE
66fcba5d26 fix(frontend): added missing js file include for proper bootstrap functionality
feat(frontend): redone navbar
2025-07-15 03:12:41 +02:00
REDCODE
494531be62 WIP full frontend graphics rewrite 2025-07-15 00:29:05 +02:00
302 changed files with 83864 additions and 1171 deletions

View File

@@ -27,5 +27,17 @@
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="TestDb@pm" uuid="a0f3e480-c856-41e0-8fb4-5cac87b9faf6">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/80498c22-67da-4c4a-8106-c4789470ef83/console.sql" value="80498c22-67da-4c4a-8106-c4789470ef83" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="PROJECT" libraries="{bootstrap-icons}" />
</component>
</project>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="false">
<TaskOptions isEnabled="true">
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<configuration default="false" name="Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
<deployment type="docker-compose.yml">
<settings>
<option name="containerName" value="" />
@@ -18,16 +18,9 @@
</list>
</option>
<option name="removeOrphansOnComposeDown" value="false" />
<option name="services">
<list>
<option value="lactose" />
<option value="database" />
<option value="milkstream" />
</list>
</option>
<option name="commandLineOptions" value="--build" />
<option name="sourceFilePath" value="docker-compose.yml" />
<option name="upRemoveOrphans" value="true" />
<option name="upRenewAnonVolumes" value="true" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />

View File

@@ -4,6 +4,7 @@
<settings>
<option name="imageTag" value="lactose" />
<option name="containerName" value="lactose" />
<option name="contextFolderPath" value="." />
<option name="envVars">
<list>
<DockerEnvVarImpl>
@@ -25,6 +26,7 @@
</DockerPortBindingImpl>
</list>
</option>
<option name="showCommandPreview" value="true" />
<option name="sourceFilePath" value="Lactose/Dockerfile" />
</settings>
</deployment>

View File

@@ -1,9 +1,16 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MilkStream HotReload" type="RunDotNetWatch" factoryName="RunDotNetWatch">
<option name="envs">
<map>
<entry key="ASPNETCORE_ENVIRONMENT" value="Development" />
<entry key="DOTNET_ENVIRONMENT" value="Hotreload" />
</map>
</option>
<option name="exePath" value="$PROJECT_DIR$/MilkStream/bin/Debug/net8.0/MilkStream.exe" />
<option name="programParameters" value="" />
<option name="projectFilePath" value="$PROJECT_DIR$/MilkStream/MilkStream.csproj" />
<option name="projectTfm" value="net8.0" />
<option name="suppressBrowserLaunch" value="true" />
<option name="watchParameters" value="" />
<option name="workingDirectory" value="$PROJECT_DIR$/MilkStream" />
<method v="2" />

13
.run/MilkStream.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MilkStream" type="docker-deploy" factoryName="dockerfile" server-name="Docker-PM">
<deployment type="dockerfile">
<settings>
<option name="containerName" value="milkstream" />
<option name="contextFolderPath" value="." />
<option name="sourceFilePath" value="MilkStream/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,6 +1,6 @@
namespace Butter.Dtos;
public class RefreshDto {
public required Guid UserId { get; set; }
public required string RefreshToken { get; set; }
public required Guid UserId { get; init; }
public required string RefreshToken { get; init; }
}

View File

@@ -1,6 +1,11 @@
using Butter.Settings;
namespace Butter.Dtos.Settings;
public class SettingDto {
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public required string Name { get; set; } = string.Empty;
public string? Value { get; set; } = string.Empty;
public required string? Description { get; set; }
public required EType Type { get; set; }
public required DisplayType DisplayType { get; set; }
}

View File

@@ -1,5 +0,0 @@
namespace Butter.Dtos.Settings;
public class SettingsDto {
public required IEnumerable<SettingDto> Settings;
}

View File

@@ -1,13 +1,16 @@
using Butter.Types;
namespace Butter.Dtos.User;
public class UserInfoDto {
public required Guid Id { get; set; }
public required string Username { get; set; }
public string? Email { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLogin { get; set; }
public DateTime? BannedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsBanned { get; set; }
public required Guid Id { get; set; }
public required string Username { get; set; }
public string? Email { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLogin { get; set; }
public DateTime? BannedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsBanned { get; set; }
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
}

104
Butter/MimeTypes.cs Normal file
View File

@@ -0,0 +1,104 @@
namespace Butter;
public struct MimeTypeMap(string mimeType, string[] extensions) {
public string MimeType { get; set; } = mimeType;
public string[] Extensions { get; set; } = extensions;
}
public static class MimeTypes{
// ReSharper disable ArrangeObjectCreationWhenTypeEvident
public static readonly MimeTypeMap[] Image = [
new("image/avif", [".avif", ".avifs"]),
new("image/bmp}", [".bmp"]),
new("image/cgm", [".cgm"]),
new("image/g3fax", [".g3"]),
new("image/gif", [".gif"]),
new("image/heic", [".heif", ".heic"]),
new("image/ief", [".ief"]),
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/pjpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfi", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/png", [".png"]),
new("image/prs.btif", [".btif"]),
new("image/svg+xml", [".svg", ".svgz"]),
new("image/tiff", [".tif", ".tiff"]),
new("image/vnd.adobe.photoshop", [".psd"]),
new("image/vnd.djvu", [".djv", ".djvu"]),
new("image/vnd.dwg", [".dwg"]),
new("image/vnd.dxf", [".dxf"]),
new("image/vnd.fastbidsheet", [".fbs"]),
new("image/vnd.fpx", [".fpx"]),
new("image/vnd.fst", [".fst"]),
new("image/vnd.fujixerox.edmics-mmr", [".mmr"]),
new("image/vnd.fujixerox.edmics-rlc", [".rlc"]),
new("image/vnd.ms-modi", [".mdi"]),
new("image/vnd.net-fpx", [".npx"]),
new("image/vnd.wap.wbmp", [".wbmp"]),
new("image/vnd.xiff", [".xif"]),
new("image/webp", [".webp"]),
new("image/x-adobe-dng", [".dng"]),
new("image/x-canon-cr2", [".cr2"]),
new("image/x-canon-crw", [".crw"]),
new("image/x-cmu-raster", [".ras"]),
new("image/x-cmx", [".cmx"]),
new("image/x-epson-erf", [".erf"]),
new("image/x-freehand", [".fh", ".fh4", ".fh5", ".fh7", ".fhc"]),
new("image/x-fuji-raf", [".raf"]),
new("image/x-icns", [".icns"]),
new("image/x-icon", [".ico"]),
new("image/x-kodak-dcr", [".dcr"]),
new("image/x-kodak-k25", [".k25"]),
new("image/x-kodak-kdc", [".kdc"]),
new("image/x-minolta-mrw", [".mrw"]),
new("image/x-nikon-nef", [".nef"]),
new("image/x-olympus-orf", [".orf"]),
new("image/x-panasonic-raw", [".raw", ".rw2", ".rwl"]),
new("image/x-pcx", [".pcx"]),
new("image/x-pentax-pef", [".pef", ".ptx"]),
new("image/x-pict", [".pct", ".pic"]),
new("image/x-portable-anymap", [".pnm"]),
new("image/x-portable-bitmap", [".pbm"]),
new("image/x-portable-graymap", [".pgm"]),
new("image/x-portable-pixmap", [".ppm"]),
new("image/x-rgb", [".rgb"]),
new("image/x-sigma-x3f", [".x3f"]),
new("image/x-sony-arw", [".arw"]),
new("image/x-sony-sr2", [".sr2"]),
new("image/x-sony-srf", [".srf"]),
new("image/x-xbitmap", [".xbm"]),
new("image/x-xpixmap", [".xpm"]),
new("image/x-xwindowdump", [".xwd"])
];
public static readonly MimeTypeMap[] Video = [
new("video/3gpp", [".3gp"]),
new("video/3gpp2", [".3g2"]),
new("video/h261", [".h261"]),
new("video/h263", [".h263"]),
new("video/h264", [".h264"]),
new("video/jpeg", [".jpgv"]),
new("video/jpm", [".jpgm, .jpm"]),
new("video/mj2", [".mj2, .mjp2"]),
new("video/mp2t", [".ts"]),
new("video/mp4", [".mp4, .mp4v, .mpg4"]),
new("video/mpeg", [".m1v, .m2v, .mpa, .mpe, .mpeg, .mpg"]),
new("video/ogg", [".ogv"]),
new("video/quicktime", [".mov", ".qt"]),
new("video/vnd.fvt", [".fvt"]),
new("video/vnd.mpegurl", [".m4u", ".mxu"]),
new("video/vnd.ms-playready.media.pyv", [".pyv"]),
new("video/vnd.vivo", [".viv"]),
new("video/webm", [".webm"]),
new("video/x-f4v", [".f4v"]),
new("video/x-fli", [".fli"]),
new("video/x-flv", [".flv"]),
new("video/x-m4v", [".m4v"]),
new("video/x-matroska", [".mkv"]),
new("video/x-ms-asf", [".asf", ".asx"]),
new("video/x-ms-wm", [".wm"]),
new("video/x-ms-wmv", [".wmv"]),
new("video/x-ms-wmx", [".wmx"]),
new("video/x-ms-wvx", [".wvx"]),
new("video/x-msvideo", [".avi"]),
new("video/x-sgi-movie", [".movie"])
];
}

View File

@@ -0,0 +1,21 @@
namespace Butter.Settings;
public enum DisplayType {
Text = 0,
Password = 1,
IntNumber = 2,
FloatNumber = 3,
Checkbox = 4,
Switch = 5,
DateTimePicker = 6,
TimePicker = 7
}
public static class DisplayTypeExtension {
public static DisplayType? ToDisplayType(this string type) {
var success = Enum.TryParse(type, out DisplayType result);
if (success) return result;
return null;
}
}

18
Butter/Settings/EType.cs Normal file
View File

@@ -0,0 +1,18 @@
namespace Butter.Settings;
public enum EType {
String = 0,
Integer = 1,
Boolean = 2,
Float = 3,
DateTime = 4
};
public static class ETypeExtension {
public static EType? ToType(this string type) {
var success = Enum.TryParse(type, out EType result);
if (success) return result;
return null;
}
}

View File

@@ -0,0 +1,22 @@
namespace Butter.Settings;
public enum Settings {
UserRegistrationEnabled, //Enable or disable self user registration
FolderScanEnabled, // Used to enable/disable folder scanning
FolderScanInterval, //Interval in minutes for folder scanning
FileUploadEnabled, //Enable or disable file upload
FileUploadMaxSize, //Maximum file size for uploads in bytes
}
public static class SettingsExtensions {
public static string AsString(this Settings setting) {
return setting switch {
Settings.UserRegistrationEnabled => "User Registration Enabled",
Settings.FolderScanEnabled => "Folder Scan Enabled",
Settings.FolderScanInterval => "Folder Scan Interval",
Settings.FileUploadEnabled => "File Upload Enabled",
Settings.FileUploadMaxSize => "File Upload MaxSize",
_ => setting.ToString() // Fallback to the enum name
};
}
}

View File

@@ -185,8 +185,10 @@ public class AuthController(
Token = authService.GenerateAccessToken(user)
};
// TODO: Decide if we want to change or extend refresh token
if (user.RefreshTokenExpires > DateTime.Now - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) {
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.Now) {
var refreshToken = authService.GenerateRefreshToken(user);
authDto.RefreshToken = refreshToken;
user.RefreshToken = refreshToken;
user.RefreshTokenExpires = DateTime.Now.AddMinutes(LactoseAuthService.LongExpirationTime);
userRepository.Save();
}

View File

@@ -22,12 +22,19 @@ public class SettingsController(
[Authorize(Roles = "Admin")]
public ActionResult Create([FromBody] SettingDto settingDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
settingsRepository.Create(new Setting {
settingsRepository.Create(
new Setting {
Name = settingDto.Name,
Value = settingDto.Value
});
Value = settingDto.Value,
Description = settingDto.Description,
Type = settingDto.Type,
DisplayType = settingDto.DisplayType
}
);
return Ok();
}
@@ -36,18 +43,8 @@ public class SettingsController(
public ActionResult<SettingDto> Get() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
return Ok(settingsRepository.Get().ToSettingsDto());
}
[HttpGet]
[Authorize(Roles = "Admin")]
[Route("get-all")]
public ActionResult<SettingsDto> GetAll() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
return Ok(settingsRepository.Get().ToSettingsDto());
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
}
[HttpPost]
@@ -55,7 +52,7 @@ public class SettingsController(
public ActionResult Update([FromBody] SettingDto settingDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Update(settingDto.ToSetting());
return Ok();
}
@@ -69,7 +66,7 @@ public class SettingsController(
settingsRepository.Delete(settingDto.ToSetting());
return Ok();
}
[HttpPost]
[Authorize(Roles = "Admin")]
[Route("delete-all")]
@@ -80,4 +77,4 @@ public class SettingsController(
settingsRepository.Delete();
return Ok();
}
}
}

View File

@@ -60,7 +60,7 @@ namespace Lactose.Controllers {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
logger.LogWarning(@"Anonymous User hasn't enough permissions to read User data. it shouldn't be here");
return Unauthorized("Unauthorized");
}

View File

@@ -0,0 +1,37 @@
[
{
"Name": "User Registration Enabled",
"Value" : "false",
"Description": "Sets if user registration is enabled or not.",
"Type" : 2,
"DisplayType" : 5
},
{
"Name": "Folder Scan Enabled",
"Value" : "true",
"Description": "Sets if the folder scan service should be running or not.",
"Type" : 2,
"DisplayType" : 5
},
{
"Name": "Folder Scan Interval",
"Value" : "",
"Description": "Time interval after the previous scan has finished before a new folder scan is started.",
"Type" : 4,
"DisplayType" : 7
},
{
"Name": "File Upload Enabled",
"Value" : "false",
"Description": "NOT IMPLEMENTED YET: Sets if the file upload service should be running or not.",
"Type" : 2,
"DisplayType" : 5
},
{
"Name": "File Upload MaxSize",
"Value" : "0",
"Description": "Max file size for uploads in bytes.",
"Type" : 1,
"DisplayType" :2
}
]

View File

@@ -7,13 +7,11 @@ EXPOSE 5162
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR "/src"
COPY ["./Lactose.csproj", "Lactose/"]
COPY ["Lactose/Lactose.csproj", "Lactose/"]
RUN dotnet restore "Lactose/Lactose.csproj"
WORKDIR "/src/Lactose"
COPY . .
WORKDIR "/src/Lactose"
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish

View File

@@ -39,4 +39,10 @@
<ProjectReference Include="..\Butter\Butter.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="DefaultSettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -14,7 +14,10 @@ public static class SettingsMapper {
/// <returns>A SettingsDto object.</returns>
public static SettingDto ToSettingDto(this Setting setting) => new SettingDto {
Name = setting.Name,
Value = setting.Value
Value = setting.Value,
Description = setting.Description,
Type = setting.Type,
DisplayType = setting.DisplayType
};
/// <summary>
@@ -23,10 +26,9 @@ public static class SettingsMapper {
/// <returns>A Settings object.</returns>
public static Setting ToSetting(this SettingDto settingDto) => new Setting {
Name = settingDto.Name,
Value = settingDto.Value
};
public static SettingsDto ToSettingsDto(this IEnumerable<Setting> settings) => new SettingsDto {
Settings = settings.Select(s => s.ToSettingDto())
Value = settingDto.Value,
Description = settingDto.Description,
Type = settingDto.Type,
DisplayType = settingDto.DisplayType
};
}

View File

@@ -19,7 +19,8 @@ public static class UsersMapper {
Username = user.Username,
CreatedAt = user.CreatedAt,
IsBanned = user.BannedAt != null,
DeletedAt = user.DeletedAt
DeletedAt = user.DeletedAt,
AccessLevel = user.AccessLevel,
};
}
}

View File

@@ -0,0 +1,457 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20250717171300_limitedStringLenght")]
partial class limitedStringLenght
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("BYTEA");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class limitedStringLenght : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
table: "Settings",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Settings",
type: "character varying(50)",
maxLength: 50,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
table: "Settings",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(1024)",
oldMaxLength: 1024);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Settings",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50);
}
}
}

View File

@@ -0,0 +1,466 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20250718183814_ExpandedSettings")]
partial class ExpandedSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("BYTEA");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class ExpandedSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
table: "Settings",
type: "character varying(1024)",
maxLength: 1024,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(1024)",
oldMaxLength: 1024);
migrationBuilder.AddColumn<string>(
name: "Description",
table: "Settings",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DisplayType",
table: "Settings",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "Type",
table: "Settings",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "Settings");
migrationBuilder.DropColumn(
name: "DisplayType",
table: "Settings");
migrationBuilder.DropColumn(
name: "Type",
table: "Settings");
migrationBuilder.AlterColumn<string>(
name: "Value",
table: "Settings",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(1024)",
oldMaxLength: 1024,
oldNullable: true);
}
}
}

View File

@@ -246,11 +246,22 @@ namespace Lactose.Migrations
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasColumnType("text");
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");

View File

@@ -1,10 +1,19 @@
using Butter.Settings;
using System.ComponentModel.DataAnnotations;
namespace Lactose.Models;
public class Setting {
[Key]
[Key][MaxLength(50)]
public required string Name { get; set; }
public string Value { get; set; } = string.Empty;
[MaxLength(1024)]
public string? Value { get; set; }
[MaxLength(2048)]
public string? Description { get; set; }
public required EType Type { get; set; }
public required DisplayType DisplayType { get; set; }
}

View File

@@ -63,7 +63,6 @@ AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
// Add services to initialize an empty the database
builder.Services.AddScoped<IDbInitializer, DbInitializer>();
// Add services to the container.
builder.Services.AddTransient<IPasswordHasher<User>, PasswordHasher<User>>();
builder.Services.AddTransient<ISettingsRepository, SettingsRepository>();
@@ -75,7 +74,7 @@ builder.Services.AddTransient<IAlbumRepository, AlbumRepository>();
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>();
builder.Services.AddTransient<FileSystemScannerService>();
builder.Services.AddSingleton<FileSystemScannerService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
@@ -152,7 +151,7 @@ builder.Services
});
//Add the background scan service
//builder.Services.AddHostedService<FileSystemScannerService>();
builder.Services.AddHostedService<FileSystemScannerService>();
WebApplication app = builder.Build();

View File

@@ -1,28 +1,58 @@
using Lactose.Context;
using Lactose.Models;
using Lactose.Services;
namespace Lactose.Repositories;
public class FolderRepository(LactoseDbContext context) : IFolderRepository {
public void Create(Folder folder) => context.Folders.Add(folder);
public class FolderRepository(LactoseDbContext context, FileSystemScannerService scanner) : IFolderRepository, IAsyncDisposable {
public void Create(Folder folder) {
context.Folders.Add(folder);
context.SaveChanges();
if (folder.Active) scanner.AddFolder(folder);
}
public void Update(Guid id, Folder folder) {
var origFolder = context.Folders.Find(id);
if (origFolder != null) {
origFolder.BasePath = folder.BasePath;
origFolder.Active = folder.Active;
context.Folders.Update(origFolder);
context.SaveChanges();
if (origFolder == null) { return; }
var oldPath = origFolder;
var hasChanged = origFolder.BasePath != folder.BasePath;
var wasActive = origFolder.Active;
origFolder.BasePath = folder.BasePath;
origFolder.Active = folder.Active;
context.Folders.Update(origFolder);
context.SaveChanges();
if (hasChanged && wasActive) {
scanner.RemoveFolder(oldPath);
}
if (hasChanged && folder.Active) {}
if (folder.Active) scanner.AddFolder(origFolder);
else scanner.RemoveFolder(origFolder);
}
public void Delete(Guid id) {
var folder = context.Folders.Find(id);
if(folder != null) context.Folders.Remove(folder);
if (folder != null) {
context.Folders.Remove(folder);
scanner.RemoveFolder(folder);
}
context.SaveChanges();
}
public IEnumerable<Folder> GetAllUntracked() => context.Folders.AsNoTracking();
public Folder? Get(Guid id) => context.Folders.Find(id);
public IEnumerable<Folder> GetAll() => context.Folders;
public void Dispose() => context.Dispose();
public async ValueTask DisposeAsync() => await context.DisposeAsync();
}

View File

@@ -5,7 +5,7 @@ namespace Lactose.Repositories;
/// <summary>
/// Interface for folder repository operations.
/// </summary>
public interface IFolderRepository {
public interface IFolderRepository : IDisposable {
/// <summary>
/// Creates a new folder.
/// </summary>
@@ -36,4 +36,6 @@ public interface IFolderRepository {
/// Retrieves all folders.
/// </summary>
IEnumerable<Folder> GetAll();
IEnumerable<Folder> GetAllUntracked();
}

View File

@@ -5,7 +5,7 @@ namespace Lactose.Repositories;
/// <summary>
/// Interface for managing settings in the repository.
/// </summary>
public interface ISettingsRepository {
public interface ISettingsRepository : IDisposable {
/// <summary>
/// Creates a settings entry in the repository. If a settings entry already exists, it will be overwritten.
/// </summary>

View File

@@ -1,20 +1,24 @@
using Butter.Settings;
using Lactose.Context;
using Lactose.Models;
using Lactose.Services;
using System.Data;
namespace Lactose.Repositories;
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
public class SettingsRepository(LactoseDbContext context, IServiceProvider serviceProvider) : ISettingsRepository {
public void Create(Setting setting) {
//Check if the settings already exist
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
if (existingSettings != null)
throw new DuplicateNameException("A setting with the same key already exists: " + setting.Name);
SettingChange(setting);
//Adds the new settings
context.Settings.Add(setting);
context.SaveChanges();
}
public IEnumerable<Setting> Get() => context.Settings.AsEnumerable();
public Setting? Get(string name) => context.Settings.FirstOrDefault(s => s.Name == name);
@@ -22,20 +26,26 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
public void Update(Setting setting) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
if (existingSettings != null) {
if (existingSettings.Value != setting.Value) SettingChange(setting);
existingSettings.Value = setting.Value;
context.Settings.Update(existingSettings);
context.SaveChanges();
} else {
// If the settings entry does not exist
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
}
}
public void Delete() => context.Settings.RemoveRange(context.Settings);
public void Delete() {
context.Settings.RemoveRange(context.Settings);
context.SaveChanges();
}
public void Delete(Setting setting) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
if (existingSettings != null) {
context.Settings.Remove(existingSettings);
context.SaveChanges();
} else {
// If the settings entry does not exist
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
@@ -45,5 +55,25 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == name);
if (existingSettings == null) throw new KeyNotFoundException("The setting with the specified name does not exist: " + name);
Delete(existingSettings);
context.SaveChanges();
}
//TODO: May need to be change on a Event based system, because adding here all the Setting Actions could be too much ramification
void SettingChange(Setting setting) {
switch (setting.Name) {
case var name when name == Settings.FolderScanEnabled.AsString():
using (var scannerService = serviceProvider.GetService<FileSystemScannerService>()) {
if (setting.Value == "false") scannerService?.Disable();
else scannerService?.Enable();
}
return;
case var name when name == Settings.FolderScanInterval.AsString():
using (var scannerService = serviceProvider.GetService<FileSystemScannerService>()) {
if (int.TryParse(setting.Value, out var interval)) scannerService?.SetTimerInterval(TimeSpan.FromMinutes(interval));
}
return;
}
}
public void Dispose() => context.Dispose();
}

View File

@@ -18,7 +18,7 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
public Tag? Find(Guid id) => context.Tags.Find(id);
public Tag? FindByName(string name) =>context.Tags.FirstOrDefault(t => t.Name == name);
public Tag? FindByName(string name) => context.Tags.FirstOrDefault(t => t.Name == name);
public List<Tag> GetChildren(Guid id) => context.Tags.Where(t=>t.ParentId==id).ToList();

View File

@@ -1,7 +1,11 @@
using Butter;
using Butter.Settings;
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Microsoft.AspNetCore.Identity;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Lactose.Services;
@@ -9,32 +13,76 @@ interface IDbInitializer {
void Initialize();
}
class DbInitializer(
LactoseDbContext dbContext,
IPasswordHasher<User> passwordHasher
): IDbInitializer
{
class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
public void Initialize() {
ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext));
var task = dbContext.Database.GetPendingMigrationsAsync();
task.ContinueWith(
(r) => {
if (r.Result.Any()) dbContext.Database.Migrate();
}
).Wait();
task.ContinueWith((r) => {
if (r.Result.Any()) dbContext.Database.Migrate();
}
)
.Wait();
// if(dbContext.Database.GetPendingMigrations().Any()) // Timeoout
if (dbContext.Users.Any()) return;
var user = new User {
Username = "admin",
Email = "admin@admin.com",
AccessLevel = EAccessLevel.Admin,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
user.Password = passwordHasher.HashPassword(user, "admin");
dbContext.Users.Add(user);
#region Users
if (!dbContext.Users.Any()) {
var user = new User {
Username = "admin",
Email = "admin@admin.com",
AccessLevel = EAccessLevel.Admin,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
user.Password = passwordHasher.HashPassword(user, "admin");
dbContext.Users.Add(user);
dbContext.SaveChanges();
}
#endregion
#region Settings
var settings = System.Text.Json.JsonSerializer.Deserialize<List<Setting>>(File.ReadAllText("DefaultSettings.json"));
if (settings is not null) {
//remove all settings that are not in the default settings file
var existingSettings = dbContext.Settings.ToList();
foreach (Setting existingSetting in existingSettings
.Where(existingSetting => settings.All(s => s.Name != existingSetting.Name)))
dbContext.Settings.Remove(existingSetting);
// add or update settings from the default settings file leaving value unchanged
foreach (var setting in settings) {
if (!dbContext.Settings.Any(s => s.Name == setting.Name)) { dbContext.Settings.Add(setting); } else {
var dbSetting = dbContext.Settings.First(s => s.Name == setting.Name);
dbSetting.Description = setting.Description;
dbSetting.Type = setting.Type;
dbSetting.DisplayType = setting.DisplayType;
dbContext.Settings.Update(dbSetting);
}
}
dbContext.SaveChanges();
} //else, we are a bit fucked.
#endregion
#region Folders
if (!dbContext.Folders.Any()) {
dbContext.Folders.Add(
new Folder() {
BasePath = "/diary",
Active = false
}
);
}
dbContext.SaveChanges();
#endregion
}
}
}

View File

@@ -1,171 +1,120 @@
using Butter;
using Butter.Types;
using Butter.Dtos.Settings;
using Butter.Settings;
using Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Utils;
using System.Data;
using System.Diagnostics;
using static System.String;
namespace Lactose.Services;
public class FileSystemScannerService(
ILogger<FileSystemScannerService> logger,
IAssetRepository assetRepository,
IFolderRepository folderRepository,
ISettingsRepository settingsRepository
) : BackgroundService {
public bool IsRunning { get; private set; } = false;
public bool IsInitialized { get; private set; } = false;
public TimeSpan ScanInterval { get; private set; }
List<Folder> folders = [];
readonly List<Task> tasks = [];
public class FileSystemScannerService(ILogger<FileSystemScannerService> logger, IServiceProvider serviceProvider)
: BackgroundService {
public bool IsScanning { get; private set; } = false;
public bool IsEnabled { get; private set; } = false;
public bool IsInitialized { get; private set; } = false;
readonly PeriodicTimer scanTimer = new(TimeSpan.FromMinutes(1));
readonly Dictionary<string, CancellationTokenSource> folders = new();
readonly List<Task> tasks = [];
// ReSharper disable ArrangeObjectCreationWhenTypeEvident
static readonly MimeTypeMap[] ImageMime = [
new("image/avif", [".avif", ".avifs"]),
new("image/bmp}", [".bmp"]),
new("image/cgm", [".cgm"]),
new("image/g3fax", [".g3"]),
new("image/gif", [".gif"]),
new("image/heic", [".heif", ".heic"]),
new("image/ief", [".ief"]),
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/pjpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfi", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/png", [".png"]),
new("image/prs.btif", [".btif"]),
new("image/svg+xml", [".svg", ".svgz"]),
new("image/tiff", [".tif", ".tiff"]),
new("image/vnd.adobe.photoshop", [".psd"]),
new("image/vnd.djvu", [".djv", ".djvu"]),
new("image/vnd.dwg", [".dwg"]),
new("image/vnd.dxf", [".dxf"]),
new("image/vnd.fastbidsheet", [".fbs"]),
new("image/vnd.fpx", [".fpx"]),
new("image/vnd.fst", [".fst"]),
new("image/vnd.fujixerox.edmics-mmr", [".mmr"]),
new("image/vnd.fujixerox.edmics-rlc", [".rlc"]),
new("image/vnd.ms-modi", [".mdi"]),
new("image/vnd.net-fpx", [".npx"]),
new("image/vnd.wap.wbmp", [".wbmp"]),
new("image/vnd.xiff", [".xif"]),
new("image/webp", [".webp"]),
new("image/x-adobe-dng", [".dng"]),
new("image/x-canon-cr2", [".cr2"]),
new("image/x-canon-crw", [".crw"]),
new("image/x-cmu-raster", [".ras"]),
new("image/x-cmx", [".cmx"]),
new("image/x-epson-erf", [".erf"]),
new("image/x-freehand", [".fh", ".fh4", ".fh5", ".fh7", ".fhc"]),
new("image/x-fuji-raf", [".raf"]),
new("image/x-icns", [".icns"]),
new("image/x-icon", [".ico"]),
new("image/x-kodak-dcr", [".dcr"]),
new("image/x-kodak-k25", [".k25"]),
new("image/x-kodak-kdc", [".kdc"]),
new("image/x-minolta-mrw", [".mrw"]),
new("image/x-nikon-nef", [".nef"]),
new("image/x-olympus-orf", [".orf"]),
new("image/x-panasonic-raw", [".raw", ".rw2", ".rwl"]),
new("image/x-pcx", [".pcx"]),
new("image/x-pentax-pef", [".pef", ".ptx"]),
new("image/x-pict", [".pct", ".pic"]),
new("image/x-portable-anymap", [".pnm"]),
new("image/x-portable-bitmap", [".pbm"]),
new("image/x-portable-graymap", [".pgm"]),
new("image/x-portable-pixmap", [".ppm"]),
new("image/x-rgb", [".rgb"]),
new("image/x-sigma-x3f", [".x3f"]),
new("image/x-sony-arw", [".arw"]),
new("image/x-sony-sr2", [".sr2"]),
new("image/x-sony-srf", [".srf"]),
new("image/x-xbitmap", [".xbm"]),
new("image/x-xpixmap", [".xpm"]),
new("image/x-xwindowdump", [".xwd"])
];
static readonly MimeTypeMap[] VideoMime = [
new("video/3gpp", [".3gp"]),
new("video/3gpp2", [".3g2"]),
new("video/h261", [".h261"]),
new("video/h263", [".h263"]),
new("video/h264", [".h264"]),
new("video/jpeg", [".jpgv"]),
new("video/jpm", [".jpgm, .jpm"]),
new("video/mj2", [".mj2, .mjp2"]),
new("video/mp2t", [".ts"]),
new("video/mp4", [".mp4, .mp4v, .mpg4"]),
new("video/mpeg", [".m1v, .m2v, .mpa, .mpe, .mpeg, .mpg"]),
new("video/ogg", [".ogv"]),
new("video/quicktime", [".mov", ".qt"]),
new("video/vnd.fvt", [".fvt"]),
new("video/vnd.mpegurl", [".m4u", ".mxu"]),
new("video/vnd.ms-playready.media.pyv", [".pyv"]),
new("video/vnd.vivo", [".viv"]),
new("video/webm", [".webm"]),
new("video/x-f4v", [".f4v"]),
new("video/x-fli", [".fli"]),
new("video/x-flv", [".flv"]),
new("video/x-m4v", [".m4v"]),
new("video/x-matroska", [".mkv"]),
new("video/x-ms-asf", [".asf", ".asx"]),
new("video/x-ms-wm", [".wm"]),
new("video/x-ms-wmv", [".wmv"]),
new("video/x-ms-wmx", [".wmx"]),
new("video/x-ms-wvx", [".wvx"]),
new("video/x-msvideo", [".avi"]),
new("video/x-sgi-movie", [".movie"])
];
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
if(settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value == "false") {
logger.LogInformation("Folder scanning is disabled. Service will not start.");
return Task.CompletedTask;
}
var service = new Task(() => ServiceLogic(stoppingToken));
service.Start();
var service = new Task(async void () => await TaskWatcher(stoppingToken));
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
}
//TODO: This really needs a decent name.
void ServiceLogic(CancellationToken stoppingToken) {
PeriodicTimer scanTimer;
async Task TaskWatcher(CancellationToken stoppingToken) {
// Resolve the repositories from the service provider
var scope = serviceProvider.CreateScope();
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
IsEnabled = settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value != "false";
stoppingToken.Register(() => {
logger.LogInformation("Filesystem Scan Service stopping...");
folders.Values.Where(tokenSource => !tokenSource.IsCancellationRequested)
.ForEach(tokenSource => {
tokenSource.Cancel();
tokenSource.Dispose();
}
);
folders.Clear();
}
);
var folderRepository = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
folderRepository.GetAllUntracked().Where(f => f.Active).Select(f => f.BasePath).ForEach(AddFolder);
logger.LogInformation("Filesystem Scan Service starting...");
var value = settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value;
if (value != null && !int.TryParse(value, out var interval)) {
logger.LogWarning("Invalid scan interval setting. Defaulting to 30 minutes.");
interval = 30; // Default to 30 minutes if parsing fails
} else interval = int.Parse(value!);
ScanInterval = TimeSpan.FromMinutes(interval);
logger.LogInformation($"Service will scan every {ScanInterval.TotalMinutes} minutes.");
scanTimer = new (ScanInterval);
SetTimerInterval(TimeSpan.FromMinutes(interval));
IsInitialized = true;
while (!stoppingToken.IsCancellationRequested) {
logger.LogInformation("Retrieving folder paths to scan...");
folders = folderRepository.GetAll().ToList();
// Wait for all tasks to complete before starting a new scan
if(tasks.Count > 0) {
logger.LogInformation("Waiting for {count} tasks to complete...", tasks.Count);
Task.WaitAll(tasks.ToArray(), stoppingToken);
IsRunning = false;
}
if (folders.Count != 0) {
// Wait for all tasks to complete before starting a new scan
if (IsScanning) {
logger.LogInformation("Waiting for {count} tasks to complete...", tasks.Count);
WaitTasks(stoppingToken);
}
if (IsEnabled && folders.Count != 0 && !IsScanning) {
logger.LogInformation("Found {count} folders to scan.", folders.Count);
logger.LogInformation("Starting Tasks for scanning folders...");
// Launch a folder scan for each folder, passing the stopping token to each task
folders.ForEach(folder => { tasks.Add(Task.Run(() => ScanFolder(folder.BasePath), stoppingToken)); });
IsRunning = true;
} else {
logger.LogWarning("No folders to scan. Waiting for next scan interval...");
scanTimer.WaitForNextTickAsync(stoppingToken);
folders.Where(entry => !entry.Value.IsCancellationRequested)
.Select(entry => entry.Key)
.ToList()
.ForEach(path => { folders[path] = new CancellationTokenSource(); });
folders.ForEach((entry)=>
tasks.Add(Task.Run(() => ScanFolder(entry.Key, entry.Value.Token), entry.Value.Token))
);
IsScanning = true;
await scanTimer.WaitForNextTickAsync(stoppingToken);
}
}
}
void ScanFolder(string path) {
void WaitTasks(CancellationToken stoppingToken) {
while (IsScanning) {
try {
Task.WaitAll(tasks.ToArray(), stoppingToken);
tasks.Clear();
IsScanning = false;
} catch (OperationCanceledException e) {
logger.LogInformation("Scanning Service was cancelled.");
IsScanning = false;
IsEnabled = false;
} catch (AggregateException e) {
if (!e.InnerExceptions.All(ie => ie is OperationCanceledException)) throw;
logger.LogDebug("A Folder Has been removed. Continuing to scan.");
}
}
}
void ScanFolder(string path, CancellationToken stoppingToken) {
if (stoppingToken.IsCancellationRequested) { stoppingToken.ThrowIfCancellationRequested(); };
List<string> filePaths = [];
List<string> folderPaths = [];
@@ -176,57 +125,117 @@ public class FileSystemScannerService(
} catch (Exception e) { logger.LogError(e, "Error scanning folder {folder}", path); }
// Launch a folder scan for each subfolder
folderPaths.ForEach(folderPath => { tasks.Add(Task.Run(() => ScanFolder(folderPath))); });
folderPaths.ForEach(folderPath => {
logger.LogDebug("Adding subfolder {folder} to scan queue.", folderPath);
tasks.Add(Task.Run(() => ScanFolder(folderPath, stoppingToken), stoppingToken));
});
var scope = serviceProvider.CreateScope();
var assetRepository = scope.ServiceProvider.GetRequiredService<IAssetRepository>();
// Process all files
filePaths.ForEach(
filePath => {
filePaths.ForEach(filePath => {
// Check if the file is already in the database
if (assetRepository.FindByPath(filePath) != null) return;
// Get the file info
var finfo = new FileInfo(filePath);
var asset = AssetFromPath(filePath);
// Determine the type of the file
string ext = finfo.Extension.ToLower();
EAssetType? type;
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
var mimeImg = ImageMime.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = VideoMime.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else return;
// Create a new asset
var asset = new Asset() {
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
IsPubliclyShared = false,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
};
//TODO: check if the file is already in the database
if (asset == null) return;
// Add the asset to the database
try { assetRepository.Insert(asset); }
catch (DuplicateNameException e) {
logger.LogError(e, $"Duplicate asset name \"{finfo.FullName}\", skipped."); }
try {
assetRepository.Insert(asset);
assetRepository.Save();
} catch (DuplicateNameException e) {
logger.LogError(e, $"Duplicate asset name \"{asset.OriginalFilename}\", skipped.");
}
}
);
}
}
public struct MimeTypeMap(string mimeType, string[] extensions) {
public string MimeType { get; set; } = mimeType;
public string[] Extensions { get; set; } = extensions;
Asset? AssetFromPath(string filePath) {
logger.LogTrace("Loading asset from file path {filePath}", filePath);
// Get the file info
var finfo = new FileInfo(filePath);
// Determine the type of the file
string ext = finfo.Extension.ToLower();
EAssetType? type;
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else return null;
// Create a new asset
var asset = new Asset() {
//TODO: folder ID is missing here
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
IsPubliclyShared = false,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
};
logger.LogDebug(
$"""
Asset created from path: {asset.OriginalPath}
Data:
- Filename: {asset.OriginalFilename}
- Type: {asset.Type}
- MimeType: {asset.MimeType}
- CreatedAt: {asset.CreatedAt}
- UpdatedAt: {asset.UpdatedAt}
- FileSize: {asset.FileSize} bytes
""");
return asset;
}
public void SetTimerInterval(TimeSpan interval) {
scanTimer.Period = interval;
logger.LogInformation($"Service will scan every {scanTimer.Period.TotalMinutes} minutes.");
}
public void AddFolder(string folder) {
if (folders.ContainsKey(folder)) {
logger.LogWarning("Folder {folder} already exists", folder);
return;
}
folders.Add(folder, new CancellationTokenSource());
logger.LogInformation("Added folder {folder}", folder);
}
public void AddFolder(Folder folder) => AddFolder(folder.BasePath);
public void RemoveFolder(string folder) {
if (!folders.TryGetValue(folder, out CancellationTokenSource? cancellationToken)) {
logger.LogWarning("Folder {folder} does not exist", folder);
return;
}
cancellationToken.Cancel();
cancellationToken.Dispose();
folders.Remove(folder);
logger.LogInformation("Removed folder {folder}", folder);
}
public void RemoveFolder(Folder folder) => RemoveFolder(folder.BasePath);
public void Enable() {
IsEnabled = true;
logger.LogInformation("Service is now enabled.");
}
public void Disable() => IsEnabled = false;
}

View File

@@ -1,33 +0,0 @@
namespace Lactose;
public enum Settings {
UserRegistrationEnabled, //Enable or disable self user registration
FolderScanEnabled, // Used to enable/disable folder scanning
FolderScanInterval, //Interval in minutes for folder scanning
FileUploadEnabled, //Enable or disable file upload
FileUploadMaxSize, //Maximum file size for uploads in bytes
}
public static class SettingsExtensions {
public static string AsString(this Settings setting) {
return setting switch {
Settings.UserRegistrationEnabled => "UserRegistrationEnabled",
Settings.FolderScanEnabled => "FolderScanEnabled",
Settings.FolderScanInterval => "FolderScanInterval",
Settings.FileUploadEnabled => "FileUploadEnabled",
Settings.FileUploadMaxSize => "FileUploadMaxSize",
_ => throw new ArgumentOutOfRangeException(nameof(setting), setting, null)
};
}
public static Type AsValueType(this Settings setting) {
return setting switch {
Settings.UserRegistrationEnabled => typeof(bool),
Settings.FolderScanEnabled => typeof(bool),
Settings.FolderScanInterval => typeof(int),
Settings.FileUploadEnabled => typeof(bool),
Settings.FileUploadMaxSize => typeof(int),
_ => throw new ArgumentOutOfRangeException(nameof(setting), setting, null)
};
}
}

View File

@@ -0,0 +1,10 @@
namespace Lactose.Utils;
public static class EnumerableExtensions {
public static void ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action) {
foreach (var item in source) {
action(item);
}
}
}

View File

@@ -9,7 +9,8 @@
"DatabaseCredentials": {
"UserID": "root",
"Password": "testOnlyDb",
"Database": "TestDb"
"Database": "TestDb",
"connString":"Server=127.0.0.1;Port=3306;Database=TestDb;User Id=root;Password=testOnlyDb;"
},
"DatabaseAddress": {
"Host": "localhost",

17
MilkStream/AuthHelpers.cs Normal file
View File

@@ -0,0 +1,17 @@
using Butter.Dtos;
namespace MilkStream;
public static class AuthHelpers {
public static AuthInfo ToAuthInfo(this AuthResultDto authResult) {
if (authResult == null) {
throw new ArgumentNullException(nameof(authResult), "AuthResultDto cannot be null");
}
return new () {
UserId = authResult.UserId,
Token = authResult.Token,
RefreshToken = authResult.RefreshToken
};
}
}

7
MilkStream/AuthInfo.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace MilkStream;
public class AuthInfo {
public required Guid? UserId { get; set; }
public required string? Token { get; set; }
public required string? RefreshToken { get; set; }
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-bs-theme="dark" lang="en">
<head>
<meta charset="utf-8"/>
@@ -12,14 +12,16 @@
@* ReSharper disable once Html.PathError *@
<link rel="stylesheet" href="MilkStream.styles.css"/>
@* the above one is the autogenerated css from the component isolations *@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet @rendermode="InteractiveServer"/>
<HeadOutlet @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<title>Milkstream</title>
</head>
<body>
<Routes @rendermode="InteractiveServer"/>
<script src="_framework/blazor.web.js"></script>
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="lib/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,59 @@
@using Butter.Dtos.Folder
@using MilkStream.Services
@inject FoldersService FoldersService
@inject NavigationManager NavigationManager
<div class="card mb-3">
<div class="card-body align-items-center">
<div class="d-flex flex-row flex-grow-1 align-items-center">
<i class="bi bi-folder2 ms-3 mx-1"></i><p class="mx-1 mb-0 @Display">@(Folder.BasePath ?? "Unknown Folder")</p>
<input class="form-control form-control-sm mx-1 @Edit" type="text" @bind="Folder.BasePath"/>
<button class="btn btn-sm btn-success @Edit" @onclick="OnConfirmEdit"><i class="bi bi-check"></i></button>
<div class="ms-auto form-switch">
<input class="form-check-input m-1" type="checkbox" id="@Folder.Id" @bind="@Folder.Active"/>
</div>
<button class="btn btn-outline-warning btn-sm @Display" @onclick="OnEdit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm mx-1" @onclick="OnDelete"><i class="bi bi-folder-minus"></i></button>
</div>
</div>
</div>
@code {
bool editing = false;
string Display => editing ? "d-none" : "";
string Edit => editing ? "" : "d-none";
[Parameter]
public FolderFullDto Folder { get; set; } = new FolderFullDto();
protected override void OnInitialized() {
FoldersService.SaveFolders += (_, _) => FoldersService.UpdateFolder(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
BasePath = Folder.BasePath
}
);
}
void OnEdit() {
editing = true;
}
void OnConfirmEdit() {
FoldersService.UpdateFolder(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
BasePath = Folder.BasePath
}
);
editing = false;
}
void OnDelete() {
FoldersService.RemoveFolder(Folder.Id);
NavigationManager.Refresh(true);
}
}

View File

@@ -1,16 +1,15 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu/>
<NavMenu/>
<main class="container-xxl d-flex flex-grow-1">
<div class="d-flex flex-column border border-1 border-opacity-50 border-secondary-subtle rounded-3 m-lg-5 m-1 p-lg-5 p-1 flex-grow-1">
@Body
</div>
</main>
<footer class="container-xxl footer d-flex justify-content-center">
<span class="text-primary m-auto">Footer (duh!)</span>
</footer>
<main>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.

View File

@@ -1,81 +1,6 @@
.page {
position: relative;
display: flex;
flex-direction: row;
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, rgb(174, 18, 213) 90%);
color: lightgray;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.page{
height: 100%;
min-height: 100%;
}
#blazor-error-ui {

View File

@@ -1,25 +1,115 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<div>
<a class="navbar-brand" href="">
<img src="MilkyShotLogoWhite.svg" alt="Milky Shot Logo" class="logo" href=""/>
Milky Shot
</a>
@using MilkStream.Services
@using Butter.Types
@inherits LayoutComponentBase
@inject NavigationManager navigation
@inject UserService userService
@inject LoginService loginService
@inject ProtectedLocalStorage localStorage
<div class="navbar navbar-expand-sm bg-dark-subtle">
<div class="container-xl">
<a class="mx-2 navbar-brand" href="">
<img src="img/MilkyShotLogoWhite.svg" alt="Milky Shot Logo" class="logo mx-2" href=""/>
<span class="text m-1">Milky Shot</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="#">Cosplayers</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Albums</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Photos</a>
</li>
</ul>
@if (isConnected) {
if (!LoggedIn) {
<button class="btn btn-primary mx-1" @onclick="OnLoginClick">
Login
</button>
<button class="btn btn-primary mx-1" @onclick="OnRegisterClick">
Register
</button>
} else {
<div class="d-flex">
<input class="form-control form-control-sm ms-1"
style="border-top-right-radius: 0; border-bottom-right-radius: 0" type="search"
placeholder="Search anything..." aria-label="search"/>
<button class="btn btn-sm btn-outline-success me-1"
style="border-top-left-radius: 0; border-bottom-left-radius: 0" type="submit">
<i class="bi bi-search-heart"></i>
</button>
</div>
<div class="mx-2">@loginService.LoggedUser?.Username</div>
<div class="btn-group">
<button class="btn btn-lg btn-outline-light" type="button">
<i class="bi bi-person-circle"></i>
</button>
<button class="btn btn-lg btn-outline-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
</ul>
</div>
}
}
</div>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler"/>
@code{
bool LoggedIn => loginService.IsLoggedIn;
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
bool isConnected;
bool needsUpdate;
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="/login">
<span class="bi" aria-hidden="true"></span> Login
</NavLink>
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
</nav>
</div>
protected override void OnInitialized() {
base.OnInitialized();
loginService.LoggedUserChanged += (sender, dto) => needsUpdate = true;
LoadStateAsync().ConfigureAwait(false);
}
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender || needsUpdate) {
isConnected = true;
await LoadStateAsync();
needsUpdate = false;
StateHasChanged();
}
}
private async Task LoadStateAsync() {
var auth = await localStorage.GetAsync<AuthInfo>("auth");
if (auth.Success == false) return;
loginService.AuthInfo = auth.Value;
loginService.LoggedUser = await userService.GetUserAsync();//.ConfigureAwait(false);
}
async Task OnLoginClick() {
loginService.LoggedUser = await userService.GetUserAsync();
if (loginService.LoggedUser != null) needsUpdate = true;
else navigation.NavigateTo("/login");
}
void OnRegisterClick() => navigation.NavigateTo("/register");
void OnSettingsClick() => navigation.NavigateTo("/settings");
async Task OnLogout() {
_ = loginService.Logout();
await localStorage.DeleteAsync("auth");
navigation.NavigateTo("/", true);
}
}

View File

@@ -1,29 +1,12 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
.navbar{
box-shadow: 0 0.05rem 0.3rem var(--bs-secondary-bg-subtle);
}
.navbar-brand {
font-size: 1.1rem;
align-items: center;
flex-direction: row;
filter: drop-shadow(0 0 0.3rem var(--bs-light-border-subtle));
}
.logo{
@@ -31,83 +14,3 @@
height: 2.5rem;
align-items: center;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -1,24 +1,8 @@
<div class="spinner-container m-5">
<div class="spinner-border m-2">
<span class="visually-hidden">Loading...</span>
</div>
<div class="d-flex align-items-center flex-column m-5">
<div class="spinner-border text-info m-2"></div>
<div class="m-2">
<strong class="text">
<span>L</span>
<span>o</span>
<span>a</span>
<span>d</span>
<span>i</span>
<span>n</span>
<span>g</span>
<span> </span>
<span>d</span>
<span>a</span>
<span>t</span>
<span>a</span>
<span>.</span>
<span>.</span>
<span>.</span>
<strong class="text-info-emphasis">
Loading data...
</strong>
</div>
</div>

View File

@@ -1,52 +0,0 @@
.spinner-container{
display: flex;
flex-direction: column;
align-items: center;
}
.spinner-container ::deep .spinner-border {
}
.spinner-container ::deep .text {
color: var(--main-col);
margin: 0.5rem;
}
.spinner-container ::deep .animated-text {
animation: ease-in-out 1s linear infinite;
animation-direction: normal;
}
.text {
display: inline-block;
position: relative;
}
@keyframes jump {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.text span {
display: inline-block;
animation: jump 1.5s ease-in-out 1s infinite;
}
.text span:nth-child(1) { animation-delay: 0.0s; }
.text span:nth-child(2) { animation-delay: 0.1s; }
.text span:nth-child(3) { animation-delay: 0.2s; }
.text span:nth-child(4) { animation-delay: 0.3s; }
.text span:nth-child(5) { animation-delay: 0.4s; }
.text span:nth-child(6) { animation-delay: 0.5s; }
.text span:nth-child(7) { animation-delay: 0.6s; }
.text span:nth-child(8) { animation-delay: 0.7s; }
.text span:nth-child(9) { animation-delay: 0.8s; }
.text span:nth-child(10) { animation-delay: 0.9s; }
.text span:nth-child(11) { animation-delay: 1.0s; }
.text span:nth-child(12) { animation-delay: 1.1s; }
.text span:nth-child(13) { animation-delay: 1.2s; }
.text span:nth-child(14) { animation-delay: 1.3s; }
.text span:nth-child(15) { animation-delay: 1.4s; }

View File

@@ -1,21 +1,27 @@
@page "/"
@using Butter.Dtos;
@using MilkStream.Components.Layout
@using MilkStream.Services
@inject NavigationManager navigationManager
@inject ISessionStorageService sessionStorage
@inject LoginService loginService
@inject NavigationManager navigationManager
@inject LoginService loginService
@inject ProtectedSessionStorage sessionStorage
@inject ProtectedLocalStorage localStorage
<PageTitle>Home</PageTitle>
<div class="home-imagecontainer">
<div class="">
@if (isLoading) {
<LoadSpinner/>
} else {
@if (mediaList.Count == 0) {
<div class="no-media">
<h2>No media available</h2>
<p>Please log in to see your media.</p>
<div class="border-warning border-5 border-opacity-100 rounded-3 p-3 m-5 bg-warning-subtle text-center">
<h2 class="text-warning">No media available</h2>
@if (loginService.IsLoggedIn) {
<p class="text-warning-emphasis">You are logged in, but there is no media available at the moment.</p>
} else {
<p class="text-warning-emphasis">Please <a href="/login" class="link-warning">log-in</a> to be able to browse any media.</p>
}
</div>
} else {
<div class="media-list">
@@ -34,10 +40,13 @@
List<MediaDto> mediaList = new();
protected async override Task OnAfterRenderAsync(bool firstRender) {
var ssid = await sessionStorage.GetItemAsync<string?>("userId");
var authToken = await sessionStorage.GetItemAsync<string?>("authToken");
if (string.IsNullOrEmpty(ssid) || string.IsNullOrEmpty(authToken)) { isLoading = false; }
if (firstRender) {
loginService.LoggedUserChanged += (sender, dto) => StateHasChanged();
//do nothing for now
await Task.Delay(1);
isLoading = false;
StateHasChanged();
}
}
}

View File

@@ -1,57 +1,53 @@
@page "/login"
@using Butter.Dtos
@using Microsoft.AspNetCore.Mvc.RazorPages
@using MilkStream.Services
@inject NavigationManager navigationManager
@inject LoginService loginService
@inject ISessionStorageService sessionStorage
@inject NavigationManager navigation
@inject LoginService loginService
@inject ProtectedSessionStorage sessionStorageService
@inject ProtectedLocalStorage protectedLocalStorage
<PageTitle>Login</PageTitle>
<div class="login-container">
@if (isLoginFailed) {
<div class="login-failed login-group">
<div class="login-failed-text">Login failed. Please try again.</div>
<div class="d-flex flex-column mx-sm-auto mx-3">
@if (isLoginFailed){
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle align-items-center">
<div class="text-danger-emphasis m-2">Login failed. Please try again.</div>
</div>
}
<div class="login-group">
<label class="login-lbl">Username</label>
<input type="text" placeholder="Username" required="required" id="username" @bind="username"/>
</div>
<div class="login-group">
<label class="login-lbl">Password</label>
<input type="password" placeholder="Password" required="required" id="password" @bind="password"/>
</div>
<div class="login-group">
<div style="margin-left: auto"></div>
<div class="login-href">Forgot password?</div>
</div>
<div class="login-group">
<button class="login-btn" type="submit" id="login-button" @onclick="Login_OnClick">Login</button>
<a href="/register">
<button class="login-btn" type="button" id="register-button">Register</button>
</a>
<div class="d-flex flex-column align-items-center">
<input type="text" class="form-control m-2" placeholder="Username" required="required" id="username" @bind="username"/>
<input type="password" class="form-control m-2" placeholder="Password" required="required" id="password" @bind="password"/>
<div class="align-self-start m-2"><a class="link-primary" href="#">Forgot password?</a></div>
<input type="checkbox" class="btn-check m-2" id="remember-me" autocomplete="off" />
<div class="d-flex flex-row justify-content-around">
<button class="btn btn-primary m-2" type="submit" id="login-button" @onclick="Login_OnClick">Login</button>
<button class="btn btn-outline-primary m-2" type="button" id="register-button" @onclick="Register_OnClick">Register</button>
</div>
</div>
</div>
@code{
string username = string.Empty;
string password = string.Empty;
bool isLoginFailed = false;
string username = string.Empty;
string password = string.Empty;
bool isLoginFailed;
private async Task Login_OnClick() {
protected override void OnInitialized() {
//if (loginService.IsLoggedIn) navigation.NavigateTo("/", true);
}
void Register_OnClick() => navigation.NavigateTo("register");
async Task Login_OnClick() {
isLoginFailed = false; // Reset the login failure string
var result = await loginService.Login(username, password);
if (result.Item1) { // Login successful
await sessionStorage.SetItemAsync("userId", result.Item2?.UserId.ToString()!);
await sessionStorage.SetItemAsync("authToken", result.Item2?.Token!);
await sessionStorage.SetItemAsync("refreshToken", result.Item2?.RefreshToken!);
navigationManager.NavigateTo("/"); // Redirect to home page
isLoginFailed = false;
await protectedLocalStorage.SetAsync("auth", result.Item2!);
navigation.NavigateTo("/", true); // Redirect to home page
} else {
//TODO: Handle login failure
//show a message to the user
isLoginFailed = true;
}
}
}

View File

@@ -1,89 +0,0 @@
.login-container{
display: flex;
flex-direction: column; /* Stack the form groups vertically */
min-width: 300px; /* Set a width for the container */
max-width: 500px; /* Set a maximum width for the container */
margin: 0 auto; /* Center the container */
}
.login-group{
display: flex; /* Use flexbox for each form group */
align-items: center; /* Center items vertically */
justify-content: center;
margin-bottom: 8px; /* Space between form groups */
padding: 0 2px 0 2px; /* Add some padding */
}
.login-btn{
/*display: flex; /* Use flexbox for the button */
align-items: center; /* Center items vertically */
margin-top: 15px; /* Space above the button */
margin-left: 12px;
margin-right: 12px;
padding: 2px 4px;
border: 1px lightgray solid;
border-radius: 5px;
background-color: rgb(255, 255, 255); /* Light background */
color: rgb(5, 39, 103); /* Dark text */
}
.login-btn:hover, .login-btn:focus {
cursor: pointer;
background-color: rgb(230, 230, 230); /* Slightly darker on hover */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: ease-in-out;
transition-duration: 0.2s;
}
.login-href{
align-items: flex-start;
margin-left: 0;
margin-right: auto;
}
.login-href:hover{
cursor: pointer;
color: rgb(174, 18, 213);
text-decoration: underline;
transition: ease-in-out;
transition-duration: 0.2s;
}
label{
flex: 1; /* Allow label to take up space */
margin-right: 10px; /* Space between label and input */
}
.login-lbl{
text-align-last: right;
}
input {
flex: 2; /* Allow input to take up more space */
padding: 8px; /* Add some padding for better appearance */
border-radius: 4px; /* Rounded corners */
background-color: rgba(255, 255, 255, 0.8); /* Semi-transparent */
color: rgb(5, 39, 103);
border: 1px solid rgb(174, 18, 213); /* Match the gradient */
transition: border-color 0.3s;
}
input:hover, input:focus {
cursor: pointer;
border-color: rgb(5, 39, 103); /* Change border on focus */
outline: none;
box-shadow: 0 0 5px rgba(174, 18, 213, 0.5);
}
.login-failed {
background-color: darkred;
border: 1px solid red;
border-radius: 5px;
}
.login-failed-text {
color: white;
padding: 5px;
text-align: center;
font-weight: bold;
}

View File

@@ -2,44 +2,34 @@
@using MilkStream.Services
@inject LoginService loginService
@inject NavigationManager Navigation
@inject NavigationManager navigation
<PageTitle>Register</PageTitle>
<div class="login-container">
<div class="login-group">
<label class="login-lbl">Username</label>
<input type="text" placeholder="Username" required="required" id="username" @bind="username"/>
</div>
<div class="login-group">
<label class="login-lbl">Email</label>
<input type="email" placeholder="Email" required="required" id="email" @bind="email"/>
</div>
<div class="login-group">
<label class="login-lbl">Password</label>
<input type="password" placeholder="Password" required="required" id="password" @bind="password"/>
</div>
<div class="login-group">
<label class="login-lbl">Confirm Password</label>
<input type="password" placeholder="Confirm Password" required="required" id="confirm-password" @bind="confirmPassword"/>
</div>
<div class="login-group">
<a href="/login"><button class="login-btn" type="button" id="login-button">Login</button></a>
<button class="login-btn" type="button" id="register-button" @onclick="Register_OnClick">Register</button>
</div>
</div>
<form class="d-flex flex-column mx-sm-auto mx-3 align-items-center">
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username" @bind="username"/>
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email" @bind="email"/>
<input type="password" class="form-control my-1" placeholder="Password" required="required" id="password" @bind="password"/>
<input type="password" class="form-control my-1" placeholder="Confirm Password" required="required" id="confirm-password" @bind="confirmPassword"/>
<div class="d-flex flex-row justify-content-around">
<button class="btn btn-primary m-2" type="button" id="register-button" @onclick="Register_OnClick">Register</button>
<button class="btn btn-outline-primary m-2" type="button" id="login-button" @onclick="Login_OnClick">Login</button>
</div>
</form>
@code {
string username = string.Empty;
string email = string.Empty;
string password = string.Empty;
string confirmPassword = string.Empty;
void Login_OnClick() => navigation.NavigateTo("login");
async Task Register_OnClick() {
var result = await loginService.Register(username, email, password);
if (result) {
Navigation.NavigateTo("login");
navigation.NavigateTo("login");
}
}
}

View File

@@ -0,0 +1,105 @@
@page "/Settings"
@using Butter.Dtos.Folder
@using Butter.Dtos.Settings
@using Butter.Settings
@using MilkStream.Components.SettingBoxes
@using MilkStream.Services
@using System.Linq;
@inject NavigationManager NavigationManager
@inject LoginService LoginService
@inject SettingsService SettingsService
@inject FoldersService FoldersService
<PageTitle>Settings</PageTitle>
<h3>Settings</h3>
@if (settings.Any()) {
foreach (var option in settings) {
switch (option.DisplayType) {
// TODO: should implement text and password at some point
case DisplayType.Text:
<input type="text" id="@option.Name" class="form-control" @bind="@option.Value"/>
break;
case DisplayType.Password:
<input type="password" id="@option.Name" class="form-control" @bind="@option.Value"/>
break;
case DisplayType.IntNumber:
case DisplayType.FloatNumber:
<SettingNumber Setting="option"/>
break;
case DisplayType.Checkbox:
// TODO: maybe decide if we ever need it?
case DisplayType.Switch:
<SettingSwitch Setting="option"/>
break;
case DisplayType.DateTimePicker:
<div class="">DATETIME NOT IMPLEMENTED Value: @option.Value</div>
break;
case DisplayType.TimePicker:
<SettingRange Setting="option"/>
break;
}
}
} else {
<div class="alert alert-danger my-2 my-sm-5 mx-auto" style="max-width: fit-content" role="alert">
No settings available.
</div>
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnSaveChanges"><i class="bi bi-floppy2 mx-1"></i>Save Changes</button>
</div>
<hr/>
<h3>Folders</h3>
@if (folders.Any()) {
foreach (var folder in folders) {
<FolderBox Folder="folder"/>
}
} else {
<div class="alert alert-danger my-2 my-sm-5 mx-auto" style="max-width: fit-content" role="alert">
No folders available.
</div>
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnAddFolder"><i class="bi bi-folder-plus mx-1"></i>Add folder</button>
</div>
@code {
List<SettingDto> settings = new();
List<FolderFullDto> folders = new();
protected override void OnInitialized() {
base.OnInitialized();
LoginService.LoggedUserChanged += async (_, _) => {
await LoadData();
StateHasChanged();
};
}
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
if (LoginService.IsLoggedIn)
await LoadData();
else
LoginService.LoggedUserChanged += async (_, _) => await LoadData();
StateHasChanged();
}
}
async Task LoadData() {
settings = await SettingsService.GetAllSettings() ?? new List<SettingDto>();
folders = await FoldersService.GetAllFolders() ?? new List<FolderFullDto>();
StateHasChanged();
}
void OnSaveChanges() {
SettingsService.OnBeginSave();
StateHasChanged();
}
void OnAddFolder() {
FoldersService.CreateFolder(new FolderCreateDto() { BasePath = "New Folder" });
NavigationManager.Refresh(true);
}
}

View File

@@ -0,0 +1,17 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@inject SettingsService SettingsService
@code {
[Parameter]
public required SettingDto Setting { get; set; }
protected virtual SettingDto GetSetting() {
return Setting;
}
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
}
}

View File

@@ -0,0 +1,34 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@using System.Globalization
@inherits SettingBox
@inject SettingsService SettingsService
<div class="card mb-3">
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div>
<input type="number" id="@Setting?.Name" class="form-control w-auto" @bind="currentValue" />
</div>
</div>
</div>
@code {
private float currentValue;
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
if (float.TryParse(Setting.Value, out float value)) {
currentValue = value;
} else {
currentValue = 0; // Default value if parsing fails
}
}
protected override SettingDto GetSetting() {
Setting.Value = currentValue.ToString(CultureInfo.InvariantCulture);
return Setting;
}
}

View File

@@ -0,0 +1,32 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@inherits SettingBox
@inject SettingsService SettingsService
<div class="card mb-3">
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex">
<div class="mx-2">@currentValue</div>
<input type="range" id="@Setting?.Name" class="form-range" @bind="@currentValue"/>
</div>
</div>
</div>
@code {
private int currentValue;
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
if (int.TryParse(Setting.Value, out int value)) { currentValue = value; } else {
currentValue = 0; // Default value if parsing fails
}
}
protected override SettingDto GetSetting() {
Setting.Value = currentValue.ToString();
return Setting;
}
}

View File

@@ -0,0 +1,31 @@
@using Butter.Dtos.Settings
@using Butter.Settings
@using MilkStream.Services
@inherits SettingBox
@inject SettingsService SettingsService
<div class="card mb-3">
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="form-switch">
<input type="checkbox" id="@Setting?.Name" class="form-check-input m-1" @bind="isChecked"/>
</div>
</div>
</div>
@code {
private bool isChecked;
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
if (bool.TryParse(Setting.Value, out var parsedValue)) {
isChecked = parsedValue;
}
}
protected override SettingDto GetSetting() {
Setting.Value = isChecked.ToString();
return Setting;
}
}

View File

@@ -5,6 +5,7 @@
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@using Microsoft.JSInterop
@using MilkStream
@using MilkStream.Components

View File

@@ -20,4 +20,8 @@ RUN dotnet publish "./MilkStream.csproj" -c $BUILD_CONFIGURATION -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p /home/app/.aspnet/DataProtection-Keys
RUN chown -R $APP_UID /home/app/
RUN chmod -R 770 /home/app/.aspnet/DataProtection-Keys
VOLUME ["/home/app/.aspnet/DataProtection-Keys"]
ENTRYPOINT ["dotnet", "MilkStream.dll"]

View File

@@ -0,0 +1,38 @@
using MilkStream.Services;
using System.Net;
using Microsoft.IdentityModel.JsonWebTokens;
namespace MilkStream;
/// <summary>
/// Can be added to an HTTP client to automatically refresh JWT tokens when they expire.
/// </summary>
/// <param name="loginService">login service that handles the current identity</param>
public class JwtTokenRefresher(LoginService loginService, ILogger<JwtTokenRefresher> logger) : DelegatingHandler {
protected async override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
) {
var jwt = new JsonWebToken(request.Headers.Authorization?.Parameter);
// Check if the JWT is valid and not going to expire within 1 minute otherwise send the request as is.
if (jwt.ValidTo >= DateTime.UtcNow.AddMinutes(1)) {
logger.LogDebug("JWT Token valid up to {JwtValidTo}, no need to refresh.", jwt.ValidTo);
return await base.SendAsync(request, cancellationToken);
}
logger.LogDebug("JWT Token expired or expiring soon at {JwtValidTo}, refreshing token...", jwt.ValidTo);
// If the JWT is expired or going to expire within 1 minute, reauthenticate.
var auth = await loginService.Reauthenticate();
if (auth == null) {
// If reauthentication fails, return an unauthorized response.
logger.LogDebug("Failed to reauthenticate, returning unauthorized response.");
return new HttpResponseMessage(HttpStatusCode.Unauthorized) {
Content = new StringContent("Authentication failed. Please log in again.")
};
}
jwt = new JsonWebToken(auth.Token);
logger.LogDebug("JWT Token refreshed, valid up to {JwtValidTo}.", jwt.ValidTo);
request.Headers.Authorization = new("bearer", auth.Token);
return await base.SendAsync(request, cancellationToken);
}
}

View File

@@ -1,3 +0,0 @@
bootstrap/dist/*
bootstrap/js/dist/*
bootstrap

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,247 @@
<p align="center">
<a href="https://getbootstrap.com/">
<img src="https://getbootstrap.com/docs/5.3/assets/brand/bootstrap-logo-shadow.png" alt="Bootstrap logo" width="200" height="165">
</a>
</p>
<h3 align="center">Bootstrap</h3>
<p align="center">
Sleek, intuitive, and powerful front-end framework for faster and easier web development.
<br>
<a href="https://getbootstrap.com/docs/5.3/"><strong>Explore Bootstrap docs »</strong></a>
<br>
<br>
<a href="https://github.com/twbs/bootstrap/issues/new?assignees=-&labels=bug&template=bug_report.yml">Report bug</a>
·
<a href="https://github.com/twbs/bootstrap/issues/new?assignees=&labels=feature&template=feature_request.yml">Request feature</a>
·
<a href="https://themes.getbootstrap.com/">Themes</a>
·
<a href="https://blog.getbootstrap.com/">Blog</a>
</p>
## Bootstrap 5
Our default branch is for development of our Bootstrap 5 release. Head to the [`v4-dev` branch](https://github.com/twbs/bootstrap/tree/v4-dev) to view the readme, documentation, and source code for Bootstrap 4.
## Table of contents
- [Quick start](#quick-start)
- [Status](#status)
- [Whats included](#whats-included)
- [Bugs and feature requests](#bugs-and-feature-requests)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [Community](#community)
- [Versioning](#versioning)
- [Creators](#creators)
- [Thanks](#thanks)
- [Copyright and license](#copyright-and-license)
## Quick start
Several quick start options are available:
- [Download the latest release](https://github.com/twbs/bootstrap/archive/v5.3.7.zip)
- Clone the repo: `git clone https://github.com/twbs/bootstrap.git`
- Install with [npm](https://www.npmjs.com/): `npm install bootstrap@v5.3.7`
- Install with [yarn](https://yarnpkg.com/): `yarn add bootstrap@v5.3.7`
- Install with [Bun](https://bun.sh/): `bun add bootstrap@v5.3.7`
- Install with [Composer](https://getcomposer.org/): `composer require twbs/bootstrap:5.3.7`
- Install with [NuGet](https://www.nuget.org/): CSS: `Install-Package bootstrap` Sass: `Install-Package bootstrap.sass`
Read the [Getting started page](https://getbootstrap.com/docs/5.3/getting-started/introduction/) for information on the framework contents, templates, examples, and more.
## Status
[![Build Status](https://img.shields.io/github/actions/workflow/status/twbs/bootstrap/js.yml?branch=main&label=JS%20Tests&logo=github)](https://github.com/twbs/bootstrap/actions/workflows/js.yml?query=workflow%3AJS+branch%3Amain)
[![npm version](https://img.shields.io/npm/v/bootstrap?logo=npm&logoColor=fff)](https://www.npmjs.com/package/bootstrap)
[![Gem version](https://img.shields.io/gem/v/bootstrap?logo=rubygems&logoColor=fff)](https://rubygems.org/gems/bootstrap)
[![Meteor Atmosphere](https://img.shields.io/badge/meteor-twbs%3Abootstrap-blue?logo=meteor&logoColor=fff)](https://atmospherejs.com/twbs/bootstrap)
[![Packagist Prerelease](https://img.shields.io/packagist/vpre/twbs/bootstrap?logo=packagist&logoColor=fff)](https://packagist.org/packages/twbs/bootstrap)
[![NuGet](https://img.shields.io/nuget/vpre/bootstrap?logo=nuget&logoColor=fff)](https://www.nuget.org/packages/bootstrap/absoluteLatest)
[![Coverage Status](https://img.shields.io/coveralls/github/twbs/bootstrap/main?logo=coveralls&logoColor=fff)](https://coveralls.io/github/twbs/bootstrap?branch=main)
[![CSS gzip size](https://img.badgesize.io/twbs/bootstrap/main/dist/css/bootstrap.min.css?compression=gzip&label=CSS%20gzip%20size)](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css)
[![CSS Brotli size](https://img.badgesize.io/twbs/bootstrap/main/dist/css/bootstrap.min.css?compression=brotli&label=CSS%20Brotli%20size)](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css)
[![JS gzip size](https://img.badgesize.io/twbs/bootstrap/main/dist/js/bootstrap.min.js?compression=gzip&label=JS%20gzip%20size)](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js)
[![JS Brotli size](https://img.badgesize.io/twbs/bootstrap/main/dist/js/bootstrap.min.js?compression=brotli&label=JS%20Brotli%20size)](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js)
[![Backers on Open Collective](https://img.shields.io/opencollective/backers/bootstrap?logo=opencollective&logoColor=fff)](#backers)
[![Sponsors on Open Collective](https://img.shields.io/opencollective/sponsors/bootstrap?logo=opencollective&logoColor=fff)](#sponsors)
## Whats included
Within the download youll find the following directories and files, logically grouping common assets and providing both compiled and minified variations.
<details>
<summary>Download contents</summary>
```text
bootstrap/
├── css/
│ ├── bootstrap-grid.css
│ ├── bootstrap-grid.css.map
│ ├── bootstrap-grid.min.css
│ ├── bootstrap-grid.min.css.map
│ ├── bootstrap-grid.rtl.css
│ ├── bootstrap-grid.rtl.css.map
│ ├── bootstrap-grid.rtl.min.css
│ ├── bootstrap-grid.rtl.min.css.map
│ ├── bootstrap-reboot.css
│ ├── bootstrap-reboot.css.map
│ ├── bootstrap-reboot.min.css
│ ├── bootstrap-reboot.min.css.map
│ ├── bootstrap-reboot.rtl.css
│ ├── bootstrap-reboot.rtl.css.map
│ ├── bootstrap-reboot.rtl.min.css
│ ├── bootstrap-reboot.rtl.min.css.map
│ ├── bootstrap-utilities.css
│ ├── bootstrap-utilities.css.map
│ ├── bootstrap-utilities.min.css
│ ├── bootstrap-utilities.min.css.map
│ ├── bootstrap-utilities.rtl.css
│ ├── bootstrap-utilities.rtl.css.map
│ ├── bootstrap-utilities.rtl.min.css
│ ├── bootstrap-utilities.rtl.min.css.map
│ ├── bootstrap.css
│ ├── bootstrap.css.map
│ ├── bootstrap.min.css
│ ├── bootstrap.min.css.map
│ ├── bootstrap.rtl.css
│ ├── bootstrap.rtl.css.map
│ ├── bootstrap.rtl.min.css
│ └── bootstrap.rtl.min.css.map
└── js/
├── bootstrap.bundle.js
├── bootstrap.bundle.js.map
├── bootstrap.bundle.min.js
├── bootstrap.bundle.min.js.map
├── bootstrap.esm.js
├── bootstrap.esm.js.map
├── bootstrap.esm.min.js
├── bootstrap.esm.min.js.map
├── bootstrap.js
├── bootstrap.js.map
├── bootstrap.min.js
└── bootstrap.min.js.map
```
</details>
We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified CSS and JS (`bootstrap.min.*`). [Source maps](https://web.dev/articles/source-maps) (`bootstrap.*.map`) are available for use with certain browsers developer tools. Bundled JS files (`bootstrap.bundle.js` and minified `bootstrap.bundle.min.js`) include [Popper](https://popper.js.org/docs/v2/).
## Bugs and feature requests
Have a bug or a feature request? Please first read the [issue guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md#using-the-issue-tracker) and search for existing and closed issues. If your problem or idea is not addressed yet, [please open a new issue](https://github.com/twbs/bootstrap/issues/new/choose).
## Documentation
Bootstraps documentation, included in this repo in the root directory, is built with [Astro](https://astro.build/) and publicly hosted on GitHub Pages at <https://getbootstrap.com/>. The docs may also be run locally.
Documentation search is powered by [Algolia's DocSearch](https://docsearch.algolia.com/).
### Running documentation locally
1. Run `npm install` to install the Node.js dependencies, including Astro (the site builder).
2. Run `npm run test` (or a specific npm script) to rebuild distributed CSS and JavaScript files, as well as our docs assets.
3. From the root `/bootstrap` directory, run `npm run docs-serve` in the command line.
4. Open `http://localhost:9001/` in your browser, and voilà.
Learn more about using Astro by reading its [documentation](https://docs.astro.build/en/getting-started/).
### Documentation for previous releases
You can find all our previous releases docs on <https://getbootstrap.com/docs/versions/>.
[Previous releases](https://github.com/twbs/bootstrap/releases) and their documentation are also available for download.
## Contributing
Please read through our [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md). Included are directions for opening issues, coding standards, and notes on development.
Moreover, if your pull request contains JavaScript patches or features, you must include [relevant unit tests](https://github.com/twbs/bootstrap/tree/main/js/tests). All HTML and CSS should conform to the [Code Guide](https://github.com/mdo/code-guide), maintained by [Mark Otto](https://github.com/mdo).
Editor preferences are available in the [editor config](https://github.com/twbs/bootstrap/blob/main/.editorconfig) for easy use in common text editors. Read more and download plugins at <https://editorconfig.org/>.
## Community
Get updates on Bootstraps development and chat with the project maintainers and community members.
- Follow [@getbootstrap on X](https://x.com/getbootstrap).
- Read and subscribe to [The Official Bootstrap Blog](https://blog.getbootstrap.com/).
- Ask questions and explore [our GitHub Discussions](https://github.com/twbs/bootstrap/discussions).
- Discuss, ask questions, and more on [the community Discord](https://discord.gg/bZUvakRU3M) or [Bootstrap subreddit](https://www.reddit.com/r/bootstrap/).
- Chat with fellow Bootstrappers in IRC. On the `irc.libera.chat` server, in the `#bootstrap` channel.
- Implementation help may be found at Stack Overflow (tagged [`bootstrap-5`](https://stackoverflow.com/questions/tagged/bootstrap-5)).
- Developers should use the keyword `bootstrap` on packages which modify or add to the functionality of Bootstrap when distributing through [npm](https://www.npmjs.com/browse/keyword/bootstrap) or similar delivery mechanisms for maximum discoverability.
## Versioning
For transparency into our release cycle and in striving to maintain backward compatibility, Bootstrap is maintained under [the Semantic Versioning guidelines](https://semver.org/). Sometimes we screw up, but we adhere to those rules whenever possible.
See [the Releases section of our GitHub project](https://github.com/twbs/bootstrap/releases) for changelogs for each release version of Bootstrap. Release announcement posts on [the official Bootstrap blog](https://blog.getbootstrap.com/) contain summaries of the most noteworthy changes made in each release.
## Creators
**Mark Otto**
- <https://x.com/mdo>
- <https://github.com/mdo>
**Jacob Thornton**
- <https://x.com/fat>
- <https://github.com/fat>
## Thanks
<a href="https://www.browserstack.com/">
<img src="https://live.browserstack.com/images/opensource/browserstack-logo.svg" alt="BrowserStack" width="192" height="42">
</a>
Thanks to [BrowserStack](https://www.browserstack.com/) for providing the infrastructure that allows us to test in real browsers!
<a href="https://www.netlify.com/">
<img src="https://www.netlify.com/v3/img/components/full-logo-light.svg" alt="Netlify" width="147" height="40">
</a>
Thanks to [Netlify](https://www.netlify.com/) for providing us with Deploy Previews!
## Sponsors
Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/bootstrap#sponsor)]
[![OC sponsor 0](https://opencollective.com/bootstrap/sponsor/0/avatar.svg)](https://opencollective.com/bootstrap/sponsor/0/website)
[![OC sponsor 1](https://opencollective.com/bootstrap/sponsor/1/avatar.svg)](https://opencollective.com/bootstrap/sponsor/1/website)
[![OC sponsor 2](https://opencollective.com/bootstrap/sponsor/2/avatar.svg)](https://opencollective.com/bootstrap/sponsor/2/website)
[![OC sponsor 3](https://opencollective.com/bootstrap/sponsor/3/avatar.svg)](https://opencollective.com/bootstrap/sponsor/3/website)
[![OC sponsor 4](https://opencollective.com/bootstrap/sponsor/4/avatar.svg)](https://opencollective.com/bootstrap/sponsor/4/website)
[![OC sponsor 5](https://opencollective.com/bootstrap/sponsor/5/avatar.svg)](https://opencollective.com/bootstrap/sponsor/5/website)
[![OC sponsor 6](https://opencollective.com/bootstrap/sponsor/6/avatar.svg)](https://opencollective.com/bootstrap/sponsor/6/website)
[![OC sponsor 7](https://opencollective.com/bootstrap/sponsor/7/avatar.svg)](https://opencollective.com/bootstrap/sponsor/7/website)
[![OC sponsor 8](https://opencollective.com/bootstrap/sponsor/8/avatar.svg)](https://opencollective.com/bootstrap/sponsor/8/website)
[![OC sponsor 9](https://opencollective.com/bootstrap/sponsor/9/avatar.svg)](https://opencollective.com/bootstrap/sponsor/9/website)
## Backers
Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/bootstrap#backer)]
[![Backers](https://opencollective.com/bootstrap/backers.svg?width=890)](https://opencollective.com/bootstrap#backers)
## Copyright and license
Code and documentation copyright 2011-2025 the [Bootstrap Authors](https://github.com/twbs/bootstrap/graphs/contributors). Code released under the [MIT License](https://github.com/twbs/bootstrap/blob/main/LICENSE). Docs released under [Creative Commons](https://creativecommons.org/licenses/by/3.0/).

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.7 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.7 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More