712 lines
24 KiB
C#
712 lines
24 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
using Blog3000.Shared;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using System.IO;
|
|
using Blog3000.Server.MiddleWares;
|
|
using System.Text.Json;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
|
|
namespace Blog3000.Server
|
|
{
|
|
public class BlogPostRepo
|
|
{
|
|
public event EventHandler BlogPostsChanged;
|
|
|
|
private static object updateLock = new object();
|
|
private static object idLock = new object();
|
|
private static long lastUsedTs;
|
|
|
|
private static int CACHETIMEOUTMINS = 5;
|
|
private static string BLOGPOSTKEY = "blog-posts-cache-key";
|
|
private static string BLOGPOSTUPDTIMEKEY = "blog-posts-upd-time-keycache-key";
|
|
|
|
private bool forceCacheUpdate = false;
|
|
|
|
private Dictionary<string, string> checksumsById = null;
|
|
|
|
private readonly string _blogPostPath;
|
|
private readonly string checksumCacheFile;
|
|
|
|
private readonly ILogger<BlogPostRepo> logger;
|
|
private readonly IMemoryCache cache;
|
|
|
|
public BlogPostRepo(ILogger<BlogPostRepo> logger, BlogEnv config, IMemoryCache cache)
|
|
{
|
|
this.logger = logger;
|
|
this.cache = cache;
|
|
|
|
this._blogPostPath = System.IO.Path.Combine(
|
|
//(string)AppDomain.CurrentDomain.GetData("AppDataPath"),
|
|
config.AppDataPath,
|
|
"posts");
|
|
|
|
this.checksumCacheFile = System.IO.Path.Combine(
|
|
//(string)AppDomain.CurrentDomain.GetData("AppDataPath"),
|
|
config.AppDataPath,
|
|
"checksumcache.json");
|
|
|
|
UpdateCache();
|
|
}
|
|
|
|
|
|
public string BlogPostRoot { get { return _blogPostPath; } }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// CreateId based on timestamp + author.
|
|
/// - Time no allowed to go backwards, especially when Service not running
|
|
/// - Not multiinstance/multihosts aware
|
|
/// </summary>
|
|
public static void CreateId(BlogPostHeader b)
|
|
{
|
|
if (String.IsNullOrEmpty(b.Id) && !String.IsNullOrEmpty(b.Author))
|
|
{
|
|
lock (idLock)
|
|
{
|
|
var span = DateTime.UtcNow - new DateTime(2001, 1, 1);
|
|
long ts = (long)(span.TotalMilliseconds / 10); // 1/10ms resolution
|
|
if (ts <= lastUsedTs) // Time went backwards
|
|
{
|
|
ts = lastUsedTs + 1;
|
|
}
|
|
lastUsedTs = ts;
|
|
b.Id = CreateId(b.Author, ts);
|
|
System.Threading.Thread.Sleep(10);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("Id already set or author missing");
|
|
}
|
|
}
|
|
|
|
public void ClearCache()
|
|
{
|
|
forceCacheUpdate = true;
|
|
}
|
|
|
|
/// <summary>
|
|
///
|
|
/// </summary>
|
|
/// <returns>cached list of posts</returns>
|
|
public List<BlogPostHeader> GetHeaders()
|
|
{
|
|
List<BlogPostHeader> res = GetHeaderFromCache();
|
|
if (res == null)
|
|
{
|
|
res = UpdateCache();
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
|
|
public string GetSafeFilePath(BlogPostHeader b)
|
|
{
|
|
return GetSafeFilePath(b.Author, b.Filename);
|
|
}
|
|
|
|
|
|
|
|
public DateTime? GetFileModDate(BlogPostHeader b)
|
|
{
|
|
try
|
|
{
|
|
var p = GetSafeFilePath(b);
|
|
var res = System.IO.File.GetLastWriteTimeUtc(p);
|
|
return res;
|
|
}
|
|
catch (IOException ioex)
|
|
{
|
|
logger.LogWarning("Error gettime writetime of file", ioex);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
public string GetText(BlogPostHeader b)
|
|
{
|
|
var p = GetSafeFilePath(b);
|
|
var sb = new StringBuilder();
|
|
|
|
bool skipMode = true;
|
|
try
|
|
{
|
|
foreach (var s in System.IO.File.ReadAllLines(p))
|
|
{
|
|
if (skipMode && !s.StartsWith("@")) skipMode = false;
|
|
if (!skipMode)
|
|
{
|
|
sb.AppendLine(s);
|
|
}
|
|
}
|
|
}
|
|
catch (System.IO.IOException fex)
|
|
{
|
|
logger.LogWarning("Error reading file", fex);
|
|
return null;
|
|
}
|
|
|
|
|
|
return sb
|
|
.Replace("$PUBLICDL$", "PublicDl")
|
|
.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Images for a given blogfn.md must reside in folder "blogfn-without-ext/fn"
|
|
/// </summary>
|
|
/// <param name="b"></param>
|
|
/// <param name="imgRef"></param>
|
|
/// <returns></returns>
|
|
public string GetImgPath(BlogPostHeader b, string imgRef)
|
|
{
|
|
string imgRelPath = null;
|
|
if (b.ImageRefs == null || !b.ImageRefs.TryGetValue(imgRef, out imgRelPath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var p = GetSafeImgFilePath(b.Author, b.Filename, System.IO.Path.GetFileName(imgRelPath));
|
|
try
|
|
{
|
|
//var bytes = System.IO.File.ReadAllBytes(p);
|
|
//return bytes;
|
|
if (System.IO.File.Exists(p))
|
|
{
|
|
return p;
|
|
}
|
|
}
|
|
catch (System.IO.IOException fex)
|
|
{
|
|
logger.LogWarning("Error reading file", fex);
|
|
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
private BlogPostHeader FromFile(string f)
|
|
{
|
|
BlogPostHeader res = null;
|
|
|
|
if (f != null)
|
|
{
|
|
bool isDraft = false;
|
|
|
|
using (var stream = System.IO.File.OpenText(f))
|
|
{
|
|
string s = null;
|
|
|
|
while ((s = stream.ReadLine()) != null)
|
|
{
|
|
if (s.StartsWith("@")) // Meta-headers without parameter
|
|
{
|
|
var parts = s.Split(':', 2);
|
|
if (parts.Length == 1)
|
|
{
|
|
switch (parts[0])
|
|
{
|
|
case "@Draft":
|
|
isDraft = true;
|
|
break;
|
|
case "@NoSocialMediaInfo":
|
|
res.NoSocialMediaInfo = true;
|
|
break;
|
|
}
|
|
}
|
|
else if (parts.Length == 2) // Meta-Headers with parameters
|
|
{
|
|
if (res == null) res = new BlogPostHeader();
|
|
res.Filename = System.IO.Path.GetFileName(f);
|
|
|
|
switch (parts[0])
|
|
{
|
|
case "@Draft":
|
|
isDraft = true;
|
|
break;
|
|
case "@Id":
|
|
res.Id = parts[1];
|
|
break;
|
|
case "@Lang":
|
|
var rr = parts[1];
|
|
if (!String.IsNullOrEmpty(rr))
|
|
{
|
|
res.Lang = rr.ToLowerInvariant();
|
|
}
|
|
break;
|
|
case "@Title":
|
|
res.Title = parts[1];
|
|
break;
|
|
case "@Author":
|
|
res.Author = parts[1];
|
|
break;
|
|
case "@Abstract":
|
|
res.Abstract = parts[1];
|
|
break;
|
|
case "@Access":
|
|
res.Access = parts[1];
|
|
break;
|
|
case "@StickyMenuPos":
|
|
int pos;
|
|
if (Int32.TryParse(parts[1], out pos))
|
|
{
|
|
res.StickyMenuPos = pos;
|
|
}
|
|
break;
|
|
case "@Topics":
|
|
res.Topics = new List<string>((parts[1] ?? "").Split("|"));
|
|
break;
|
|
case "@Revision":
|
|
var p = (parts[1] ?? "").Split("|");
|
|
try
|
|
{
|
|
Revision r = new Revision();
|
|
if (p.Length >= 1)
|
|
{
|
|
DateTime dt;
|
|
if (DateTime.TryParse(p[0], out dt))
|
|
{
|
|
r.ChangedAt = dt;
|
|
}
|
|
}
|
|
r.Description = p.Length > 1 ? p[1] : null;
|
|
r.Author = p.Length > 2 ? p[2] : null;
|
|
res.Revisions ??= new List<Revision>();
|
|
res.Revisions.Add(r);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
logger.LogWarning($"Error parsing Revision on file {f}");
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else if (s.StartsWith("[")) // ImageRefs
|
|
{
|
|
var match = Regex.Match(s, @"\[(.+)\]:(.+)");
|
|
if (match.Success && match.Groups.Count > 2)
|
|
{
|
|
res.ImageRefs ??= new Dictionary<String, String>();
|
|
res.ImageRefs.Add(match.Groups[1].Value.Trim(), match.Groups[2].Value.Trim());
|
|
}
|
|
//
|
|
}
|
|
else if (!String.IsNullOrWhiteSpace(s)) // Non-empty string
|
|
{
|
|
// End of header+imagerefs
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isDraft)
|
|
{
|
|
bool rewriteRevisions = false;
|
|
if ((res.Revisions?.Count ?? 0) == 0)
|
|
{
|
|
res.Revisions ??= new List<Revision>();
|
|
var r = new Revision();
|
|
r.Description = "Initial";
|
|
res.Revisions.Add(r);
|
|
}
|
|
|
|
foreach (var r in res.Revisions)
|
|
{
|
|
if (String.IsNullOrWhiteSpace(r.Author) && (!String.IsNullOrWhiteSpace(res.Author)))
|
|
{
|
|
r.Author = res.Author;
|
|
rewriteRevisions = true;
|
|
}
|
|
|
|
if (r.ChangedAt == null)
|
|
{
|
|
r.ChangedAt ??= DateTime.UtcNow;
|
|
rewriteRevisions = true;
|
|
}
|
|
}
|
|
|
|
|
|
bool rewriteId = false;
|
|
if (String.IsNullOrEmpty(res.Id) && !String.IsNullOrWhiteSpace(res.Author))
|
|
{
|
|
CreateId(res);
|
|
rewriteId = true;
|
|
}
|
|
|
|
if (rewriteId || rewriteRevisions)
|
|
{
|
|
var lines = new List<string>(System.IO.File.ReadAllLines(f));
|
|
var outlines = new List<string>();
|
|
outlines.Add($"@Id:{res.Id}");
|
|
foreach (var r in res.Revisions)
|
|
{
|
|
outlines.Add($"@Revision:{r.ChangedAt}|{r.Description}|{r.Author}");
|
|
}
|
|
bool outOfHeader = false;
|
|
foreach (var ln in lines)
|
|
{
|
|
if (!outOfHeader)
|
|
{
|
|
if (ln.StartsWith("@Id") || (ln.StartsWith("@Revision"))) continue;
|
|
if (!ln.StartsWith("@")) outOfHeader = true;
|
|
}
|
|
outlines.Add(ln);
|
|
}
|
|
System.IO.File.WriteAllLines(f, outlines, Encoding.UTF8);
|
|
}
|
|
}
|
|
|
|
|
|
if (!isDraft && BlogPostHeader.IsUsable(res))
|
|
{
|
|
res.Checksum = CalcCheckSum(res);
|
|
}
|
|
else
|
|
{
|
|
res = null;
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
|
|
#region Caching stuff
|
|
|
|
|
|
/// <summary>
|
|
/// Returns content of cache, updates cache if required
|
|
/// </summary>
|
|
private List<BlogPostHeader> GetHeaderFromCache()
|
|
{
|
|
List<BlogPostHeader> res = null;
|
|
|
|
if (forceCacheUpdate)
|
|
{
|
|
lock(updateLock)
|
|
{
|
|
if (forceCacheUpdate) // Cache still needs update?
|
|
{
|
|
UpdateCache();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cache.TryGetValue(BLOGPOSTKEY, out res))
|
|
{
|
|
if (IsCacheExpired())
|
|
{
|
|
lock (updateLock)
|
|
{
|
|
// Recheck if cache is still expired after we acquired lock
|
|
if (IsCacheExpired())
|
|
{
|
|
res = UpdateCache();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
|
|
private bool IsCacheExpired()
|
|
{
|
|
DateTime dt;
|
|
if (cache.TryGetValue(BLOGPOSTUPDTIMEKEY, out dt))
|
|
{
|
|
if (dt != null && dt.AddMinutes(CACHETIMEOUTMINS).CompareTo(DateTime.UtcNow) < 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
///
|
|
/// </summary>
|
|
/// <returns>The new data</returns>
|
|
internal List<BlogPostHeader> UpdateCache()
|
|
{
|
|
lock (updateLock)
|
|
{
|
|
logger.LogWarning($"Updating BlogPostCache");
|
|
var res = new List<BlogPostHeader>();
|
|
|
|
foreach (var s in System.IO.Directory.GetFiles(
|
|
BlogPostRoot,
|
|
"*.md",
|
|
System.IO.SearchOption.AllDirectories)
|
|
)
|
|
{
|
|
var be = FromFile(s);
|
|
if (be != null)
|
|
{
|
|
res.Add(be);
|
|
}
|
|
else
|
|
{
|
|
logger.LogWarning($"Post {s} not usable");
|
|
}
|
|
}
|
|
|
|
|
|
cache.Set(BLOGPOSTKEY, res);
|
|
cache.Set(BLOGPOSTUPDTIMEKEY, DateTime.UtcNow);
|
|
forceCacheUpdate = false;
|
|
|
|
// Compare with cached checksum for changes
|
|
bool hasChanges = false;
|
|
bool newChache = false;
|
|
|
|
if (checksumsById == null)
|
|
{
|
|
checksumsById = LoadCheckSumsById();
|
|
if (checksumsById == null)
|
|
{
|
|
checksumsById = new Dictionary<string, string>();
|
|
newChache = true;
|
|
}
|
|
}
|
|
|
|
var dcCopy = new Dictionary<string, string>(checksumsById ?? new Dictionary<string, string>());
|
|
foreach (var bp in res)
|
|
{
|
|
string cs = null;
|
|
if (dcCopy.TryGetValue(bp.Id, out cs))
|
|
{
|
|
if (!String.Equals(cs, bp.Checksum))
|
|
{
|
|
hasChanges = true;
|
|
break;
|
|
}
|
|
dcCopy.Remove(bp.Id);
|
|
}
|
|
else
|
|
{
|
|
hasChanges = true;
|
|
break;
|
|
}
|
|
}
|
|
if (dcCopy.Count > 0)
|
|
{
|
|
hasChanges = true;
|
|
}
|
|
|
|
// Write new id/checksum dict
|
|
var dc = new Dictionary<string, string>();
|
|
foreach (var bp in res)
|
|
{
|
|
dc.Add(bp.Id, bp.Checksum);
|
|
}
|
|
checksumsById = dc;
|
|
SaveCheckSumsById(checksumsById);
|
|
|
|
// Notify listenera about changes
|
|
if (hasChanges)
|
|
{
|
|
if (newChache)
|
|
{
|
|
logger.LogWarning("BlogPosts changed, but new or bad cache, not notifying listeners");
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("BlogPosts changed, notifying listeners");
|
|
BlogPostsChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("BlogPosts not changed, not notifying listeners");
|
|
}
|
|
|
|
return res;
|
|
}
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
#region File+Path utils
|
|
|
|
private string GetSafeFilePath(string author, string filename)
|
|
{
|
|
|
|
var at = CheckPathPart(author);
|
|
var fn = CheckPathPart(filename);
|
|
|
|
return System.IO.Path.Combine(BlogPostRoot, at, fn);
|
|
}
|
|
|
|
private string GetSafeImgFilePath(string author, string blogFilenmame, string imgFilename)
|
|
{
|
|
var at = CheckPathPart(author);
|
|
var bt = System.IO.Path.GetFileNameWithoutExtension(CheckPathPart(blogFilenmame));
|
|
var it = CheckPathPart(imgFilename);
|
|
|
|
return System.IO.Path.Combine(BlogPostRoot, at, bt, it);
|
|
}
|
|
|
|
|
|
private static string CheckPathPart(string part)
|
|
{
|
|
if (String.IsNullOrWhiteSpace(part))
|
|
{
|
|
throw new ArgumentException($"null or empty?");
|
|
}
|
|
|
|
var pt = System.IO.Path.GetFileName(part.Trim());
|
|
if (!String.Equals(part, pt))
|
|
{
|
|
throw new ArgumentException($"contains invalid path-information?");
|
|
}
|
|
return pt;
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
private string CalcCheckSum(BlogPostHeader b)
|
|
{
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.Append(b.Id ?? "");
|
|
sb.Append("|");
|
|
sb.Append(b.Filename ?? "");
|
|
sb.Append("|");
|
|
//sb.Append(res.Title ?? "");
|
|
//sb.Append("|");
|
|
//sb.Append(res.Author ?? "");
|
|
//sb.Append("|");
|
|
//sb.Append(res.Access ?? "");
|
|
//sb.Append("|");
|
|
//res.Topics.ForEach(t => sb.Append(t + ","));
|
|
//sb.Append(res.Revisions?.Count ?? 0);
|
|
//sb.Append("|");
|
|
//sb.Append(res.Abstract ?? "");
|
|
//sb.Append("|");
|
|
//sb.Append(res.StickyMenuPos ?? -1);
|
|
//sb.Append("|");
|
|
if (b.ImageRefs != null)
|
|
{
|
|
foreach (var fn in b.ImageRefs.Values.OrderBy(s => s))
|
|
{
|
|
var fno = System.IO.Path.GetFileName(fn);
|
|
var rfp = GetSafeImgFilePath(b.Author, b.Filename, fno);
|
|
var bytes = new byte[] { };
|
|
try
|
|
{
|
|
bytes = System.IO.File.ReadAllBytes(rfp);
|
|
}
|
|
catch (System.IO.IOException ex)
|
|
{
|
|
logger.LogWarning($"ImgFile {rfp} not readable");
|
|
}
|
|
var sha = System.Convert.ToBase64String(
|
|
System.Security.Cryptography.SHA256.Create().ComputeHash(bytes)
|
|
);
|
|
sb.Append(sha);
|
|
sb.Append(";");
|
|
}
|
|
}
|
|
|
|
var f = GetSafeFilePath(b.Author, b.Filename);
|
|
sb.Append(System.IO.File.ReadAllText(f));
|
|
|
|
return
|
|
System.Convert.ToBase64String(
|
|
System.Security.Cryptography.SHA256.Create().ComputeHash(
|
|
System.Text.Encoding.UTF8.GetBytes(sb.ToString())
|
|
)
|
|
); ;
|
|
}
|
|
|
|
|
|
private Dictionary<string, string> LoadCheckSumsById()
|
|
{
|
|
Dictionary<string, string> res = null;
|
|
try
|
|
{
|
|
if (System.IO.File.Exists(checksumCacheFile))
|
|
{
|
|
var text = System.IO.File.ReadAllText(checksumCacheFile);
|
|
res = JsonSerializer.Deserialize<Dictionary<string, string>>(text);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, $"Error reading checksumcache file ${checksumCacheFile}");
|
|
}
|
|
|
|
return res;
|
|
|
|
}
|
|
|
|
|
|
private bool SaveCheckSumsById(Dictionary<string, string> cbcById)
|
|
{
|
|
cbcById ??= new Dictionary<string, string>();
|
|
|
|
try
|
|
{
|
|
var text = JsonSerializer.Serialize(cbcById);
|
|
System.IO.File.WriteAllText(checksumCacheFile, text);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, $"Error writing checksumCache file ${checksumCacheFile}");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
private static string CreateId(string author, long ts)
|
|
{
|
|
const string radixChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefhijklmnopqrstuvwxyz";
|
|
|
|
|
|
string res = "";
|
|
|
|
void radixer(long l, ref string res)
|
|
{
|
|
do
|
|
{
|
|
var r = l % radixChars.Length;
|
|
res = radixChars[(int)r] + res;
|
|
l = (l - r) / radixChars.Length;
|
|
|
|
}
|
|
while (l > 0);
|
|
}
|
|
|
|
radixer(ts, ref res);
|
|
|
|
var authorPrefixBytes = System.Text.Encoding.UTF8.GetBytes(author);
|
|
foreach (var b in authorPrefixBytes)
|
|
{
|
|
radixer(b, ref res);
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
}
|
|
}
|