diff --git a/.gitignore b/.gitignore index afbef65..a4f4fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *obj/ *.DS_Store docker-compose.yml -cmd/dist/* \ No newline at end of file +cmd/dist/* +src/core +tests/* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 4d78b66..d2b2ca4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,7 +21,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "SYNOLOGY_SUPPORT": "false", "SYNOLOGY_SIZE": "SM", - "DATA_DIR": "../tests/posts", + "DATA_DIR": "../tests", "CONFIG_DIR": "/tmp", "DOMAIN": "localhost:8080", "PASSWORD": "", diff --git a/cmd/go.mod b/cmd/go.mod index ecbdc02..69e26aa 100644 --- a/cmd/go.mod +++ b/cmd/go.mod @@ -11,5 +11,6 @@ require ( github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/cmd/go.sum b/cmd/go.sum index 59fb8a5..65bee56 100644 --- a/cmd/go.sum +++ b/cmd/go.sum @@ -18,4 +18,6 @@ golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/main.go b/cmd/main.go index 1426859..abeec6b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,17 +1,21 @@ package main import ( + "bufio" "fmt" "os" "os/exec" "path/filepath" "regexp" "runtime" + "sort" "strings" + "sync" "time" "github.com/manifoldco/promptui" "github.com/spf13/cobra" + "gopkg.in/yaml.v2" ) var ( @@ -29,6 +33,218 @@ type BlogPost struct { Draft bool } +type PostMeta struct { + Title string `yaml:"title"` + Date string `yaml:"date"` + Public bool `yaml:"public"` + Draft bool `yaml:"draft"` +} + +type SearchResult struct { + Path string + Filename string + Date time.Time // Add explicit date field for searching + Meta PostMeta +} + +func (s SearchResult) String() string { + return fmt.Sprintf("%s (%s) - %s", s.Filename, s.Date.Format("2006-01-02"), s.Meta.Title) +} + +func init() { + searchCmd := &cobra.Command{ + Use: "search", + Short: "Search blog posts by filename, title, or date", + RunE: searchPosts, + } + rootCmd.AddCommand(searchCmd) +} + +func searchPosts(cmd *cobra.Command, args []string) error { + results, err := findAllPosts(BaseDir) + if err != nil { + return err + } + + // Sort results by date descending + sort.Slice(results, func(i, j int) bool { + return results[i].Date.After(results[j].Date) + }) + + searcher := &promptui.Select{ + Label: "Search posts (type to filter by title, filename, or date)", + Items: results, + Size: 15, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | cyan }}", + Active: "\u279C {{ .Filename | cyan }} ({{ .Date.Format \"2006-01-02\" }}) - {{ .Meta.Title }}", + Inactive: " {{ .Filename | white }} ({{ .Date.Format \"2006-01-02\" }}) - {{ .Meta.Title }}", + Selected: "\u2713 {{ .Filename | green }} ({{ .Date.Format \"2006-01-02\" }}) - {{ .Meta.Title }}", + Details: ` +{{ "File:" | faint }} {{ .Filename }} +{{ "Date:" | faint }} {{ .Date.Format "2006-01-02" }} +{{ "Title:" | faint }} {{ .Meta.Title }} +{{ "Path:" | faint }} {{ .Path }} +{{ "Draft:" | faint }} {{ .Meta.Draft }} +{{ "Public:" | faint }} {{ .Meta.Public }}`, + }, + Keys: &promptui.SelectKeys{ + Prev: promptui.Key{Code: 107, Display: "k"}, // k key + Next: promptui.Key{Code: 106, Display: "j"}, // j key + PageUp: promptui.Key{Code: 2, Display: "b"}, // ctrl+b + PageDown: promptui.Key{Code: 6, Display: "f"}, // ctrl+f + }, + Searcher: func(input string, index int) bool { + result := results[index] + + // Convert everything to lowercase for case-insensitive search + title := strings.ToLower(result.Meta.Title) + filename := strings.ToLower(result.Filename) + date := result.Date.Format("2006-01-02") + searchInput := strings.ToLower(input) + + // Search in title, filename, and date + return strings.Contains(title, searchInput) || + strings.Contains(filename, searchInput) || + strings.Contains(date, searchInput) + }, + } + + index, _, err := searcher.Run() + if err != nil { + if err == promptui.ErrInterrupt || err == promptui.ErrAbort { + fmt.Println("Search cancelled") + return nil + } + return err + } + + selected := results[index] + dir := filepath.Dir(selected.Path) + + // First open the directory + if err := openFile(dir); err != nil { + return fmt.Errorf("error opening directory: %v", err) + } + + // Then open the file + return openFile(selected.Path) +} + +func findAllPosts(root string) ([]SearchResult, error) { + var results []SearchResult + resultChan := make(chan SearchResult) + doneChan := make(chan bool) + + // Use a semaphore to limit concurrent goroutines + sem := make(chan struct{}, runtime.NumCPU()) + var wg sync.WaitGroup + + // Start a goroutine to collect results + go func() { + for result := range resultChan { + results = append(results, result) + } + doneChan <- true + }() + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(info.Name(), ".md") { + wg.Add(1) + go func(path string, info os.FileInfo) { + defer wg.Done() + sem <- struct{}{} // Acquire semaphore + defer func() { <-sem }() // Release semaphore + + meta, err := parseMarkdownFrontMatter(path) + if err != nil { + fmt.Printf("Warning: Could not parse %s: %v\n", path, err) + return + } + + // Parse the date from meta + date, err := time.Parse(time.RFC3339[:10], meta.Date) + if err != nil { + // Try parsing with multiple date formats + formats := []string{ + "2006-01-02", + "2006-01-02T15:04:05Z", + time.RFC3339, + } + + parsed := false + for _, format := range formats { + if date, err = time.Parse(format, meta.Date); err == nil { + parsed = true + break + } + } + + if !parsed { + fmt.Printf("Warning: Invalid date format in %s: %v\n", path, err) + date = info.ModTime() // Use file modification time as fallback + } + } + + resultChan <- SearchResult{ + Path: path, + Filename: info.Name(), + Date: date, + Meta: meta, + } + }(path, info) + } + return nil + }) + + // Wait for all goroutines to complete + wg.Wait() + close(resultChan) + <-doneChan + + return results, err +} + +func parseMarkdownFrontMatter(filepath string) (PostMeta, error) { + var meta PostMeta + file, err := os.Open(filepath) + if err != nil { + return meta, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var frontMatter strings.Builder + inFrontMatter := false + yamlStart := false + + for scanner.Scan() { + line := scanner.Text() + if line == "---" { + if !inFrontMatter { + inFrontMatter = true + yamlStart = true + continue + } else { + break + } + } + if inFrontMatter && yamlStart { + frontMatter.WriteString(line + "\n") + } + } + + if err := yaml.Unmarshal([]byte(frontMatter.String()), &meta); err != nil { + return meta, err + } + + return meta, nil +} + func createMarkdown(file string, post BlogPost) error { publicText := "true" if !post.Public { diff --git a/src/Controllers/HomeController.cs b/src/Controllers/HomeController.cs index 8dc7f3c..a676ce9 100755 --- a/src/Controllers/HomeController.cs +++ b/src/Controllers/HomeController.cs @@ -4,10 +4,12 @@ namespace picoblog.Controllers; public class HomeController : Controller { private readonly ILogger _logger; + private readonly MonitorLoop _monitorLoop; - public HomeController(ILogger logger) + public HomeController(ILogger logger, MonitorLoop monitorLoop) { _logger = logger; + _monitorLoop = monitorLoop; } [AllowAnonymous] @@ -84,6 +86,8 @@ private IActionResult RedirectToLocal(string returnUrl) [Route("")] public IActionResult Index() { + _monitorLoop.Execute(); + ViewBag.Home = "class = active"; return View(Cache.Models.OrderByDescending(f => f.Date)); } diff --git a/src/Controllers/PostController.cs b/src/Controllers/PostController.cs index 47a0e48..feff6ec 100755 --- a/src/Controllers/PostController.cs +++ b/src/Controllers/PostController.cs @@ -5,7 +5,10 @@ public class PostController : Controller private readonly ILogger _logger; private readonly VisitTracker _visitTracker; - public PostController(ILogger logger, VisitTracker visitTracker) + public PostController( + ILogger logger, + VisitTracker visitTracker + ) { _logger = logger; _visitTracker = visitTracker; @@ -205,51 +208,71 @@ private async Task resize(string path) var fileName = $"{Config.ConfigDir}/images{path}"; if (System.IO.File.Exists(fileName)) { - using ( - var SourceStream = System.IO.File.Open( + try + { + using var sourceStream = new FileStream( fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite - ) - ) - { - var result = new byte[SourceStream.Length]; - await SourceStream.ReadAsync(result, 0, (int)SourceStream.Length); + ); + var result = new byte[sourceStream.Length]; + await sourceStream.ReadAsync(result, 0, (int)sourceStream.Length); return result; } + catch (IOException ex) + { + _logger.LogWarning("Could not access cached file: {Error}", ex.Message); + // Continue to generate a new resized image + } } - using (var outputStream = new MemoryStream()) + using var outputStream = new MemoryStream(); + using (var image = await Image.LoadAsync(path)) { - 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; + 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); + if (width + height != 0) + image.Mutate(x => x.Resize(width, height)); + var encoder = new JpegEncoder { Quality = Config.ImageQuality }; + await image.SaveAsJpegAsync(outputStream, encoder); + } - return outputStream.ToArray(); + outputStream.Position = 0; + if (fileName != null) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(fileName)); + using var destination = new FileStream( + fileName, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous + ); + await outputStream.CopyToAsync(destination); + } + catch (IOException ex) + { + _logger.LogWarning("Could not cache resized image: {Error}", ex.Message); + // Continue without caching + } } + + return outputStream.ToArray(); } catch (Exception e) { - _logger.LogError(e, "Error reading file: {FilePath}", path); + _logger.LogError(e, "Error processing image: {FilePath}", path); throw; } } diff --git a/src/Middleware/SanitizingLogger.cs b/src/Middleware/SanitizingLogger.cs new file mode 100644 index 0000000..faa9015 --- /dev/null +++ b/src/Middleware/SanitizingLogger.cs @@ -0,0 +1,56 @@ +public class SanitizingLogger : ILogger +{ + private readonly ILogger _innerLogger; + + public SanitizingLogger(ILogger innerLogger) + { + _innerLogger = innerLogger; + } + + public IDisposable? BeginScope(TState state) + where TState : notnull => _innerLogger.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) => _innerLogger.IsEnabled(logLevel); + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + var originalMessage = formatter(state, exception); + var sanitizedMessage = SanitizeInput(originalMessage); + _innerLogger.Log(logLevel, eventId, state, exception, (s, e) => sanitizedMessage); + } + + private static string SanitizeInput(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + var sanitized = input + .Replace("'", "") + .Replace("\"", "") + .Replace(";", "") + .Replace("--", "") + .Replace("/*", "") + .Replace("*/", "") + .Replace("xp_", "") + .Replace("{", "") + .Replace("}", "") + .Replace("${", "") + .Replace("#{", "") + .Replace("\r", "") + .Replace("\n", " ") + .Replace("\t", " ") + .Replace("\0", "") + .Replace("", "") + .Trim() + .Replace(" ", " "); + + return $"[Sanitized] {sanitized}"; + } +} diff --git a/src/Middleware/SanitizingLoggerProvider.cs b/src/Middleware/SanitizingLoggerProvider.cs new file mode 100644 index 0000000..d27e03d --- /dev/null +++ b/src/Middleware/SanitizingLoggerProvider.cs @@ -0,0 +1,38 @@ +public class SanitizingLoggerProvider : ILoggerProvider +{ + private readonly ILoggerProvider _innerProvider; + + public SanitizingLoggerProvider(ILoggerProvider innerProvider) + { + _innerProvider = innerProvider; + } + + public ILogger CreateLogger(string categoryName) + { + // Get the actual type from the category name + Type categoryType = Type.GetType(categoryName) ?? typeof(object); + + // Create the correct generic ILogger type + Type genericLoggerType = typeof(ILogger<>).MakeGenericType(categoryType); + + // Get the inner logger and cast it to ILogger + ILogger innerLogger = _innerProvider.CreateLogger(categoryName); + var genericInnerLogger = innerLogger + .GetType() + .GetMethod("AsLogger") + ?.MakeGenericMethod(categoryType) + .Invoke(innerLogger, null); + + if (genericInnerLogger == null) + { + // Fallback to using the non-generic logger + return innerLogger; + } + + // Create our sanitizing logger with the properly typed inner logger + var sanitizingLoggerType = typeof(SanitizingLogger<>).MakeGenericType(categoryType); + return (ILogger)Activator.CreateInstance(sanitizingLoggerType, genericInnerLogger)!; + } + + public void Dispose() => _innerProvider.Dispose(); +} diff --git a/src/Models/Cache.cs b/src/Models/Cache.cs index f9da1df..345af81 100755 --- a/src/Models/Cache.cs +++ b/src/Models/Cache.cs @@ -2,6 +2,7 @@ namespace picoblog.Models; public static class Cache { + public static DateTime? LastUpdate = null; public static IList Models = new List(); public static IList PrivatePosts = new List(); } diff --git a/src/Program.cs b/src/Program.cs index abd0753..6fcd1e1 100755 --- a/src/Program.cs +++ b/src/Program.cs @@ -152,7 +152,7 @@ { var services = serviceScope.ServiceProvider; var monitorLoop = services.GetRequiredService(); - // monitorLoop.StartMonitorLoop(); + monitorLoop.Execute(); } var logger = app.Services.GetRequiredService>(); diff --git a/src/Services/MonitorLoop.cs b/src/Services/MonitorLoop.cs index 76fe05d..bcc9598 100644 --- a/src/Services/MonitorLoop.cs +++ b/src/Services/MonitorLoop.cs @@ -3,7 +3,7 @@ public class MonitorLoop : IDisposable { private readonly ILogger _logger; - private readonly FileSystemWatcher _watcher; + private FileSystemWatcher? _watcher; private readonly ConcurrentDictionary _modelCache; private readonly ConcurrentDictionary _privatePostsCache; private readonly object _updateLock = new object(); @@ -25,50 +25,46 @@ public MonitorLoop(ILogger logger, IHostApplicationLifetime applica _modelCache = new ConcurrentDictionary(); _privatePostsCache = new ConcurrentDictionary(); - if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != "Production") - { - _logger.LogInformation("Production environment detected - using polling mode"); - _watcher = null; - } - else + try { - try - { - EnsureInotifyLimits(); + EnsureInotifyLimits(); - _watcher = new FileSystemWatcher(Config.DataDir) - { - Filter = "*.md", - IncludeSubdirectories = true, - EnableRaisingEvents = false, - NotifyFilter = - NotifyFilters.FileName - | NotifyFilters.DirectoryName - | NotifyFilters.LastWrite, - }; - - _watcher.Created += FilterExcludedDirectories; - _watcher.Changed += FilterExcludedDirectories; - _watcher.Deleted += FilterExcludedDirectories; - _watcher.Renamed += FilterExcludedDirectoriesRenamed; - - // Register for cancellation - applicationLifetime.ApplicationStopping.Register(() => - { - Dispose(); - }); - } - catch (Exception ex) + var watcher = new FileSystemWatcher(Config.DataDir, filter: "*.md") { - _logger.LogError( - ex, - "Failed to initialize FileSystemWatcher. Falling back to polling." - ); - _watcher = null; - } + IncludeSubdirectories = true, + EnableRaisingEvents = true, + NotifyFilter = + NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite, + }; + + watcher.Created += FilterExcludedDirectories; + watcher.Changed += FilterExcludedDirectories; + watcher.Deleted += FilterExcludedDirectories; + watcher.Renamed += FilterExcludedDirectoriesRenamed; + watcher.EnableRaisingEvents = true; + + _watcher = watcher; + applicationLifetime.ApplicationStopping.Register(() => Dispose()); + + LoadAllFiles(); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to initialize FileSystemWatcher. Falling back to polling." + ); + _watcher = null; } } + public void Execute() + { + if (_watcher == null) + if (Cache.LastUpdate == null || Cache.LastUpdate < DateTime.Now.AddMinutes(-5)) + LoadAllFiles(); + } + private void FilterExcludedDirectoriesRenamed(object sender, RenamedEventArgs e) { if (!IsExcludedPath(e.FullPath) && !IsExcludedPath(e.OldFullPath)) @@ -79,9 +75,19 @@ private void FilterExcludedDirectoriesRenamed(object sender, RenamedEventArgs e) private void FilterExcludedDirectories(object sender, FileSystemEventArgs e) { + _logger.LogInformation("File changed {FullPath}", e.FullPath); if (!IsExcludedPath(e.FullPath)) { - OnFileChanged(sender, e); + switch (e.ChangeType) + { + case WatcherChangeTypes.Created: + case WatcherChangeTypes.Changed: + OnFileChanged(sender, e); + break; + case WatcherChangeTypes.Deleted: + OnFileDeleted(sender, e); + break; + } } } @@ -135,62 +141,6 @@ sudo sysctl -p" } } - public void StartMonitorLoop() - { - // Initial load of all files - LoadAllFiles(); - - if (_watcher != null) - { - try - { - // Disable watching subdirectories to avoid hitting inotify limits - _watcher.IncludeSubdirectories = false; - - // Setup watchers but filter out excluded directories - _watcher.Created += FilterExcludedDirectories; - _watcher.Changed += FilterExcludedDirectories; - _watcher.Deleted += FilterExcludedDirectories; - _watcher.Renamed += FilterExcludedDirectoriesRenamed; - _watcher.Error += OnWatcherError; - - // Start watching - _watcher.EnableRaisingEvents = true; - _logger.LogInformation( - "Started watching (top-level only) 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"); @@ -208,18 +158,29 @@ private void OnWatcherError(object sender, ErrorEventArgs e) catch (Exception ex) { _logger.LogError(ex, "Failed to restart FileSystemWatcher. Falling back to polling."); - StartPolling(); + _watcher?.Dispose(); + _watcher = null; } } private void LoadAllFiles() { _logger.LogInformation("Loading all existing markdown files"); - var files = Directory.EnumerateFiles(Config.DataDir, "*.md", SearchOption.AllDirectories); + DirectoryInfo diTop = new DirectoryInfo(Config.DataDir); - foreach (var file in files) + foreach (var di in diTop.EnumerateDirectories("*")) { - ProcessFile(file); + if (!IsExcludedPath(di.FullName)) + { + var files = di.EnumerateFiles("*.md", SearchOption.AllDirectories) + .Where(file => !IsExcludedPath(file.FullName)); + + foreach (var file in files) + { + ProcessFile(file.FullName); + } + } + _logger.LogInformation("Directory: {0}", di.Name); } UpdateCaches(); @@ -398,7 +359,7 @@ private void UpdateCaches() // Check for duplicates var duplicates = models - .GroupBy(p => new { p.Title, p.Date?.Year }) + .GroupBy(static p => new { p.Title, p.Date?.Year }) .Where(g => g.Count() > 1) .SelectMany(g => g.Skip(1)) .ToList(); @@ -421,6 +382,7 @@ private void UpdateCaches() // Update the global cache Cache.Models = models; Cache.PrivatePosts = privatePosts; + Cache.LastUpdate = DateTime.UtcNow; } }