diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d1acac5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,40 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile +{ + "name": "Existing Dockerfile", + "build": { + // Sets the run context to one level up instead of the .devcontainer folder. + "context": "../src", + // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. + "dockerfile": "../src/Dockerfile.dev" + }, + + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "installOhMyZsh": true, + "upgradePackages": false + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Uncomment the next line to run commands after the container is created. + "postCreateCommand": "sudo apt-get update && sudo apt-get install -y sqlite3 && dotnet restore src/ && dotnet tool install csharpier --global", + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csharp", + "ms-dotnettools.csdevkit", + "csharpier.csharpier-vscode" + ] + } + }, + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" +} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5550c49..d58fd42 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,7 +42,7 @@ jobs: uses: docker/build-push-action@v4 id: build-docker-image with: - context: . + context: src/ platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore index 9de2a40..afbef65 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *obj/ *.DS_Store docker-compose.yml +cmd/dist/* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 6ea5498..4d78b66 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,30 +4,14 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - "name": "Debug .NET Core in Docker", - "type": "coreclr", - "request": "attach", - "processId": "${command:pickRemoteProcess}", - "sourceFileMap": { - "/app": "${workspaceRoot}/" - }, - "pipeTransport": { - "pipeCwd": "${workspaceRoot}", - "pipeProgram": "docker", - "pipeArgs": ["exec", "-i", "webapi"], - "quoteArgs": false, - "debuggerPath": "/vsdbg/vsdbg" - } - }, { "name": ".NET Core Launch (web)", "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/bin/Debug/net7.0/picoblog.dll", + "program": "${workspaceFolder}/src/bin/Debug/net9.0/picoblog.dll", "args": [], - "cwd": "${workspaceFolder}", + "cwd": "${workspaceFolder}/src", "stopAtEntry": false, // "serverReadyAction": { // "action": "openExternally", @@ -37,14 +21,30 @@ "ASPNETCORE_ENVIRONMENT": "Development", "SYNOLOGY_SUPPORT": "false", "SYNOLOGY_SIZE": "SM", - "DATA_DIR": "./data/posts", - "CONFIG_DIR": "./data/tmp", + "DATA_DIR": "../tests/posts", + "CONFIG_DIR": "/tmp", "DOMAIN": "localhost:8080", "PASSWORD": "", "CUSTOM_HEADER": "" }, "sourceFileMap": { - "/Views": "${workspaceFolder}/Views" + "/Views": "${workspaceFolder}/src/Views" + } + }, + { + "name": "Debug .NET Core in Docker", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickRemoteProcess}", + "sourceFileMap": { + "/app": "${workspaceRoot}/" + }, + "pipeTransport": { + "pipeCwd": "${workspaceRoot}", + "pipeProgram": "docker", + "pipeArgs": ["exec", "-i", "webapi"], + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" } }, { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 21c1ac2..8dfc680 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/picoblog.csproj", + "${workspaceFolder}/src/picoblog.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -19,7 +19,7 @@ "type": "process", "args": [ "publish", - "${workspaceFolder}/picoblog.csproj", + "${workspaceFolder}/src/picoblog.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -33,7 +33,7 @@ "watch", "run", "--project", - "${workspaceFolder}/picoblog.csproj" + "${workspaceFolder}/src/picoblog.csproj" ], "problemMatcher": "$msCompile" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e409863..c522aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.9] - 2024-11-24 +### Added + +- Simple stats page +- Added Likes button +- Added ViewCount + +### Changed + +- Bumped to .NET9 + +### Developers + +- Added devcontainers support + +## [0.0.8.1] - 2024-05-07 +### Cleanup + +- Spring cleanup of the project. + +### Fixed + +- Redirect after login did not work correctly. + ## [0.0.8] - 2023-08-05 ### Added @@ -12,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implemented global usings through `GlobalUsings.cs` for cleaner code (if applicable to your .NET version). - Parallel processing of markdown files during search - Log failed login attempts - + ### Changed - Updated the README to reflect new environment variables and features. diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs deleted file mode 100755 index cb8e417..0000000 --- a/Controllers/HomeController.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace picoblog.Controllers; -[Route("")] -public class HomeController : Controller -{ - private readonly ILogger _logger; - - public HomeController(ILogger logger, MonitorLoop monitorLoop) - { - _logger = logger; - monitorLoop.StartMonitorLoop(); - } - - [AllowAnonymous] - [Route("/login")] - public IActionResult Login() - { - if (User.Claims.Any()) - return Redirect("/"); - return View(); - } - - [AllowAnonymous] - [HttpPost] - [ValidateAntiForgeryToken] - [Route("/login")] - public async Task Login(LoginViewModel model) - { - if (!ModelState.IsValid) - return View(model); - - if (!model.Password.Equals(Config.Password)) - { - string clientIp = HttpContext.Request.Headers["Cf-Connecting-Ip"].FirstOrDefault() ?? HttpContext.Connection.RemoteIpAddress.ToString(); - _logger.LogWarning("Failed login attempt by user with IP {IP}.", clientIp); - return View(model); - } - - var claims = new List - { - new Claim(ClaimTypes.Name, "shared password user") - }; - - var claimsIdentity = new ClaimsIdentity( - claims, CookieAuthenticationDefaults.AuthenticationScheme); - var authProperties = new AuthenticationProperties() { IsPersistent = true }; - - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(claimsIdentity), - authProperties); - - if (!String.IsNullOrEmpty(model.ReturnURL) && Url.IsLocalUrl(model.ReturnURL) && IsValidReturnUrl(model.ReturnURL)) - return Redirect(model.ReturnURL); - - return RedirectToAction("/"); - } - - [Route("")] - public IActionResult Index() - { - ViewBag.Home = "class = active"; - return View(Cache.Models.Where(p => p.Visible).OrderByDescending(f => f.Date)); - } - - private bool IsValidReturnUrl(string url) - { - var safeUrls = new List { "/post", "/calendar", "/memories" }; - return safeUrls.Any(safeUrl => url.StartsWith(safeUrl, StringComparison.OrdinalIgnoreCase)); - } -} diff --git a/Controllers/PostController.cs b/Controllers/PostController.cs deleted file mode 100755 index 7af154c..0000000 --- a/Controllers/PostController.cs +++ /dev/null @@ -1,150 +0,0 @@ -namespace picoblog.Controllers; - -public class PostController : Controller -{ - private readonly ILogger _logger; - - public PostController(ILogger logger, MonitorLoop monitorLoop) - { - _logger = logger; - monitorLoop.StartMonitorLoop(); - } - - [HttpGet] - [Route("[Controller]/{year:int}/{title}/{**image}")] - [Route("[Controller]/{year:int}/{title}")] - [AllowAnonymous] - public async Task Index(Payload payload) - { - var model = Cache.Models.SingleOrDefault(f => f.Date?.Year == payload.Year && f.Title == payload.Title); - if(model == null) - { - _logger.LogWarning("No model found for payload title: {PayloadTitle}", payload.Title); - return NotFound(); - } - - if (string.IsNullOrEmpty(payload.Image)) - { - _logger.LogDebug("Payload image is null or empty. Reading from model path: {ModelPath}", model.Path); - model.Markdown = System.IO.File.ReadAllText(model.Path); - return View(model); - } - - if (model.CoverImage.Contains(payload.Image) || model.Markdown?.Contains(payload.Image) == true) - { - if(!model.CoverImage.Contains(payload.Image)) - { - if (Config.Password != null && !User.Identity.IsAuthenticated) - { - _logger.LogWarning("Unauthenticated request with Config.Password set."); - return NotFound(); - } - } - - var path = $"{Path.GetDirectoryName(model.Path)}/{payload.Image}"; - _logger.LogDebug("Calling Synology method with path: {Path}", path); - return await Synology(path); - } - else - { - _logger.LogWarning("Payload image not found in CoverImage and Markdown."); - return NotFound(); - } - } - - - private async Task Synology(string path) { - if (Config.Synology) - { - var synologyFile = Path.GetFileName(path); - var directory = Path.GetDirectoryName(path); - var synologyPath = $"@eaDir/{synologyFile}/{Config.SynologySize()}"; - synologyPath = $"{directory}/{synologyPath}"; - - if (System.IO.File.Exists(synologyPath)) { - path = synologyPath; - _logger.LogDebug("Synology file exists. Updated path to: {0}", path); - } - } - - if (!System.IO.File.Exists(path)){ - if(path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".JPG", StringComparison.OrdinalIgnoreCase)){ - path = ToggleCaseExtension(path); - if (!System.IO.File.Exists(path)){ - _logger.LogWarning("File does not exist after toggling case of extension: {0}", path); - return NotFound(); - } - } else { - _logger.LogWarning("File does not exist after toggling case of extension: {0}", path); - return NotFound(); - } - } - - HttpContext.Response.Headers.Add("ETag", ComputeMD5(path)); - HttpContext.Response.Headers.Add("Cache-Control", "private, max-age=12000"); - await HttpContext.Response.Body.WriteAsync(await resize(path)); - return new EmptyResult(); - } - - private string ToggleCaseExtension(string path) - { - string ext = System.IO.Path.GetExtension(path); - string oppositeCaseExt = ext.Equals(ext.ToLower()) ? ext.ToUpper() : ext.ToLower(); - _logger.LogDebug("Toggled case of extension. New path: {0}\nOld path: {1}", oppositeCaseExt, path); - return System.IO.Path.ChangeExtension(path, oppositeCaseExt); - } - - private string ComputeMD5(string s) - { - using (System.Security.Cryptography.MD5 md5 = System.Security.Cryptography.MD5.Create()) - { - return BitConverter.ToString(md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))) - .Replace("-", ""); - } - } - - private async Task resize(string path) { - _logger.LogDebug("Resize method started for path: {0}", path); - try{ - var fileName = $"{Config.ConfigDir}/images{path}"; - if (System.IO.File.Exists(fileName)) { - using (var SourceStream = System.IO.File.Open(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - { - var result = new byte[SourceStream.Length]; - await SourceStream.ReadAsync(result, 0, (int)SourceStream.Length); - return result; - } - } - - using (var outputStream = new MemoryStream()) - { - using (var image = await Image.LoadAsync(path)) - { - int width = image.Width / 2; - int height = image.Height / 2; - width = 0; - height = 0; - if(image.Height > image.Width && height > Config.ImageMaxSize) - height = Config.ImageMaxSize; - if (image.Width > image.Height && width > Config.ImageMaxSize) - width = Config.ImageMaxSize; - - if (width + height != 0) - image.Mutate(x => x.Resize(width, height)); - JpegEncoder encoder = new JpegEncoder(); - encoder.Quality = Config.ImageQuality; - await image.SaveAsJpegAsync(outputStream, encoder); - } - outputStream.Position = 0; - Directory.CreateDirectory(Path.GetDirectoryName(fileName)); - using var destination = System.IO.File.Create(fileName, bufferSize: 4096); - await outputStream.CopyToAsync(destination); - - return outputStream.ToArray(); - } - } catch(Exception e){ - _logger.LogError(e, "Error Reading File: {0}", path); - throw; - } - } -} diff --git a/Models/Config.cs b/Models/Config.cs deleted file mode 100644 index 0b9d2c8..0000000 --- a/Models/Config.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace picoblog.Models; - -public static class Config{ - private static double _cachetime; - private static int maxheight; - private static int imagequality; - public static double CacheTimeInMinutes => double.TryParse(Environment.GetEnvironmentVariable("IMAGE_CACHE_MINUTES"), out _cachetime) ? _cachetime : 4.0; - public static int ImageMaxSize => int.TryParse(Environment.GetEnvironmentVariable("IMAGE_MAX_SIZE"), out maxheight) ? maxheight : 1280; - public static int ImageQuality => int.TryParse(Environment.GetEnvironmentVariable("IMAGE_QUALITY"), out imagequality) ? imagequality : 65; - public static string Title => Environment.GetEnvironmentVariable("TITLE") ?? "Picoblog"; - public static string Description => Environment.GetEnvironmentVariable("DESCRIPTION") ?? "A simple zero fraction blogging platform"; - public static string CustomHeader => Environment.GetEnvironmentVariable("CUSTOM_HEADER"); - public static string ConfigDir => Environment.GetEnvironmentVariable("CONFIG_DIR") ?? "/config"; - public static bool Synology => Environment.GetEnvironmentVariable("SYNOLOGY_SUPPORT") == "true"; - public static string? Password => string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PASSWORD")) ? null : Environment.GetEnvironmentVariable("PASSWORD"); - public static string DataDir => Environment.GetEnvironmentVariable("DATA_DIR") ?? "/data"; - public static string Domain => string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOMAIN")) ? "http://localhost:8080" : "https://" + Environment.GetEnvironmentVariable("DOMAIN"); - public static bool EnableBackup => - Environment.GetEnvironmentVariable("PICOBLOG_ENABLE_BACKUP")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; - - public static string SynologySize() { - var sizeEnv = Environment.GetEnvironmentVariable("SYNOLOGY_SIZE"); - var defaultSize = "XL"; - var allowedSizes = new string[] {"SM", "M", "XL"}; - if(allowedSizes.Any(p => p.Equals(sizeEnv.ToUpper()))) - return $"SYNOPHOTO_THUMB_{sizeEnv.ToUpper()}.jpg"; - - Console.WriteLine($"WRONG SYNOLOGY_SIZE SELECTED {sizeEnv}. DEFAULTING TO {defaultSize}"); - return $"SYNOPHOTO_THUMB_{defaultSize}.jpg"; - } -} diff --git a/Models/MarkdownModel.cs b/Models/MarkdownModel.cs deleted file mode 100755 index c4dd63f..0000000 --- a/Models/MarkdownModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace picoblog.Models; - -public class MarkdownModel -{ - private string? _markdown; - - public string? Markdown { get => _markdown; set { - _markdown = value; - var match = Regex.Match(_markdown, @"^---\r?\n(.*?)\r?\n---", RegexOptions.Singleline); - - if (match.Success && match.Groups.Count > 1) - { - var frontmatter = match.Groups[1].Value.Split(System.Environment.NewLine); - CoverImage = frontmatter.SingleOrDefault(p => p.StartsWith(MetadataHeader.CoverImage))?.Split(':')[1].Trim(); - } - } - } - public string Title { get; internal set; } - public bool Public { get; internal set; } = false; - public string Path { get; internal set; } - public DateTime? Date { get; internal set; } - public string? CoverImage { get; internal set; } - public bool Visible { get; internal set; } = true; - public string? Description { get; internal set; } -} diff --git a/Program.cs b/Program.cs deleted file mode 100755 index 9fa0c0e..0000000 --- a/Program.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Microsoft.AspNetCore.HttpLogging; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpLogging(logging => -{ - logging.LoggingFields = HttpLoggingFields.RequestMethod | - HttpLoggingFields.RequestPath | - HttpLoggingFields.Duration | - HttpLoggingFields.ResponseStatusCode; - logging.CombineLogs = true; -}); - -builder.Services.Configure(options => options.LowercaseUrls = true); -builder.WebHost.UseKestrel(option => option.AddServerHeader = false); -builder.Services.AddHealthChecks(); - -builder.Services.AddHostedService(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); -builder.Services.AddSingleton(ctx => -{ - return new BackgroundTaskQueue(1); -}); - -// builder.Services.AddImageSharp(options => { -// options.Configuration = Configuration.Default; -// options.MemoryStreamManager = new RecyclableMemoryStreamManager(); -// options.BrowserMaxAge = TimeSpan.FromDays(7); -// options.CacheMaxAge = TimeSpan.FromDays(365); -// options.CacheHashLength = 8; -// }); - -if (Config.Password != null) -{ - builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Cookie.HttpOnly = true; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.Cookie.SameSite = SameSiteMode.Strict; - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; - options.ExpireTimeSpan = TimeSpan.FromDays(30); - options.SlidingExpiration = true; - options.Cookie.Name = "Picoblog.AuthCookie"; - options.LoginPath = "/login"; - }); - builder.Services.AddControllersWithViews(options => - { - options.Filters.Add(new AuthorizeFilter()); - options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); - }); - builder.Services.AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(Config.ConfigDir)); -} -else - builder.Services.AddControllersWithViews(options => - { - options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); - }); - -// builder.Services.AddWebOptimizer(pipeline => -// { -// pipeline.MinifyJsFiles("**/*.js"); -// pipeline.MinifyCssFiles("css/**/*.css"); -// }); - -var app = builder.Build(); -app.UseHttpLogging(); -// app.UseImageSharp(); - -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto -}); - -if (Config.Password != null) -{ - app.UseCookiePolicy(); - app.UseAuthentication(); - app.UseAuthorization(); -} - -//app.UseWebOptimizer(); -app.UseRequestLocalization(new RequestLocalizationOptions { ApplyCurrentCultureToResponseHeaders = true }); -app.UseStaticFiles(); -app.MapHealthChecks("/healthz"); -app.UseRouting(); - -var supportedCultures = new[] -{ - new CultureInfo("nb-NO"), - new CultureInfo("en-GB"), -}; - -app.UseRequestLocalization(new RequestLocalizationOptions -{ - DefaultRequestCulture = new RequestCulture("nb-NO"), - SupportedCultures = supportedCultures, - SupportedUICultures = supportedCultures -}); - -app.MapControllerRoute( - name: "default", - pattern: "{controller=Home}/{action=Index}/{id?}"); - -using (var serviceScope = app.Services.CreateScope()) -{ - var services = serviceScope.ServiceProvider; - var monitorLoop = services.GetRequiredService(); - monitorLoop.StartMonitorLoop(); -} - -app.Use(async (context, next) => -{ - try - { - await next.Invoke(); - - // If status code is not 200, log it - if (context.Response.StatusCode != 200) - { - var ip = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - Console.WriteLine($"[RemoteIP::{ip}]:[StatusCode::{context.Response.StatusCode}]:{context.Request.Path}"); - } - } - catch (Exception ex) - { - Console.WriteLine(ex); - // Re-throw the exception so it can be handled by other middleware - throw; - } -}); - -var logger = app.Services.GetRequiredService>(); - -logger.LogTrace("Trace level log"); -logger.LogDebug("Debug level log"); -logger.LogInformation("Information level log"); -logger.LogWarning("Warning level log"); -logger.LogError("Error level log"); -logger.LogCritical("Critical level log"); - -app.Run(); diff --git a/README.md b/README.md index 8249400..16175fd 100644 --- a/README.md +++ b/README.md @@ -167,4 +167,4 @@ Some important work remains - [ ] Tags -- [x] ~~Virtual directory with assets imported from `*.md` files as a security measurement.~~ +- [x] ~~Virtual directory with assets imported from `*.md` files as a security measurement.~~ \ No newline at end of file diff --git a/Services/MonitorLoop.cs b/Services/MonitorLoop.cs deleted file mode 100644 index abf429e..0000000 --- a/Services/MonitorLoop.cs +++ /dev/null @@ -1,132 +0,0 @@ -public class MonitorLoop -{ - private readonly IBackgroundTaskQueue _taskQueue; - private readonly ILogger _logger; - private readonly CancellationToken _cancellationToken; - private DateTime _lastSync = DateTime.MinValue; - - public MonitorLoop(IBackgroundTaskQueue taskQueue, - ILogger logger, - IHostApplicationLifetime applicationLifetime) - { - _taskQueue = taskQueue; - _logger = logger; - _cancellationToken = applicationLifetime.ApplicationStopping; - } - - public void StartMonitorLoop() - { - // Run a console user input loop in a background thread - Task.Run(async () => await MonitorAsync()); - } - - private async ValueTask MonitorAsync() - { - // Enqueue a background work item - await _taskQueue.QueueBackgroundWorkItemAsync(BuildWorkItem); - } - - private async ValueTask BuildWorkItem(CancellationToken token) - { - var guid = Guid.NewGuid().ToString(); - - - try - { - if (_lastSync == DateTime.MinValue || _lastSync <= DateTime.Now.AddMinutes(-5)) - { - _logger.LogInformation("Queued Background Task {Guid} is starting.", guid); - FindFiles(); - } - _lastSync = DateTime.Now; - } - catch (OperationCanceledException) - { - // Prevent throwing if the Delay is cancelled - } - } - - private void FindFiles() - { - _logger.LogInformation("Starting searching for markdown files (*.md)"); - var files = Directory.EnumerateFiles(Config.DataDir, "*.md", SearchOption.AllDirectories); - var concurrentModels = new ConcurrentBag(); - - Parallel.ForEach(files, file => - { - string content = File.ReadAllText(file); - Match match = Regex.Match(content, @"^---\n(.*?)\n---", RegexOptions.Singleline); - - if (match.Success) - ProcessFrontMatter(match.Groups[1].Value, file, concurrentModels); - }); - - var models = concurrentModels.ToList(); - ProcessResults(models); - Cache.Models = models; - } - - private void ProcessFrontMatter(string frontmatter, string file, ConcurrentBag models) - { - var model = new MarkdownModel(); - foreach (var line in frontmatter.Split('\n')) - { - string[] parts = line.Split(':', 2); - if (parts.Length < 2) continue; - - string key = parts[0].Trim(); - string value = parts[1].Trim(); - - model.Path = file; - if (key.Equals(MetadataHeader.Public, StringComparison.InvariantCultureIgnoreCase)) model.Public = value.Equals("true", StringComparison.InvariantCultureIgnoreCase); - else if (key.Equals(MetadataHeader.Title, StringComparison.InvariantCultureIgnoreCase)) model.Title = value; - else if (key.Equals(MetadataHeader.Date, StringComparison.InvariantCultureIgnoreCase)) model.Date = DateTime.Parse(value); - else if (key.Equals(MetadataHeader.Draft, StringComparison.InvariantCultureIgnoreCase)) model.Visible = value.ToLower() != "true"; - else if (key.Equals(MetadataHeader.CoverImage, StringComparison.InvariantCultureIgnoreCase)) model.CoverImage = value; - else if (key.Equals(MetadataHeader.Description, StringComparison.InvariantCultureIgnoreCase)) model.Description = value; - } - _logger.LogInformation("FOUND: {Title} - Path: {Path} - URL: {Url}", model.Title, model.Path, $"{Config.Domain}/post/{model.Title}"); - models.Add(model); - } - - private void ProcessResults(IList models) - { - if (models.Any(p => p.Visible == false)) - { - var hiddenPosts = models.Where(p => p.Visible == false); - _logger.LogInformation($"FOUND {hiddenPosts.Count()} HIDDEN POSTS"); - foreach (var model in hiddenPosts) - _logger.LogInformation($"HIDDEN POST: Title: {model.Title} - {Config.Domain}/post/{model.Date?.Year}/{model.Title}"); - } - - if (models.Any(p => string.IsNullOrEmpty(p.Title))) - { - var postsWithoutTitles = models.Where(p => string.IsNullOrEmpty(p.Title)); - _logger.LogInformation($"FOUND {postsWithoutTitles.Count()} POSTS WITHOUT TITLES"); - - foreach (var model in postsWithoutTitles) - _logger.LogInformation($"POST WITHOUT TITLE: {model.Path}"); - - models = models.Where(p => !string.IsNullOrEmpty(p.Title)).ToList(); - } - - var duplicates = models.GroupBy(p => p.Title).Where(g => g.Count() >= 2).Select(p => p.Key); - if (duplicates.Any()){ - _logger.LogInformation("FOUND DUPLICATES, REMOVED FROM SET"); - foreach (var title in duplicates) - { - var dups = models.Where(p => p.Title == title); - foreach(var dup in dups) - _logger.LogInformation("Duplicate found: Title: {Title}, Path: {Path}", dup.Title, dup.Path); - } - models = models.Where(p => !duplicates.Contains(p.Title)).ToList(); - } - var deleted = Cache.Models.Where(p => !models.Any(n => n.Path == p.Path)); - if (deleted.Any()) - { - _logger.LogInformation("FOUND DELETED FILES"); - foreach (var del in deleted) - _logger.LogInformation("DELETED FILE: Title: {Title}, Path: {Path}", del.Title, del.Path); - } - } -} diff --git a/Views/Post/index.cshtml b/Views/Post/index.cshtml deleted file mode 100755 index 2ef86c2..0000000 --- a/Views/Post/index.cshtml +++ /dev/null @@ -1,162 +0,0 @@ -@using Markdig; - - -@{ - ViewData["Title"] = Model.Title; - ViewData["Url"] = $"{Config.Domain}/post/{Model.Date?.Year}/{Uri.EscapeDataString(Model.Title)}"; - ViewData["Image"] = $"{Config.Domain}/post/{Model.Date?.Year}/{Uri.EscapeDataString(Model.Title)}/{Model.CoverImage}"; -} - -@if(!string.IsNullOrEmpty(Model.CoverImage)){ -
- Banner Image - @*
-

@Model.Title

-
*@ -
-} - - - - - -
-
-

@Model.Title

-

@Model.Date?.ToString("MMM dd, yyyy")

-
- - - - @if (Config.Password != null && !User.Identity.IsAuthenticated) - { -
-

Members only

-

You have to login to view the content of this page.

- -
- return; - } - -
- @{ - var pipeline = new MarkdownPipelineBuilder().UseYamlFrontMatter().UseAdvancedExtensions().Build(); - var html = Markdown.ToHtml(Model.Markdown, pipeline); - html = html.Replace("

- -

- - - -
- @Html.Partial("_ImageViewer") -
- - diff --git a/Views/Post/index.cshtml.css b/Views/Post/index.cshtml.css deleted file mode 100644 index fe6314b..0000000 --- a/Views/Post/index.cshtml.css +++ /dev/null @@ -1,123 +0,0 @@ -main { - margin-top: 0 !important; -} - -.rendered-markdown { - margin: 0 auto; /* centers the container */ - padding: 1em; /* provides some space around the content */ - max-width: 50em; /* limits the width of the container */ - font-size: 16px; /* defines the base font size */ -} - -a{ - cursor: pointer; -} - -.no-scroll { - overflow: hidden; /* disables scrolling */ - height: 100%; /* keeps the layout stable */ -} - -.top-left { - position: fixed; - color: white; - top: 1em; - left: 0; - padding: 6vh 3vh 3vh 3vh; - border: 0; - background: none; -} - -.hidden{ - display: none; -} - -.top-right { - position: fixed; - color: white; - top: 1em; - right: 0; - padding: 6vh 3vh 3vh 3vh; - border: 0; - background: none; -} - -.date h2 { - margin-bottom: 0px; -} -.date p { - color: #c2c2c2; - font-size: 0.8em; -} - -@media all and (display-mode: browser) { - #shareButton { - display:none; - } - - .content { - padding-top: 40vw; - z-index: 100; - } - .top-left { - display: none; - } -} -/* display-mode:standalone */ -@media all and (display-mode: standalone) { - menu{ - display: none !important; - } - - .content { - z-index: 100; - padding-top: 40vw; - background-color: white; - border-radius: 19px 19px 0px 0px; - position: absolute; - left: 0; - padding: 5vw 5vw 18vw 5vw; - top: 53vh; - } - - .hero { - height: 120vw; - width: 100vw; - /* background: rgba(0, 0, 0, 0.27); */ - overflow: hidden; - position: fixed; - left: 0; - right: 0; - top: 0; - bottom: 0; - z-index: -1; - } - - .hero img { - object-fit: cover; - /* opacity: 0.9; */ - bottom: 0; - top: 0; - position: absolute; - height: 100%; - width: 100vw; - } - - #shareButton { - top: -5vw !important; - right: 10vw !important; - } - - .circle { - border: 0; - position: absolute; - color: rgb(103, 103, 103); - z-index: 3; - padding: 12px; - border-radius: 50%; - background-color: white !important; - box-shadow: 0 0 39px 1px rgb(236, 236, 236); - width: 3em; - height: 3em; - } -} diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 0000000..3ad138e --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,198 @@ +# 🚀 PicoBlog CLI + +A command-line tool for managing markdown blog posts in a structured directory format. Written in Go, compiled as a native executable that runs on macOS and Linux. + +## ⚙️ Prerequisites + +For building: +- Go 1.21 or later + +For running: +- No prerequisites! The binary is statically linked + +## 🏗️ Building from Source + +### Building for your current platform + +```bash +# Clone the repository +git clone https://github.com/jonasbg/picoblog.git +cd picoblog + +# Download dependencies +go mod download + +# Build the project +go build +``` + +### Building for specific platforms + +Use the provided build script: + +```bash +# Make the build script executable +chmod +x build.sh + +# Build for all platforms +./build.sh +``` + +This will create binaries for: +- macOS ARM64 (M1/M2/M3) +- macOS AMD64 (Intel) +- Linux ARM64 +- Linux AMD64 + +The compiled binaries will be in `dist/[os]-[arch]/picoblog` + +## 📦 Installation + +1. Copy the appropriate binary to your desired location: +```bash +# For macOS M1/M2/M3 +sudo cp dist/darwin-arm64/picoblog /usr/local/bin/ + +# For macOS Intel +sudo cp dist/darwin-amd64/picoblog /usr/local/bin/ + +# For Linux (choose appropriate architecture) +sudo cp dist/linux-amd64/picoblog /usr/local/bin/ +``` + +2. Make it executable (if needed): +```bash +sudo chmod +x /usr/local/bin/picoblog +``` + +3. Set the required environment variable: +```bash +# Add to your ~/.bashrc or ~/.zshrc +export PICOBLOG_BASE_DIR="/path/to/your/blog/directory" +``` + +## 📝 Usage + +### Creating a New Blog Post + +```bash +# Create a post with today's date +picoblog new "My Amazing Blog Post" + +# Create a post with a specific date +picoblog new "My Amazing Blog Post" --date "2024-01-20" + +# Create a private draft post +picoblog new "Draft Post" --public=false --draft=true +``` + +### Opening Existing Posts + +```bash +# Open post by date +picoblog open "2024-01-20" + +# Alternative date formats +picoblog open "Jan. 20, 2024" +picoblog open "January 20, 2024" +picoblog open "2024/01/20" +picoblog open "2024.01.20" +``` + +## 📄 Blog Post Format + +When you create a new post, it will generate a markdown file with this structure: + +```markdown +--- +title: My Amazing Blog Post +date: 2024-01-20 +cover: +weather: +public: true +draft: false +--- +``` + +The files are organized in a year/month/day directory structure: +``` +PICOBLOG_BASE_DIR/ +└── 2024/ + └── 01/ + └── 20/ + └── My-Amazing-Blog-Post.md +``` + +## 🚀 Command Options + +### Global Options + +``` +--help Show command help and exit +--version Show version information +``` + +### `new` Command + +``` +Arguments: + title Title of the blog post + +Options: + --date, -d Date for the post (YYYY-MM-DD or MMM. DD, YYYY) + --public Set post visibility [default: true] + --draft Set post as draft [default: false] + -h, --help Show help for the new command +``` + +### `open` Command + +``` +Arguments: + date Date of the blog post (YYYY-MM-DD or MMM. DD, YYYY) + +Options: + -h, --help Show help for the open command +``` + +## ⚡️ Features + +- ✅ Native binary - no runtime dependencies +- ✅ Structured directory organization +- ✅ Markdown file generation with front matter +- ✅ Support for public/private and draft posts +- ✅ Automatic file opening (uses `open` on macOS, `xdg-open` on Linux) +- ✅ Multiple date format support +- ✅ Timestamp preservation +- ✅ Cross-platform support (macOS ARM/Intel, Linux ARM/AMD) + +## 🔧 Development + +To generate development builds: + +```bash +go build +``` + +To run tests (if added): + +```bash +go test ./... +``` + +To format code: + +```bash +go fmt ./... +``` + +## ❌ Uninstallation + +```bash +# If installed in /usr/local/bin +sudo rm /usr/local/bin/picoblog +``` + +## 📝 License + +This project is open source and available under the MIT License. \ No newline at end of file diff --git a/cmd/build.sh b/cmd/build.sh new file mode 100755 index 0000000..b7ed725 --- /dev/null +++ b/cmd/build.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Exit on any error +set -e + +# Get the version from git tag if available, otherwise use "dev" +VERSION=$(git describe --tags 2>/dev/null || echo "dev") + +# Common build flags +BUILD_FLAGS="-buildvcs=false -ldflags \"-X main.Version=${VERSION} -s -w\"" + +# Clean previous builds +rm -rf dist/ +mkdir -p dist + +# Build function +build() { + local os=$1 + local arch=$2 + local output_dir="dist/${os}-${arch}" + local binary_name="picoblog" + + # Add .exe extension for Windows + if [ "$os" = "windows" ]; then + binary_name="picoblog.exe" + fi + + echo "Building picoblog ${VERSION} for ${os}/${arch}..." + mkdir -p "${output_dir}" + + GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ + go build -buildvcs=false \ + -ldflags "-X main.Version=${VERSION} -s -w" \ + -o "${output_dir}/${binary_name}" + + chmod +x "${output_dir}/${binary_name}" +} + +# Build for all targets +build "darwin" "arm64" # macOS ARM (M1/M2) +build "darwin" "amd64" # macOS Intel +build "linux" "arm64" # Linux ARM64 +build "linux" "amd64" # Linux AMD64 + +# Create archives for each build +echo "Creating archives..." +cd dist + +for dir in *; do + if [ -d "$dir" ]; then + tar -czf "${dir}.tar.gz" "${dir}" + echo "Created ${dir}.tar.gz" + fi +done + +cd .. + +# Print results +echo "" +echo "✨ Build complete! Binaries are in dist/ directory:" +ls -l dist/ + +echo "" +echo "Archives created:" +ls -l dist/*.tar.gz + +echo "" +echo "To install on macOS ARM (M1/M2), use:" +echo "sudo cp dist/darwin-arm64/picoblog /usr/local/bin/" + +echo "" +echo "To install on macOS Intel, use:" +echo "sudo cp dist/darwin-amd64/picoblog /usr/local/bin/" + +echo "" +echo "To install on Linux, use:" +echo "sudo cp dist/linux-/picoblog /usr/local/bin/" +echo "Where is amd64 or arm64 depending on your system" \ No newline at end of file diff --git a/cmd/go.mod b/cmd/go.mod new file mode 100644 index 0000000..5ab8f7a --- /dev/null +++ b/cmd/go.mod @@ -0,0 +1,10 @@ +module picoblog + +go 1.23 + +require github.com/spf13/cobra v1.8.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/cmd/go.sum b/cmd/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/cmd/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..e7a6719 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/spf13/cobra" +) + +var ( + baseDir string + dateStr string + public bool + draft bool +) + +func init() { + baseDir = os.Getenv("PICOBLOG_BASE_DIR") + if baseDir == "" { + log.Fatal("PICOBLOG_BASE_DIR environment variable not set") + } + + newCmd.Flags().StringVarP(&dateStr, "date", "d", "", "Post date (YYYY-MM-DD)") + newCmd.Flags().BoolVar(&public, "public", true, "Set post as public") + newCmd.Flags().BoolVar(&draft, "draft", false, "Set post as draft") + + openCmd.Flags().StringVarP(&dateStr, "date", "d", "", "Post date (YYYY-MM-DD)") + + rootCmd.AddCommand(newCmd, openCmd) +} + +func openWithDefaultApp(path string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", path) + case "windows": + cmd = exec.Command("cmd", "/c", "start", path) + case "linux": + cmd = exec.Command("xdg-open", path) + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func createMarkdown(file string, title string, date time.Time, public bool, draft bool) error { + publicText := "true" + if !public { + publicText = "false" + } + draftText := "true" + if !draft { + draftText = "false" + } + + content := fmt.Sprintf(`--- +title: %s +date: %s +cover: +weather: +public: %s +draft: %s +---`, title, date.Format("2006-01-02"), publicText, draftText) + + return os.WriteFile(file, []byte(content), 0644) +} + +func createPost(title string, date time.Time, public, draft bool) error { + dirPath := filepath.Join(baseDir, date.Format("2006/01/02")) + if err := os.MkdirAll(dirPath, 0755); err != nil { + return fmt.Errorf("failed to create directories: %w", err) + } + + filePath := filepath.Join(dirPath, title+".md") + + if _, err := os.Stat(filePath); err == nil { + return fmt.Errorf("post already exists: %s", filePath) + } + + if err := createMarkdown(filePath, title, date, public, draft); err != nil { + return fmt.Errorf("failed to create markdown file: %w", err) + } + + return openWithDefaultApp(filePath) +} + +func findPost(title string, date time.Time) (string, error) { + dirPath := filepath.Join(baseDir, date.Format("2006/01/02")) + filePath := filepath.Join(dirPath, title+".md") + + if _, err := os.Stat(filePath); err != nil { + return "", fmt.Errorf("post not found: %s", filePath) + } + + return filePath, nil +} + +func getDate() (time.Time, error) { + if dateStr == "" { + return time.Now(), nil + } + return time.Parse("2006-01-02", dateStr) +} + +var rootCmd = &cobra.Command{ + Use: "picoblog", + Short: "A simple blog post manager", +} + +var newCmd = &cobra.Command{ + Use: "new [title]", + Short: "Create a new blog post", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + title := args[0] + + date, err := getDate() + if err != nil { + return fmt.Errorf("invalid date format: %w", err) + } + + return createPost(title, date, public, draft) + }, +} + +var openCmd = &cobra.Command{ + Use: "open [title]", + Short: "Open an existing blog post", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + title := args[0] + + date, err := getDate() + if err != nil { + return fmt.Errorf("invalid date format: %w", err) + } + + filePath, err := findPost(title, date) + if err != nil { + return err + } + + return openWithDefaultApp(filePath) + }, +} + +func main() { + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 4eecba4..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: "3" -services: - webapi: - container_name: webapi - build: - context: ./ - dockerfile: ./Dockerfile.dev - volumes: - - ./:/app - ports: - - 8080:8080 diff --git a/.dockerignore b/src/.dockerignore similarity index 100% rename from .dockerignore rename to src/.dockerignore diff --git a/.env b/src/.env similarity index 100% rename from .env rename to src/.env diff --git a/Controllers/CalendarController.cs b/src/Controllers/CalendarController.cs similarity index 79% rename from Controllers/CalendarController.cs rename to src/Controllers/CalendarController.cs index 707ee55..dbe1217 100755 --- a/Controllers/CalendarController.cs +++ b/src/Controllers/CalendarController.cs @@ -14,7 +14,7 @@ public CalendarController(ILogger logger) public async Task All() { ViewBag.Calendar = "class = active"; - var models = Cache.Models.Where(p => p.Visible).Where(p => p.Date != null).OrderBy(p => p.Date).ToList(); + var models = Cache.Models.Where(p => p.Date != null).OrderBy(p => p.Date).ToList(); return View(models); } @@ -23,7 +23,7 @@ public async Task All() public async Task Year(int? year) { ViewBag.Calendar = "class = active"; - var models = Cache.Models.Where(p => p.Visible).Where(f => f.Date?.Year == year).OrderBy(p => p.Date).ToList(); + var models = Cache.Models.Where(f => f.Date?.Year == year).OrderBy(p => p.Date).ToList(); var dictionary = new Dictionary>(); var months = models.Where(p => p.Date != null).Select(p => p.Date?.Month).Distinct().ToArray(); foreach(int month in months) @@ -36,7 +36,7 @@ public async Task Year(int? year) public async Task Index() { ViewBag.Calendar = "class = active"; - var models = Cache.Models.Where(p => p.Visible); + var models = Cache.Models; var years = models.Select(p => p.Date?.Year).Distinct().OrderByDescending(p => p).ToList(); return View(years); } diff --git a/src/Controllers/HomeController.cs b/src/Controllers/HomeController.cs new file mode 100755 index 0000000..2b7511e --- /dev/null +++ b/src/Controllers/HomeController.cs @@ -0,0 +1,81 @@ +namespace picoblog.Controllers; + +[Route("")] +public class HomeController : Controller +{ + private readonly ILogger _logger; + + public HomeController(ILogger logger) + { + _logger = logger; + } + + [AllowAnonymous] + [Route("/login")] + public IActionResult Login(string returnUrl = null) + { + if (User.Claims.Any()) + return Redirect("/"); + return View(new LoginViewModel { ReturnURL = returnUrl }); + } + + [AllowAnonymous] + [HttpPost] + [ValidateAntiForgeryToken] + [Route("/login")] + public async Task Login(LoginViewModel model) + { + if (!ModelState.IsValid) + return View(model); + + if (!model.Password.Equals(Config.Password)) + { + string clientIp = + HttpContext.Request.Headers["Cf-Connecting-Ip"].FirstOrDefault() + ?? HttpContext.Connection.RemoteIpAddress.ToString(); + _logger.LogWarning("Failed login attempt by user with IP {IP}.", clientIp); + return View(model); + } + + var claims = new List { new Claim(ClaimTypes.Name, "shared password user") }; + + var claimsIdentity = new ClaimsIdentity( + claims, + CookieAuthenticationDefaults.AuthenticationScheme + ); + var authProperties = new AuthenticationProperties() { IsPersistent = true }; + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + authProperties + ); + + // model.ReturnURL = HttpContext.Request.Query["returnUrl"].ToString() ?? "/"; + //model.ReturnURL is null why? + + return RedirectToLocal(model.ReturnURL ?? "/"); + } + + private IActionResult RedirectToLocal(string returnUrl) + { + if (Url.IsLocalUrl(returnUrl) && !string.IsNullOrEmpty(returnUrl)) + { + return Redirect(returnUrl); + } + return Redirect("/"); + } + + [Route("")] + public IActionResult Index() + { + ViewBag.Home = "class = active"; + return View(Cache.Models.OrderByDescending(f => f.Date)); + } + + private bool IsValidReturnUrl(string url) + { + var safeUrls = new List { "/post", "/calendar", "/memories" }; + return safeUrls.Any(safeUrl => url.StartsWith(safeUrl, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Controllers/MemoriesController.cs b/src/Controllers/MemoriesController.cs similarity index 100% rename from Controllers/MemoriesController.cs rename to src/Controllers/MemoriesController.cs diff --git a/src/Controllers/PostController.cs b/src/Controllers/PostController.cs new file mode 100755 index 0000000..47a0e48 --- /dev/null +++ b/src/Controllers/PostController.cs @@ -0,0 +1,256 @@ +namespace picoblog.Controllers; + +public class PostController : Controller +{ + private readonly ILogger _logger; + private readonly VisitTracker _visitTracker; + + public PostController(ILogger logger, VisitTracker visitTracker) + { + _logger = logger; + _visitTracker = visitTracker; + } + + [HttpGet] + [Route("[Controller]/{year:int}/{title}/{**image}")] + [Route("[Controller]/{year:int}/{title}")] + [AllowAnonymous] + public async Task Index(Payload payload) + { + var model = Cache.Models.SingleOrDefault(f => + f.Date?.Year == payload.Year && f.Title == payload.Title + ); + if (model == null) + { + model = Cache.PrivatePosts.SingleOrDefault(f => + f.Date?.Year == payload.Year && f.Title == payload.Title + ); + if (model == null) + { + _logger.LogWarning( + "No model found for payload title: {PayloadTitle}", + payload.Title + ); + return NotFound(); + } + } + + if (string.IsNullOrEmpty(payload.Image)) + { + _logger.LogDebug( + "Payload image is null or empty. Reading from model path: {ModelPath}", + model.Path + ); + + try + { + model.Markdown = System.IO.File.ReadAllText(model.Path); + if (model.Draft) + return RedirectBack(model); + else + return View(model); + } + catch (FileNotFoundException) + { + _logger.LogWarning( + "File not found at path: {ModelPath}. Removing from cache.", + model.Path + ); + return RedirectBack(model); + } + } + + if ( + model.CoverImage.Contains(payload.Image) + || model.Markdown?.Contains(payload.Image) == true + ) + { + if (!model.CoverImage.Contains(payload.Image)) + { + if (Config.Password != null && !User.Identity.IsAuthenticated) + { + _logger.LogWarning("Unauthenticated request with Config.Password set."); + return Unauthorized(); + } + } + + var path = $"{Path.GetDirectoryName(model.Path)}/{payload.Image}"; + _logger.LogDebug("Calling Synology method with path: {FilePath}", path); + return await Synology(path); + } + else + { + _logger.LogWarning("Payload image not found in CoverImage and Markdown."); + return NotFound(); + } + } + + private IActionResult RedirectBack(MarkdownModel model) + { + if (Cache.Models.Contains(model)) + Cache.Models = Cache.Models.Where(m => m.Path != model.Path).ToList(); + else if (Cache.PrivatePosts.Contains(model)) + Cache.PrivatePosts = Cache.PrivatePosts.Where(m => m.Path != model.Path).ToList(); + + // Redirect to previous page if available, otherwise to home + var previousUrl = Request.Headers.Referer.ToString(); + if (!string.IsNullOrEmpty(previousUrl)) + return Redirect(previousUrl); + + return RedirectToAction("Index", "Home"); // Default fallback route + } + + [HttpPost] + [IgnoreAntiforgeryToken] + [Route("[Controller]/{year:int}/{title}/like")] + public async Task Like(int year, string title, [FromBody] LikeAction action) + { + bool success = false; + if (action.Action == "increment") + { + success = await _visitTracker.AddLikeAsync(title, year); + } + else if (action.Action == "decrement") + { + success = await _visitTracker.RemoveLikeAsync(title, year); + } + if (success) + { + var model = Cache.Models.SingleOrDefault(f => f.Date?.Year == year && f.Title == title); + if (model == null) + { + model = Cache.PrivatePosts.SingleOrDefault(f => + f.Date?.Year == year && f.Title == title + ); + if (model == null) + { + _logger.LogWarning("No model found for title: {Title}", title); + return NotFound(); + } + } + return Json(new { success = true, likes = model?.LikeCount ?? 0 }); + } + return Json(new { success = false }); + } + + public class LikeAction + { + public string Action { get; set; } + } + + private async Task Synology(string path) + { + if (Config.Synology) + { + var synologyFile = Path.GetFileName(path); + var directory = Path.GetDirectoryName(path); + var synologyPath = $"@eaDir/{synologyFile}/{Config.SynologySize()}"; + synologyPath = $"{directory}/{synologyPath}"; + + if (System.IO.File.Exists(synologyPath)) + { + path = synologyPath; + _logger.LogDebug("Synology file exists. Updated path to: {FilePath}", path); + } + } + + if (!System.IO.File.Exists(path)) + { + if ( + path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".JPG", StringComparison.OrdinalIgnoreCase) + ) + { + path = ToggleCaseExtension(path); + if (!System.IO.File.Exists(path)) + { + _logger.LogWarning( + "File does not exist after toggling case of extension: {FilePath}", + path + ); + return NotFound(); + } + } + else + { + _logger.LogWarning( + "File does not exist after toggling case of extension: {FilePath}", + path + ); + return NotFound(); + } + } + + var imageData = await resize(path); + return File(imageData, "image/jpeg", Path.GetFileName(path)); + } + + private string ToggleCaseExtension(string path) + { + string ext = Path.GetExtension(path); + string oppositeCaseExt = ext.Equals(ext.ToLower()) ? ext.ToUpper() : ext.ToLower(); + _logger.LogDebug( + "Toggled case of extension. New path: {NewPath}, Old path: {OldPath}", + oppositeCaseExt, + path + ); + return Path.ChangeExtension(path, oppositeCaseExt); + } + + private async Task resize(string path) + { + _logger.LogDebug("Resize method started for path: {FilePath}", path); + try + { + var fileName = $"{Config.ConfigDir}/images{path}"; + if (System.IO.File.Exists(fileName)) + { + using ( + var SourceStream = System.IO.File.Open( + fileName, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite + ) + ) + { + var result = new byte[SourceStream.Length]; + await SourceStream.ReadAsync(result, 0, (int)SourceStream.Length); + return result; + } + } + + using (var outputStream = new MemoryStream()) + { + using (var image = await Image.LoadAsync(path)) + { + int width = image.Width / 2; + int height = image.Height / 2; + width = 0; + height = 0; + if (image.Height > image.Width && height > Config.ImageMaxSize) + height = Config.ImageMaxSize; + if (image.Width > image.Height && width > Config.ImageMaxSize) + width = Config.ImageMaxSize; + + if (width + height != 0) + image.Mutate(x => x.Resize(width, height)); + var encoder = new JpegEncoder { Quality = Config.ImageQuality }; + await image.SaveAsJpegAsync(outputStream, encoder); + } + outputStream.Position = 0; + if (fileName != null) + Directory.CreateDirectory(Path.GetDirectoryName(fileName)); + using var destination = System.IO.File.Create(fileName, bufferSize: 4096); + await outputStream.CopyToAsync(destination); + + return outputStream.ToArray(); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error reading file: {FilePath}", path); + throw; + } + } +} diff --git a/Controllers/RssFeedController.cs b/src/Controllers/RssFeedController.cs similarity index 98% rename from Controllers/RssFeedController.cs rename to src/Controllers/RssFeedController.cs index 5e6f592..1e7ea96 100644 --- a/Controllers/RssFeedController.cs +++ b/src/Controllers/RssFeedController.cs @@ -26,7 +26,6 @@ public ContentResult Get() new XElement("link", domain), new XElement("description", description), from model in Cache.Models - where model.Public && model.Visible select new XElement("item", new XElement("title", model.Title), new XElement("link", $"{domain}/post/{model.Date?.Year}/{model.Title}"), diff --git a/src/Controllers/StatsController.cs b/src/Controllers/StatsController.cs new file mode 100644 index 0000000..6305666 --- /dev/null +++ b/src/Controllers/StatsController.cs @@ -0,0 +1,115 @@ +namespace picoblog.Controllers; + +public class StatsController : Controller +{ + private readonly ILogger _logger; + private readonly VisitTracker _visitTracker; + + public StatsController( + ILogger logger, + VisitTracker visitTracker) + { + _logger = logger; + _visitTracker = visitTracker; + } + + [HttpGet] + [Route("[Controller]")] + public async Task Index() +{ + try + { + // Basic stats remain synchronous as they use in-memory Cache + var monthlyActivity = Cache.Models + .Where(x => x.Date.HasValue) + .GroupBy(x => new { x.Date.Value.Year, x.Date.Value.Month }) + .Select(g => new MonthlyStats + { + Date = new DateTime(g.Key.Year, g.Key.Month, 1), + Count = g.Count() + }) + .OrderBy(x => x.Date) + .ToList(); + + // Await all async operations in parallel + var uniqueVisitorsTask = _visitTracker.GetUniqueVisitorsPerMonthAsync(); + var mostLikedPostsTask = _visitTracker.GetTopPostsAsync("likes", 5); + var mostViewedPostsTask = _visitTracker.GetTopPostsAsync("views", 5); + var userAgentStatsTask = _visitTracker.GetUserAgentStatsAsync(); + var totalViewsTask = _visitTracker.GetTotalViewsAsync(); + var monthlyVisitsTask = _visitTracker.GetVisitsPerMonthAsync(); + + await Task.WhenAll( + uniqueVisitorsTask, + mostLikedPostsTask, + mostViewedPostsTask, + userAgentStatsTask, + totalViewsTask, + monthlyVisitsTask + ); + + var stats = new StatsViewModel + { + // Basic stats + TotalPosts = Cache.Models.Count, + TotalViews = await totalViewsTask, // New + + // Monthly activity and visitors + MonthlyActivity = monthlyActivity, + UniqueVisitors = await uniqueVisitorsTask, + + // Top posts + MostLikedPosts = await mostLikedPostsTask, + MostViewedPosts = await mostViewedPostsTask, + + // Browser stats + UserAgentStats = await userAgentStatsTask, + + MonthlyVisits = await monthlyVisitsTask, + }; + + stats.TotalUniqueVisitors = stats.UniqueVisitors.Sum(p => p.Count); + + return View(stats); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating stats"); + + // Return a basic version of stats if database operations fail + return View(new StatsViewModel + { + TotalPosts = Cache.Models.Count, + TotalUniqueVisitors = 0, + TotalViews = 0, + MonthlyActivity = new List(), + UniqueVisitors = new List(), + MostLikedPosts = new List(), + MostViewedPosts = new List(), + UserAgentStats = new Dictionary() + }); + } +} + + // Helper method to ensure consistent date ranges across charts + private List NormalizeDateRange(List stats) + { + if (!stats.Any()) return stats; + + var minDate = stats.Min(s => s.Date); + var maxDate = stats.Max(s => s.Date); + var normalizedStats = new List(); + + for (var date = minDate; date <= maxDate; date = date.AddMonths(1)) + { + var stat = stats.FirstOrDefault(s => s.Date.Year == date.Year && s.Date.Month == date.Month); + normalizedStats.Add(new MonthlyStats + { + Date = date, + Count = stat?.Count ?? 0 + }); + } + + return normalizedStats; + } +} \ No newline at end of file diff --git a/Dockerfile b/src/Dockerfile similarity index 87% rename from Dockerfile rename to src/Dockerfile index 7491ad3..b64ed06 100755 --- a/Dockerfile +++ b/src/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS backend +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS backend ARG TARGETPLATFORM ARG BUILDPLATFORM @@ -14,9 +14,10 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm64" ] ; then DOTNET_TARGET=linux-musl-arm RUN cat /tmp/rid RUN dotnet restore "picoblog.csproj" -r $(cat /tmp/rid) /p:PublishReadyToRun=true -RUN dotnet publish "picoblog.csproj" -c Release -o /publish --runtime $(cat /tmp/rid) --self-contained true --no-restore /p:PublishTrimmed=true /p:PublishReadyToRun=true /p:PublishSingleFile=true +RUN dotnet publish "picoblog.csproj" -c Release -o /publish --runtime $(cat /tmp/rid) --self-contained true --no-restore /p:PublishReadyToRun=true /p:PublishSingleFile=true +# /p:PublishTrimmed=true is not possible with .NET9 (MVC doesn't support trimming) -FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine EXPOSE 8080 USER root diff --git a/Dockerfile.dev b/src/Dockerfile.dev similarity index 86% rename from Dockerfile.dev rename to src/Dockerfile.dev index b55a6f2..48978e7 100644 --- a/Dockerfile.dev +++ b/src/Dockerfile.dev @@ -1,5 +1,5 @@ # 1 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build RUN apt-get update \ && apt-get install unzip \ diff --git a/GlobalUsings.cs b/src/GlobalUsings.cs similarity index 100% rename from GlobalUsings.cs rename to src/GlobalUsings.cs diff --git a/LICENSE b/src/LICENSE similarity index 97% rename from LICENSE rename to src/LICENSE index be12557..c849f90 100644 --- a/LICENSE +++ b/src/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Jonasbg +Copyright (c) 2024 Jonasbg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Middleware/CustomLoggingMiddleware.cs b/src/Middleware/CustomLoggingMiddleware.cs new file mode 100644 index 0000000..2fda99b --- /dev/null +++ b/src/Middleware/CustomLoggingMiddleware.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +public class CustomLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public CustomLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _logger = loggerFactory?.CreateLogger() + ?? throw new ArgumentNullException(nameof(loggerFactory)); + } + + private string GetClientIp(HttpContext context) + { + // Try Cloudflare header first + var cfConnectingIp = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(cfConnectingIp)) + { + return cfConnectingIp; + } + + // Try X-Forwarded-For + var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrEmpty(forwardedFor)) + { + var ips = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries); + if (ips.Length > 0) + { + return ips[0].Trim(); + } + } + + // Fallback to remote IP address + var remoteIp = context.Connection?.RemoteIpAddress?.ToString(); + if (!string.IsNullOrEmpty(remoteIp)) + { + // Clean up IPv4 mapped to IPv6 + if (remoteIp.StartsWith("::ffff:")) + { + remoteIp = remoteIp.Substring(7); + } + return remoteIp; + } + + return "unknown"; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + var startTime = DateTime.UtcNow; + + try + { + await _next(context); + } + finally + { + var duration = DateTime.UtcNow - startTime; + var durationMs = duration.TotalMilliseconds; + + // Skip health check endpoints + if (!context.Request.Path.StartsWithSegments("/healthz")) + { + var clientIp = GetClientIp(context); + var country = context.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? ""; + + var logMessage = new System.Text.StringBuilder() + .Append($"{DateTime.UtcNow:yyyy/MM/dd HH:mm:ss} ") + .Append($"method={context.Request.Method} ") + .Append($"path={context.Request.Path} ") + .Append($"status={context.Response.StatusCode} ") + .Append($"duration={durationMs:F4}ms ") + .Append($"ip={clientIp}"); + + // Only add country if it exists + if (!string.IsNullOrEmpty(country)) + { + logMessage.Append($" country={country}"); + } + + var message = logMessage.ToString(); + + // Choose log level based on status code + if (context.Response.StatusCode >= 500) + { + _logger.LogError(message); + } + else if (context.Response.StatusCode >= 400) + { + _logger.LogWarning(message); + } + else + { + _logger.LogInformation(message); + } + } + } + } +} + +// Extension method to make registration cleaner +public static class CustomLoggingMiddlewareExtensions +{ + public static IApplicationBuilder UseCustomLogging(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/Middleware/VisitTrackerMiddleware.cs b/src/Middleware/VisitTrackerMiddleware.cs new file mode 100644 index 0000000..fb18913 --- /dev/null +++ b/src/Middleware/VisitTrackerMiddleware.cs @@ -0,0 +1,128 @@ +public class VisitTrackerMiddleware +{ + private readonly RequestDelegate _next; + private readonly VisitTracker _visitTracker; + private readonly ILogger _logger; + + public VisitTrackerMiddleware( + RequestDelegate next, + VisitTracker visitTracker, + ILogger logger) + { + _next = next; + _visitTracker = visitTracker; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + // Don't delay the response - capture the task but don't await it + var trackingTask = Task.CompletedTask; + + // Only track actual blog post views + if (IsPostView(context.Request)) + { + var visit = new VisitTracker.Visit + { + PostTitle = GetPostTitle(context.Request.Path), + Year = GetPostYear(context.Request.Path), + IpAddress = GetClientIp(context), + UserAgent = context.Request.Headers.UserAgent.ToString(), + VisitTime = DateTime.UtcNow, + Referrer = context.Request.Headers.Referer.ToString() + }; + + // Fire and forget tracking + trackingTask = Task.Run(async () => + { + try + { + await _visitTracker.LogVisitAsync(visit); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error tracking visit"); + } + }); + } + + // Continue the pipeline immediately + await _next(context); + + // Optionally wait for tracking to complete after the response + // Only if you want to ensure tracking completes before the request ends + try + { + await trackingTask; + } + catch + { + // Suppress any tracking errors after response + } + } + + private string GetClientIp(HttpContext context) + { + // Try Cloudflare header first + var cfConnectingIp = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(cfConnectingIp)) + { + return cfConnectingIp; + } + + // Try X-Forwarded-For + var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrEmpty(forwardedFor)) + { + var ips = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries); + if (ips.Length > 0) + { + return ips[0].Trim(); + } + } + + // Fallback to remote IP address + var remoteIp = context.Connection?.RemoteIpAddress?.ToString(); + if (!string.IsNullOrEmpty(remoteIp)) + { + // Clean up IPv4 mapped to IPv6 + if (remoteIp.StartsWith("::ffff:")) + { + remoteIp = remoteIp.Substring(7); + } + return remoteIp; + } + + return "unknown"; + } + + private bool IsPostView(HttpRequest request) + { + // Ensure we have a path and it's a GET request + if (request.Method != "GET" || string.IsNullOrEmpty(request.Path.Value)) + { + return false; + } + + // Normalize the path - don't trim as spaces in title are valid + var path = request.Path.Value.ToLowerInvariant(); + + // Check if path matches the pattern /post/year/title + // Where year is 4 digits and title can contain spaces, letters, numbers, and common symbols + var postPathPattern = @"^/post/\d{4}/[^/]+$"; + + return Regex.IsMatch(path, postPathPattern, RegexOptions.IgnoreCase); + } + + private string GetPostTitle(PathString path) + { + var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments?.Length >= 3 ? segments[2] : "unknown"; + } + + private int GetPostYear(PathString path) + { + var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments?.Length >= 2 && int.TryParse(segments[1], out var year) ? year : 0; + } +} \ No newline at end of file diff --git a/src/Middleware/VisitTrackerMiddlewareExtensions.cs b/src/Middleware/VisitTrackerMiddlewareExtensions.cs new file mode 100644 index 0000000..adfe49c --- /dev/null +++ b/src/Middleware/VisitTrackerMiddlewareExtensions.cs @@ -0,0 +1,17 @@ +public static class VisitTrackerMiddlewareExtensions +{ + public static IApplicationBuilder UseVisitTracker(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + + public static IServiceCollection AddVisitTracker(this IServiceCollection services, string configDir) + { + services.AddSingleton(sp => + new VisitTracker( + configDir, + sp.GetRequiredService>() + )); + return services; + } +} \ No newline at end of file diff --git a/Models/Cache.cs b/src/Models/Cache.cs similarity index 62% rename from Models/Cache.cs rename to src/Models/Cache.cs index dd0b621..1aeec62 100755 --- a/Models/Cache.cs +++ b/src/Models/Cache.cs @@ -2,4 +2,5 @@ namespace picoblog.Models; public static class Cache { public static IList Models = new List(); + public static IList PrivatePosts = new List(); } \ No newline at end of file diff --git a/src/Models/Config.cs b/src/Models/Config.cs new file mode 100644 index 0000000..66ca7dd --- /dev/null +++ b/src/Models/Config.cs @@ -0,0 +1,52 @@ +namespace picoblog.Models; + +public static class Config +{ + private static double _cachetime; + private static int maxheight; + private static int imagequality; + public static double CacheTimeInMinutes => + double.TryParse(Environment.GetEnvironmentVariable("IMAGE_CACHE_MINUTES"), out _cachetime) + ? _cachetime + : 4.0; + public static int ImageMaxSize => + int.TryParse(Environment.GetEnvironmentVariable("IMAGE_MAX_SIZE"), out maxheight) + ? maxheight + : 1280; + public static int ImageQuality => + int.TryParse(Environment.GetEnvironmentVariable("IMAGE_QUALITY"), out imagequality) + ? imagequality + : 65; + public static string Title => Environment.GetEnvironmentVariable("TITLE") ?? "Picoblog"; + public static string Description => + Environment.GetEnvironmentVariable("DESCRIPTION") + ?? "A simple zero fraction blogging platform"; + public static string CustomHeader => Environment.GetEnvironmentVariable("CUSTOM_HEADER") ?? ""; + public static string ConfigDir => Environment.GetEnvironmentVariable("CONFIG_DIR") ?? "/config"; + public static bool Synology => Environment.GetEnvironmentVariable("SYNOLOGY_SUPPORT") == "true"; + public static string? Password => + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PASSWORD")) + ? null + : Environment.GetEnvironmentVariable("PASSWORD"); + public static string DataDir => Environment.GetEnvironmentVariable("DATA_DIR") ?? "/data"; + public static string Domain => + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOMAIN")) + ? "http://localhost:8080" + : "https://" + Environment.GetEnvironmentVariable("DOMAIN"); + public static bool EnableBackup => + Environment + .GetEnvironmentVariable("PICOBLOG_ENABLE_BACKUP") + ?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; + + public static string SynologySize() + { + var sizeEnv = Environment.GetEnvironmentVariable("SYNOLOGY_SIZE"); + var defaultSize = "XL"; + var allowedSizes = new string[] { "SM", "M", "XL" }; + if (allowedSizes.Any(p => p.Equals(sizeEnv.ToUpper()))) + return $"SYNOPHOTO_THUMB_{sizeEnv.ToUpper()}.jpg"; + + Console.WriteLine($"WRONG SYNOLOGY_SIZE SELECTED {sizeEnv}. DEFAULTING TO {defaultSize}"); + return $"SYNOPHOTO_THUMB_{defaultSize}.jpg"; + } +} diff --git a/Models/ErrorViewModel.cs b/src/Models/ErrorViewModel.cs similarity index 100% rename from Models/ErrorViewModel.cs rename to src/Models/ErrorViewModel.cs diff --git a/Models/LoginViewModel.cs b/src/Models/LoginViewModel.cs similarity index 66% rename from Models/LoginViewModel.cs rename to src/Models/LoginViewModel.cs index 3ba46ab..e5e80db 100644 --- a/Models/LoginViewModel.cs +++ b/src/Models/LoginViewModel.cs @@ -4,6 +4,8 @@ public class LoginViewModel { [Required] public string Password { get; set; } = ""; - public string? ReturnURL {get;set;} + + [BindProperty] + public string? ReturnURL { get; set; } } -} \ No newline at end of file +} diff --git a/src/Models/MarkdownModel.cs b/src/Models/MarkdownModel.cs new file mode 100755 index 0000000..7eb4e97 --- /dev/null +++ b/src/Models/MarkdownModel.cs @@ -0,0 +1,38 @@ +public class MarkdownModel +{ + private string? _markdown; + public int ViewCount; + public int LikeCount; + + public string? Markdown + { + get => _markdown; + set { + _markdown = value; + var match = Regex.Match(_markdown, @"^---\r?\n(.*?)\r?\n---", RegexOptions.Singleline); + + if (match.Success && match.Groups.Count > 1) + { + var frontmatter = match.Groups[1].Value.Split(Environment.NewLine); + Title = frontmatter.SingleOrDefault(p => p.StartsWith("title:"))?.Split(':', 2)[1].Trim(); + Description = frontmatter.SingleOrDefault(p => p.StartsWith("description:"))?.Split(':', 2)[1].Trim(); + CoverImage = frontmatter.SingleOrDefault(p => p.StartsWith(MetadataHeader.CoverImage))?.Split(':', 2)[1].Trim(); + Public = bool.Parse(frontmatter.SingleOrDefault(p => p.StartsWith("public:"))?.Split(':', 2)[1].Trim() ?? "false"); + Draft = bool.Parse(frontmatter.SingleOrDefault(p => p.StartsWith("draft:"))?.Split(':', 2)[1].Trim() ?? "false"); + + var dateString = frontmatter.SingleOrDefault(p => p.StartsWith("date:"))?.Split(':', 2)[1].Trim(); + if (DateTime.TryParse(dateString, out DateTime date)) + { + Date = date; + } + } + } + } + public string Title { get; internal set; } + public bool Public { get; internal set; } = false; + public string Path { get; internal set; } + public DateTime? Date { get; internal set; } + public string? CoverImage { get; internal set; } + public string? Description { get; internal set; } + public bool Draft { get; internal set; } = false; +} \ No newline at end of file diff --git a/Models/MetadataHeader.cs b/src/Models/MetadataHeader.cs similarity index 100% rename from Models/MetadataHeader.cs rename to src/Models/MetadataHeader.cs diff --git a/Models/Payload.cs b/src/Models/Payload.cs similarity index 100% rename from Models/Payload.cs rename to src/Models/Payload.cs diff --git a/src/Models/StatsViewModel.cs b/src/Models/StatsViewModel.cs new file mode 100644 index 0000000..8144dda --- /dev/null +++ b/src/Models/StatsViewModel.cs @@ -0,0 +1,26 @@ +public class StatsViewModel +{ + public int TotalPosts { get; set; } + public int TotalUniqueVisitors { get; set; } // New property + public int TotalViews { get; set; } // New property + public List MonthlyActivity { get; set; } + public List UniqueVisitors { get; set; } + public List MostLikedPosts { get; set; } + public List MostViewedPosts { get; set; } + public Dictionary UserAgentStats { get; set; } + public List MonthlyVisits { get; set; } +} + +public class MonthlyStats +{ + public DateTime Date { get; set; } + public int Count { get; set; } +} + +public class TopPost +{ + public string Title { get; set; } + public int Year { get; set; } + public string CoverImage { get; set; } + public int Count { get; set; } +} \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs new file mode 100755 index 0000000..9763a0e --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,168 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddLogging(logging => +{ + logging.ClearProviders(); + logging.AddConsole(options => + { + options.FormatterName = "Simple"; + // options.TimestampFormat = "yyyy/MM/dd HH:mm:ss "; + }); + + // Set minimum log level + logging.SetMinimumLevel(LogLevel.Information); +}); + +builder.Services.Configure(options => options.LowercaseUrls = true); +builder.WebHost.UseKestrel(option => option.AddServerHeader = false); +builder.Services.AddHealthChecks(); + +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(ctx => +{ + return new BackgroundTaskQueue(1); +}); + +// builder.Services.AddImageSharp(options => { +// options.Configuration = Configuration.Default; +// options.MemoryStreamManager = new RecyclableMemoryStreamManager(); +// options.BrowserMaxAge = TimeSpan.FromDays(7); +// options.CacheMaxAge = TimeSpan.FromDays(365); +// options.CacheHashLength = 8; +// }); + +if (Config.Password != null) +{ + builder + .Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.SameSite = SameSiteMode.Strict; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + options.Cookie.Name = "Picoblog.AuthCookie"; + options.LoginPath = "/login"; + }); + builder.Services.AddControllersWithViews(options => + { + options.Filters.Add(new AuthorizeFilter()); + options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); + }); + builder + .Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(Config.ConfigDir)); +} +else + builder.Services.AddControllersWithViews(options => + { + options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); + }); + +// builder.Services.AddWebOptimizer(pipeline => +// { +// pipeline.MinifyJsFiles("**/*.js"); +// pipeline.MinifyCssFiles("css/**/*.css"); +// }); + +builder.Services.AddVisitTracker(Config.ConfigDir); + +var app = builder.Build(); +app.UseCustomLogging(); +app.UseVisitTracker(); + +// app.UseImageSharp(); + +app.UseForwardedHeaders( + new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, + } +); + +if (Config.Password != null) +{ + app.UseCookiePolicy(); + app.UseAuthentication(); + app.UseAuthorization(); +} + +//app.UseWebOptimizer(); +app.UseStaticFiles(); +app.MapHealthChecks("/healthz"); +app.UseRouting(); + +var supportedCultures = new[] { new CultureInfo("nb-NO"), new CultureInfo("en-GB") }; + +app.UseRequestLocalization( + new RequestLocalizationOptions + { + DefaultRequestCulture = new RequestCulture("nb-NO"), + SupportedCultures = supportedCultures, + SupportedUICultures = supportedCultures, + ApplyCurrentCultureToResponseHeaders = true, + } +); + +app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); + +// app.MapGet("/debug-headers", async context => +// { +// var headers = context.Request.Headers +// .Select(h => $"{h.Key}: {string.Join(", ", h.Value.ToArray())}") +// .ToList(); + +// await context.Response.WriteAsJsonAsync(new +// { +// RemoteIpAddress = context.Connection.RemoteIpAddress?.ToString(), +// Headers = headers, +// StandardProxyHeaders = new +// { +// XForwardedFor = context.Request.Headers["X-Forwarded-For"].ToString(), +// XRealIP = context.Request.Headers["X-Real-IP"].ToString(), +// XForwardedProto = context.Request.Headers["X-Forwarded-Proto"].ToString(), +// XForwardedHost = context.Request.Headers["X-Forwarded-Host"].ToString() +// }, +// CloudflareHeaders = new +// { +// CFConnectingIP = context.Request.Headers["CF-Connecting-IP"].ToString(), +// CFIPCountry = context.Request.Headers["CF-IPCountry"].ToString(), +// CFIPCity = context.Request.Headers["CF-IPCity"].ToString(), +// CFIPRegion = context.Request.Headers["CF-IPRegion"].ToString(), +// CFRay = context.Request.Headers["CF-Ray"].ToString(), +// CFVisitorScheme = context.Request.Headers["CF-Visitor"].ToString(), +// CFWorker = context.Request.Headers["CF-Worker"].ToString(), +// CFRequestPriority = context.Request.Headers["CF-Request-Priority"].ToString(), +// CFAccessAuthenticatedUser = context.Request.Headers["CF-Access-Authenticated-User-Email"].ToString() +// }, +// TrustChain = new +// { +// ForwardedForChain = context.Request.Headers["X-Forwarded-For"] +// .ToString() +// .Split(',') +// .Select(ip => ip.Trim()) +// .ToArray() // Changed to ToArray() to make it explicit +// } +// }); +// }); + +using (var serviceScope = app.Services.CreateScope()) +{ + var services = serviceScope.ServiceProvider; + var monitorLoop = services.GetRequiredService(); + monitorLoop.StartMonitorLoop(); +} + +var logger = app.Services.GetRequiredService>(); +logger.LogInformation("Application started"); + +logger.LogTrace("Trace level log"); +logger.LogDebug("Debug level log"); +logger.LogInformation("Information level log"); +logger.LogWarning("Warning level log"); +logger.LogError("Error level log"); +logger.LogCritical("Critical level log"); + +app.Run(); diff --git a/Properties/launchSettings.json b/src/Properties/launchSettings.json similarity index 100% rename from Properties/launchSettings.json rename to src/Properties/launchSettings.json diff --git a/Services/BackgroundService.cs b/src/Services/BackgroundService.cs similarity index 100% rename from Services/BackgroundService.cs rename to src/Services/BackgroundService.cs diff --git a/Services/BackupService.cs b/src/Services/BackupService.cs similarity index 100% rename from Services/BackupService.cs rename to src/Services/BackupService.cs diff --git a/src/Services/MonitorLoop.cs b/src/Services/MonitorLoop.cs new file mode 100644 index 0000000..5e255a2 --- /dev/null +++ b/src/Services/MonitorLoop.cs @@ -0,0 +1,339 @@ +using System.Runtime.InteropServices; + +public class MonitorLoop : IDisposable +{ + private readonly ILogger _logger; + private readonly FileSystemWatcher _watcher; + private readonly ConcurrentDictionary _modelCache; + private readonly ConcurrentDictionary _privatePostsCache; + private readonly object _updateLock = new object(); + private bool _disposed; + + public MonitorLoop(ILogger logger, IHostApplicationLifetime applicationLifetime) + { + _logger = logger; + _modelCache = new ConcurrentDictionary(); + _privatePostsCache = new ConcurrentDictionary(); + + try + { + EnsureInotifyLimits(); + + _watcher = new FileSystemWatcher(Config.DataDir) + { + Filter = "*.md", + IncludeSubdirectories = true, + EnableRaisingEvents = false, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite + }; + + // Register for cancellation + applicationLifetime.ApplicationStopping.Register(() => + { + Dispose(); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize FileSystemWatcher. Falling back to polling."); + _watcher = null; + } + } + + private void EnsureInotifyLimits() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + try + { + // Check current limits + var maxWatches = File.ReadAllText("/proc/sys/fs/inotify/max_user_watches"); + var maxInstances = File.ReadAllText("/proc/sys/fs/inotify/max_user_instances"); + + _logger.LogInformation("Current inotify limits - max_user_watches: {Watches}, max_user_instances: {Instances}", + maxWatches.Trim(), maxInstances.Trim()); + + // Log instructions if limits are too low + if (int.TryParse(maxWatches.Trim(), out int watches) && watches < 524288) + { + _logger.LogWarning(@"Low inotify watch limit detected. To increase, run: + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p"); + } + + if (int.TryParse(maxInstances.Trim(), out int instances) && instances < 256) + { + _logger.LogWarning(@"Low inotify instances limit detected. To increase, run: + echo fs.inotify.max_user_instances=256 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check inotify limits"); + } + } + } + + public void StartMonitorLoop() + { + // Initial load of all files + LoadAllFiles(); + + if (_watcher != null) + { + try + { + // Setup watchers + _watcher.Created += OnFileChanged; + _watcher.Changed += OnFileChanged; + _watcher.Deleted += OnFileDeleted; + _watcher.Renamed += OnFileRenamed; + _watcher.Error += OnWatcherError; + + // Start watching + _watcher.EnableRaisingEvents = true; + _logger.LogInformation("Started watching for markdown files in {Directory}", Config.DataDir); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start FileSystemWatcher. Falling back to polling."); + StartPolling(); + } + } + else + { + StartPolling(); + } + } + + private void StartPolling() + { + _logger.LogInformation("Starting polling-based file monitoring"); + var timer = new Timer(PollForChanges, null, TimeSpan.Zero, TimeSpan.FromMinutes(5)); + } + + private void PollForChanges(object state) + { + try + { + LoadAllFiles(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during file polling"); + } + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + _logger.LogError(e.GetException(), "FileSystemWatcher error occurred"); + + try + { + // Try to restart the watcher + if (_watcher != null && !_disposed) + { + _watcher.EnableRaisingEvents = false; + _watcher.EnableRaisingEvents = true; + _logger.LogInformation("FileSystemWatcher restarted after error"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to restart FileSystemWatcher. Falling back to polling."); + StartPolling(); + } + } + + private void LoadAllFiles() + { + _logger.LogInformation("Loading all existing markdown files"); + var files = Directory.EnumerateFiles(Config.DataDir, "*.md", SearchOption.AllDirectories); + + foreach (var file in files) + { + ProcessFile(file); + } + + UpdateCaches(); + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + try + { + // Add small delay to ensure file is completely written + Thread.Sleep(100); + ProcessFile(e.FullPath); + UpdateCaches(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing file change for {Path}", e.FullPath); + } + } + + private void OnFileDeleted(object sender, FileSystemEventArgs e) + { + try + { + _modelCache.TryRemove(e.FullPath, out _); + _privatePostsCache.TryRemove(e.FullPath, out _); + _logger.LogInformation("DELETED FILE: {Path}", e.FullPath); + UpdateCaches(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing file deletion for {Path}", e.FullPath); + } + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + try + { + // Remove old path + _modelCache.TryRemove(e.OldFullPath, out _); + _privatePostsCache.TryRemove(e.OldFullPath, out _); + + // Process with new path + ProcessFile(e.FullPath); + UpdateCaches(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing file rename from {OldPath} to {NewPath}", + e.OldFullPath, e.FullPath); + } + } + + private void ProcessFile(string filePath) + { + string content = File.ReadAllText(filePath); + Match match = Regex.Match(content, @"^---\n(.*?)\n---", RegexOptions.Singleline); + + if (match.Success) + { + var model = ProcessFrontMatter(match.Groups[1].Value, filePath); + if (model != null) + { + if (model.Public && !model.Draft) + { + _modelCache[filePath] = model; + _logger.LogInformation("UPDATED: {Title} - Path: {Path} - URL: {Url}", + model.Title, model.Path, $"{Config.Domain}/post/{model.Title}"); + } + else if (!model.Public && !model.Draft) + { + _privatePostsCache[filePath] = model; + } + else if (model.Draft) + { + _logger.LogInformation("FOUND DRAFT (IGNORING): {Path}", model.Path); + } + } + } + } + + private MarkdownModel ProcessFrontMatter(string frontmatter, string file) + { + var model = new MarkdownModel { Path = file }; + + foreach (var line in frontmatter.Split('\n')) + { + string[] parts = line.Split(':', 2); + if (parts.Length < 2) continue; + + string key = parts[0].Trim(); + string value = parts[1].Trim(); + + switch (key.ToLowerInvariant()) + { + case var k when k.Equals(MetadataHeader.Public, StringComparison.InvariantCultureIgnoreCase): + model.Public = value.Equals("true", StringComparison.InvariantCultureIgnoreCase); + break; + case var k when k.Equals(MetadataHeader.Title, StringComparison.InvariantCultureIgnoreCase): + model.Title = value; + break; + case var k when k.Equals(MetadataHeader.Date, StringComparison.InvariantCultureIgnoreCase): + model.Date = DateTime.Parse(value); + break; + case var k when k.Equals(MetadataHeader.Draft, StringComparison.InvariantCultureIgnoreCase): + model.Draft = value.Equals("true", StringComparison.InvariantCultureIgnoreCase); + break; + case var k when k.Equals(MetadataHeader.CoverImage, StringComparison.InvariantCultureIgnoreCase): + model.CoverImage = value; + break; + case var k when k.Equals(MetadataHeader.Description, StringComparison.InvariantCultureIgnoreCase): + model.Description = value; + break; + } + } + + return model; + } + + private void UpdateCaches() + { + lock (_updateLock) + { + var models = _modelCache.Values.ToList(); + var privatePosts = _privatePostsCache.Values.ToList(); + + // Check for posts without titles + var postsWithoutTitles = models.Where(p => string.IsNullOrEmpty(p.Title)).ToList(); + if (postsWithoutTitles.Any()) + { + _logger.LogInformation("FOUND {Count} POSTS WITHOUT TITLES", postsWithoutTitles.Count); + foreach (var model in postsWithoutTitles) + { + _logger.LogInformation("POST WITHOUT TITLE: {Path}", model.Path); + _modelCache.TryRemove(model.Path, out _); + } + models = models.Where(p => !string.IsNullOrEmpty(p.Title)).ToList(); + } + + var duplicates = models + .GroupBy(p => new { p.Title, p.Date?.Year }) + .Where(g => g.Count() > 1) + .SelectMany(g => g.Skip(1)) + .ToList(); + + if (duplicates.Any()) + { + _logger.LogInformation("FOUND DUPLICATES, REMOVING EXTRAS"); + foreach (var dup in duplicates) + { + _logger.LogInformation("REMOVING DUPLICATE: Title: {Title}, Path: {Path}", + dup.Title, dup.Path); + _modelCache.TryRemove(dup.Path, out _); + } + models = models.Except(duplicates).ToList(); + } + + // Update the global cache + Cache.Models = models; + Cache.PrivatePosts = privatePosts; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + _watcher.Created -= OnFileChanged; + _watcher.Changed -= OnFileChanged; + _watcher.Deleted -= OnFileDeleted; + _watcher.Renamed -= OnFileRenamed; + _watcher.Error -= OnWatcherError; + _watcher.Dispose(); + } + } +} \ No newline at end of file diff --git a/Services/Queue.cs b/src/Services/Queue.cs similarity index 100% rename from Services/Queue.cs rename to src/Services/Queue.cs diff --git a/src/Services/VisitTracker.cs b/src/Services/VisitTracker.cs new file mode 100644 index 0000000..f5cb578 --- /dev/null +++ b/src/Services/VisitTracker.cs @@ -0,0 +1,412 @@ +using Microsoft.Data.Sqlite; + +public class VisitTracker +{ + public class Visit + { + public string PostTitle { get; set; } + public int Year { get; set; } + public string IpAddress { get; set; } + public string UserAgent { get; set; } + public DateTime VisitTime { get; set; } + public string Referrer { get; set; } + } + + private readonly string _dbPath; + private readonly ILogger _logger; + + public VisitTracker(string configDir, ILogger logger) + { + _dbPath = Path.Combine(configDir, "visits.db"); + _logger = logger; + InitializeDatabase(); + UpdateCacheViewCounts(); + } + + private void InitializeDatabase() + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + connection.Open(); + + var command = connection.CreateCommand(); + command.CommandText = @" + CREATE TABLE IF NOT EXISTS Visits ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + PostTitle TEXT NOT NULL, + Year INTEGER NOT NULL, + IpAddress TEXT, + UserAgent TEXT, + VisitTime DATETIME NOT NULL, + Referrer TEXT + ); + CREATE INDEX IF NOT EXISTS IX_Visits_PostTitle ON Visits(PostTitle); + CREATE INDEX IF NOT EXISTS IX_Visits_VisitTime ON Visits(VisitTime); + + CREATE TABLE IF NOT EXISTS Likes ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + PostTitle TEXT NOT NULL, + Year INTEGER NOT NULL, + LikedAt DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS IX_Likes_PostTitle ON Likes(PostTitle);"; + + command.ExecuteNonQuery(); + } + + private async Task UpdateLikeAsync(string postTitle, int year, bool isAdd) + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = isAdd + ? @"INSERT INTO Likes (PostTitle, Year, LikedAt) VALUES (@title, @year, @time)" + : @"DELETE FROM Likes WHERE PostTitle = @title AND Year = @year"; + + command.Parameters.AddWithValue("@title", postTitle); + command.Parameters.AddWithValue("@year", year); + + if (isAdd) + { + command.Parameters.AddWithValue("@time", DateTime.UtcNow); + } + + await command.ExecuteNonQueryAsync(); + + // Update Cache + var model = Cache.Models.FirstOrDefault(m => + m.Title == postTitle && m.Date?.Year == year); + + if (model != null) + { + if (isAdd) + Interlocked.Increment(ref model.LikeCount); + else + Interlocked.Decrement(ref model.LikeCount); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error {Action} like for post {PostTitle}", + isAdd ? "adding" : "removing", postTitle); + return false; + } + } + + public Task AddLikeAsync(string postTitle, int year) => + UpdateLikeAsync(postTitle, year, true); + + public Task RemoveLikeAsync(string postTitle, int year) => + UpdateLikeAsync(postTitle, year, false); + + private void UpdateCacheViewCounts() + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + connection.Open(); + + // Get view counts + var viewCommand = connection.CreateCommand(); + viewCommand.CommandText = @" + SELECT PostTitle, Year, COUNT(*) as ViewCount + FROM Visits + GROUP BY PostTitle, Year"; + + using (var reader = viewCommand.ExecuteReader()) + { + while (reader.Read()) + { + var postTitle = reader.GetString(0); + var year = reader.GetInt32(1); + var viewCount = reader.GetInt32(2); + + var model = Cache.Models.FirstOrDefault(m => + m.Title == postTitle && m.Date?.Year == year); + + if (model != null) + { + model.ViewCount = viewCount; + } + } + } + + // Get like counts + var likeCommand = connection.CreateCommand(); + likeCommand.CommandText = @" + SELECT PostTitle, Year, COUNT(*) as LikeCount + FROM Likes + GROUP BY PostTitle, Year"; + + using (var reader = likeCommand.ExecuteReader()) + { + while (reader.Read()) + { + var postTitle = reader.GetString(0); + var year = reader.GetInt32(1); + var likeCount = reader.GetInt32(2); + + var model = Cache.Models.FirstOrDefault(m => + m.Title == postTitle && m.Date?.Year == year); + + if (model != null) + { + model.LikeCount = likeCount; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating cache counts"); + } + } + + public async Task LogVisitAsync(Visit visit) + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO Visits (PostTitle, Year, IpAddress, UserAgent, VisitTime, Referrer) + VALUES (@title, @year, @ip, @agent, @time, @referrer)"; + + command.Parameters.AddWithValue("@title", visit.PostTitle); + command.Parameters.AddWithValue("@year", visit.Year); + command.Parameters.AddWithValue("@ip", visit.IpAddress); + command.Parameters.AddWithValue("@agent", visit.UserAgent); + command.Parameters.AddWithValue("@time", visit.VisitTime); + command.Parameters.AddWithValue("@referrer", visit.Referrer ?? ""); + + await command.ExecuteNonQueryAsync(); + + // Update Cache + var model = Cache.Models.FirstOrDefault(m => + m.Title == visit.PostTitle && m.Date?.Year == visit.Year); + + if (model != null) + { + Interlocked.Increment(ref model.ViewCount); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error logging visit for post {PostTitle}", visit.PostTitle); + } + } + + public async Task> GetViewCountsAsync() + { + var viewCounts = new Dictionary(); + + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = @" + SELECT PostTitle, COUNT(*) as ViewCount + FROM Visits + GROUP BY PostTitle"; + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + viewCounts[reader.GetString(0)] = reader.GetInt32(1); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving view counts"); + } + + return viewCounts; + } + public async Task> GetUniqueVisitorsPerMonthAsync() + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = @" + SELECT + strftime('%Y-%m', VisitTime) as Month, + COUNT(DISTINCT IpAddress) as UniqueVisitors + FROM Visits + GROUP BY strftime('%Y-%m', VisitTime) + ORDER BY Month"; + + var stats = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var monthStr = reader.GetString(0); + stats.Add(new MonthlyStats + { + Date = DateTime.ParseExact(monthStr + "-01", "yyyy-MM-dd", null), + Count = reader.GetInt32(1) + }); + } + return stats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting unique visitors per month"); + return new List(); + } + } + + public async Task> GetVisitsPerMonthAsync() + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = @" + SELECT + strftime('%Y-%m', VisitTime) as Month, + COUNT(*) as TotalVisits + FROM Visits + GROUP BY strftime('%Y-%m', VisitTime) + ORDER BY Month"; + + var stats = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var monthStr = reader.GetString(0); + stats.Add(new MonthlyStats + { + Date = DateTime.ParseExact(monthStr + "-01", "yyyy-MM-dd", null), + Count = reader.GetInt32(1) + }); + } + return stats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting visits per month"); + return new List(); + } + } + + public async Task GetTotalViewsAsync() + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM Visits"; + + var totalViews = Convert.ToInt32(await command.ExecuteScalarAsync()); + return totalViews; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting total views"); + return 0; + } + } + + public async Task> GetUserAgentStatsAsync() + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var command = connection.CreateCommand(); + command.CommandText = @" + SELECT + CASE + WHEN UserAgent LIKE '%Mobile%' THEN 'Mobile' + WHEN UserAgent LIKE '%Chrome%' THEN 'Chrome' + WHEN UserAgent LIKE '%Firefox%' THEN 'Firefox' + WHEN UserAgent LIKE '%Safari%' THEN 'Safari' + WHEN UserAgent LIKE '%Edge%' THEN 'Edge' + ELSE 'Other' + END as Browser, + COUNT(*) as Count + FROM Visits + GROUP BY Browser + ORDER BY Count DESC"; + + var stats = new Dictionary(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + stats[reader.GetString(0)] = reader.GetInt32(1); + } + return stats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting user agent stats"); + return new Dictionary(); + } + } + + public async Task> GetTopPostsAsync(string metric = "views", int limit = 5) + { + try + { + using var connection = new SqliteConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(); + + var tableName = metric.ToLower() == "likes" ? "Likes" : "Visits"; + var command = connection.CreateCommand(); + command.CommandText = $@" + SELECT + PostTitle, + Year, + COUNT(*) as Count + FROM {tableName} + GROUP BY PostTitle, Year + ORDER BY Count DESC + LIMIT @limit"; + + command.Parameters.AddWithValue("@limit", limit); + + var topPosts = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var title = reader.GetString(0); + var year = reader.GetInt32(1); + var model = Cache.Models.FirstOrDefault(m => + m.Title == title && m.Date?.Year == year); + + if (model != null) + { + topPosts.Add(new TopPost + { + Title = title, + Year = year, + CoverImage = model.CoverImage, + Count = reader.GetInt32(2) + }); + } + } + return topPosts; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting top posts"); + return new List(); + } + } +} \ No newline at end of file diff --git a/Views/Calendar/Year.cshtml b/src/Views/Calendar/Year.cshtml similarity index 100% rename from Views/Calendar/Year.cshtml rename to src/Views/Calendar/Year.cshtml diff --git a/Views/Calendar/Year.cshtml.css b/src/Views/Calendar/Year.cshtml.css similarity index 100% rename from Views/Calendar/Year.cshtml.css rename to src/Views/Calendar/Year.cshtml.css diff --git a/Views/Calendar/index.cshtml b/src/Views/Calendar/index.cshtml similarity index 100% rename from Views/Calendar/index.cshtml rename to src/Views/Calendar/index.cshtml diff --git a/Views/Calendar/index.cshtml.css b/src/Views/Calendar/index.cshtml.css similarity index 92% rename from Views/Calendar/index.cshtml.css rename to src/Views/Calendar/index.cshtml.css index e7f5575..6738334 100644 --- a/Views/Calendar/index.cshtml.css +++ b/src/Views/Calendar/index.cshtml.css @@ -1,7 +1,7 @@ -.hero img{ - width: 100% !important; -} - -.banner-grid img { - max-height: 35vh !important; -} +.hero img{ + width: 100% !important; +} + +.banner-grid img { + max-height: 35vh !important; +} diff --git a/Views/Home/Index.cshtml b/src/Views/Home/Index.cshtml similarity index 100% rename from Views/Home/Index.cshtml rename to src/Views/Home/Index.cshtml diff --git a/Views/Home/Login.cshtml b/src/Views/Home/Login.cshtml similarity index 77% rename from Views/Home/Login.cshtml rename to src/Views/Home/Login.cshtml index 4f29d8c..c7363ae 100644 --- a/Views/Home/Login.cshtml +++ b/src/Views/Home/Login.cshtml @@ -6,11 +6,10 @@ }