using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Xml; using Tofvesson.Crypto; using Tofvesson.Collections; namespace Server { public sealed class Database { private static readonly RandomProvider random = new RegularRandomProvider(); public string[] MasterEntry { get; } public string DatabaseName { get; } public bool LoadFull { get; set; } // Cached changes private readonly List changeList = new List(); private readonly List toRemove = new List(); private readonly EvictionList loadedUsers = new EvictionList(40); public Database(string dbName, string master, bool loadFullDB = false) { dbName += ".xml"; MasterEntry = master.Split('/'); if (!File.Exists(dbName)) { FileStream strm = File.Create(dbName); byte[] b; strm.Write(b = Encoding.UTF8.GetBytes($"\n"), 0, b.Length); // Generate root element for users for (int i = 0; i < MasterEntry.Length; ++i) strm.Write(b = Encoding.UTF8.GetBytes($"<{MasterEntry[i]}>"), 0, b.Length); for (int i = MasterEntry.Length - 1; i >= 0; --i) strm.Write(b = Encoding.UTF8.GetBytes($""), 0, b.Length); strm.Close(); } DatabaseName = dbName; LoadFull = loadFullDB; } // Flush before deletion ~Database() { Flush(false); } // UpdateUser is just another name for AddUser public void UpdateUser(User entry) => AddUser(entry, true); public void AddUser(User entry) => AddUser(entry, true); private void AddUser(User entry, bool withFlush) { for (int i = 0; i < loadedUsers.Count; ++i) if (entry.Name.Equals(loadedUsers[i].Name)) loadedUsers[i] = entry; for (int i = toRemove.Count - 1; i >= 0; --i) if (toRemove[i].Name.Equals(entry.Name)) toRemove.RemoveAt(i); for (int i = 0; i < changeList.Count; ++i) if (changeList[i].Name.Equals(entry.Name)) { changeList[i] = entry; return; } changeList.Add(entry); if(withFlush) Flush(true); } public void RemoveUser(User entry) => RemoveUser(entry, true); private void RemoveUser(User entry, bool withFlush) { // Remove from loaded users collection for (int i = 0; i < loadedUsers.Count; ++i) if (entry.Name.Equals(loadedUsers[i].Name)) loadedUsers.RemoveAt(i); // Changes are retracted from change collectino for (int i = changeList.Count - 1; i >= 0; --i) if (changeList[i].Name.Equals(entry.Name)) changeList.RemoveAt(i); // Check if user already is scheduled for deletion for (int i = toRemove.Count - 1; i >= 0; --i) if (toRemove[i].Name.Equals(entry.Name)) return; toRemove.Add(entry); if(withFlush) Flush(true); } // Triggers a forceful flush public void Flush() => Flush(false); // Permissive (cache-dependent) flush private void Flush(bool optional) { if(optional && (changeList.Count < 30 && toRemove.Count < 30)) return; // No need to flush string temp = GenerateTempFileName("tmp_", ".xml"); using(var writer = XmlWriter.Create(temp)) { using(var reader = XmlReader.Create(DatabaseName)) { int masterDepth = 0; bool trigger = false, wn = false, recent = false, justTriggered = false; while (wn || reader.Read() || (reader.NodeType==XmlNodeType.None && justTriggered)) { justTriggered = false; wn = false; if (trigger) { foreach (var user in changeList) WriteUser(writer, user); bool wroteNode = false; while ((wroteNode || reader.Name.Equals("User") || reader.Read()) && reader.NodeType != XmlNodeType.EndElement && reader.NodeType != XmlNodeType.None) { wroteNode = false; if (reader.Name.Equals("User")) { User u = FromEncoded(User.Parse(ReadEntry(reader), this)); if (u != null) { bool shouldWrite = true; foreach (var toChange in changeList) if (toChange.Name.Equals(u.Name)) { shouldWrite = false; break; } if (shouldWrite) foreach (var remove in toRemove) if (remove.Name.Equals(u.Name)) { shouldWrite = false; break; } if (shouldWrite) WriteUser(writer, u); } } else { wroteNode = true; writer.WriteNode(reader, true); } } trigger = false; recent = true; writer.WriteEndElement(); toRemove.Clear(); changeList.Clear(); } if (masterDepth != MasterEntry.Length && reader.Name.Equals(MasterEntry[masterDepth])) { trigger = reader.NodeType == XmlNodeType.Element && ++masterDepth == MasterEntry.Length; justTriggered = true; reader.MoveToContent(); writer.WriteStartElement(MasterEntry[masterDepth - 1]); } else if (masterDepth == MasterEntry.Length && recent) { if(masterDepth!=1) writer.WriteEndElement(); recent = false; } else { wn = true; writer.WriteNode(reader, true); } } } writer.Flush(); } File.Delete(DatabaseName); File.Move(temp, DatabaseName); } private static void WriteUser(XmlWriter writer, User u) { u = ToEncoded(u); writer.WriteStartElement("User"); if (u.IsAdministrator) writer.WriteAttributeString("admin", "", "true"); writer.WriteElementString("Name", u.Name); //writer.WriteElementString("Balance", u.Balance.ToString()); writer.WriteElementString("Password", u.PasswordHash); writer.WriteElementString("Salt", u.Salt); foreach(var acc in u.accounts) { writer.WriteStartElement("Account"); writer.WriteElementString("Name", acc.name); writer.WriteElementString("Balance", acc.UncomputedBalance.ToString()); writer.WriteElementString("ChangeTime", acc.dateUpdated.ToString()); writer.WriteElementString("Type", ((int)acc.type).ToString()); foreach (var tx in acc.History) { writer.WriteStartElement("Transaction"); writer.WriteElementString("FromAccount", tx.fromAccount); writer.WriteElementString("ToAccount", tx.toAccount); writer.WriteElementString(tx.to.Equals(u.Name) ? "From" : "To", tx.to.Equals(u.Name) ? tx.from : tx.to); writer.WriteElementString("Balance", tx.amount.ToString()); if (tx.meta != null && tx.meta.Length != 0) writer.WriteElementString("Meta", tx.meta); writer.WriteEndElement(); } writer.WriteEndElement(); } writer.WriteEndElement(); } private static string GenerateTempFileName(string prefix, string suffix) { string s; do s = prefix + random.NextString((Math.Abs(random.NextInt())%16)+8) + suffix; while (File.Exists(s)); return s; } private Entry ReadEntry(XmlReader reader) { Entry e = new Entry(reader.Name); if (reader.HasAttributes) { reader.MoveToAttribute(0); do e.Attributes.Add(reader.Name, reader.Value); while (reader.MoveToNextAttribute()); } reader.MoveToContent(); while (reader.Read() && reader.NodeType != XmlNodeType.EndElement) { if (reader.NodeType == XmlNodeType.Element) using (var subRead = reader.ReadSubtree()) { SkipSpaces(subRead); e.NestedEntries.Add(ReadEntry(subRead)); } else if (reader.NodeType == XmlNodeType.Text) e.Text = reader.Value; } reader.Read(); return e; } public User GetUser(string name) => name.Equals("System") ? null : FirstUser(u => u.Name.Equals(name)); public User FirstUser(Predicate p) { if (p == null) return null; // Done to conveniently handle system insertions // Check if user is scheduled for removal foreach (var entry in toRemove) if (p(entry)) return null; // Check loaded users foreach (var entry in loadedUsers) if (p(entry)) return entry; // Check modified users foreach (var entry in changeList) if (p(entry)) { if (!loadedUsers.Contains(entry)) loadedUsers.Add(entry); return entry; } // Read from database using (var reader = XmlReader.Create(DatabaseName)) { if (!Traverse(reader, MasterEntry)) return null; while ((reader.Name.Equals("User") && reader.NodeType != XmlNodeType.EndElement) || (reader.Read() && reader.NodeType != XmlNodeType.EndElement)) { if (reader.Name.Equals("User")) { User n = FromEncoded(User.Parse(ReadEntry(reader), this)); if (n != null && p(n)) { if (!loadedUsers.Contains(n)) loadedUsers.Add(n); return n; } } } } return null; } public bool AddTransaction(string sender, string recipient, decimal amount, string fromAccount, string toAccount, decimal ratePerDay, string message = null) { User from = FirstUser(u => u.Name.Equals(sender)); User to = FirstUser(u => u.Name.Equals(recipient)); Account fromAcc = from?.GetAccount(fromAccount); Account toAcc = to.GetAccount(toAccount); // Errant states if ( to == null || (from == null && !to.IsAdministrator) || toAcc == null || (from != null && fromAcc == null) || (from != null && fromAcc.ComputeBalance(ratePerDay) p) { List l = new List(); // Get changed users foreach (var entry in changeList) if (p(entry)) l.Add(entry); // Get loaded users foreach(var entry in loadedUsers) if (!l.Contains(entry) && p(entry)) l.Add(entry); // Get from database using (var reader = XmlReader.Create(DatabaseName)) { if (!Traverse(reader, MasterEntry)) return null; while ((reader.Name.Equals("User") && reader.NodeType != XmlNodeType.EndElement) || (reader.Read() && reader.NodeType != XmlNodeType.EndElement)) { if (reader.Name.Equals("User")) { User n = FromEncoded(User.Parse(ReadEntry(reader), this)); if (n != null && p(n)) { if (!l.Contains(n)) l.Add(n); } } } } // Remove users scheduled for eviction foreach(var user in toRemove) for(int i = l.Count - 1; i>=0; --i) if (l[i].Name.Equals(user.Name)) { l.RemoveAt(i); i = -1; } return l.ToArray(); } public bool ContainsUser(string user) => user.Equals("System") || FirstUser(u => u.Name.Equals(user)) != null; public bool ContainsUser(User user) => user.Name.Equals("System") || FirstUser(u => u.Name.Equals(user.Name)) != null; private bool Traverse(XmlReader reader, params string[] downTo) { for(int i = 0; i Convert.ToBase64String(Encoding.UTF8.GetBytes(s)); private static string Decode(string s) => Convert.FromBase64String(s).ToUTF8String(); internal class Entry { public string Text { get; set; } public string Name { get; set; } public Dictionary Attributes { get; } // Transient properties in comparison public List NestedEntries { get; } // Semi-transient comparison properties public Entry(string name, string text = "") { Name = name; Text = text; Attributes = new Dictionary(); NestedEntries = new List(); } public Entry GetNestedEntry(Predicate p) { foreach (var entry in NestedEntries) if (p(entry)) return entry; return null; } public bool BoolAttribute(string attrName, bool def = false, bool ignoreCase = true) => Attributes.ContainsKey(attrName) && bool.TryParse(ignoreCase ? Attributes[attrName].ToLower() :Attributes[attrName], out bool b) ? b : def; public Entry AddNested(Entry e) { NestedEntries.Add(e); return this; } public Entry AddNested(string name, string text) => AddNested(new Entry(name, text)); public Entry AddAttribute(string key, string value) { Attributes[key] = value; return this; } public override bool Equals(object obj) { if (!(obj is Entry)) return false; Entry cmp = (Entry)obj; if (cmp.Attributes.Count != Attributes.Count || cmp.NestedEntries.Count != NestedEntries.Count || !Text.Equals(cmp.Text) || !Name.Equals(cmp.Name)) return false; foreach(var entry in NestedEntries) { if (entry.BoolAttribute("omit")) goto Next; foreach (var cmpEntry in cmp.NestedEntries) if (cmpEntry.BoolAttribute("omit")) continue; else if (cmpEntry.Equals(entry)) goto Next; return false; Next: { } } return true; } public override int GetHashCode() { var hashCode = 495068346; hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Text); hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Name); hashCode = hashCode * -1521134295 + EqualityComparer>.Default.GetHashCode(Attributes); hashCode = hashCode * -1521134295 + EqualityComparer>.Default.GetHashCode(NestedEntries); return hashCode; } } public sealed class Account { public enum AccountType { Savings, Checking } public User owner; private decimal _balance; public string name; public List History { get; } public AccountType type; public long dateUpdated; public decimal UncomputedBalance { get => _balance; set { _balance = value; dateUpdated = DateTime.Now.Ticks; } } public Account(User owner, decimal balance, string name, AccountType type, long dateUpdated) { History = new List(); this.owner = owner; this._balance = balance; this.name = name; this.type = type; this.dateUpdated = dateUpdated; } // Create a deep copy of the given account public Account(Account copy) : this(copy.owner, copy._balance, copy.name, copy.type, copy.dateUpdated) { // Value copy, not reference copy foreach (var tx in copy.History) History.Add(new Transaction(tx.from, tx.to, tx.amount, tx.meta, tx.fromAccount, tx.toAccount)); } // Add a transaction to the tx history public Account AddTransaction(Transaction tx) { History.Add(tx); return this; } // Compute balance growth with a daily rate for savings accounts (no growth for checking accounts) public decimal ComputeBalance(decimal ratePerDay) => _balance + (type==AccountType.Savings ? (((decimal)(DateTime.Now.Ticks - dateUpdated) / TimeSpan.TicksPerDay) * ratePerDay * _balance) : 0); public decimal UpdateBalance(decimal change, decimal ratePerDay) { decimal d = ComputeBalance(ratePerDay) + change; dateUpdated = DateTime.Now.Ticks; return _balance = d; } public string ToString(decimal ratePerDay) { StringBuilder builder = new StringBuilder(ComputeBalance(ratePerDay).ToString()).Append('&').Append((int)type); // Format: balance&type (ex: 123.45&0) foreach (var tx in History) { builder .Append('{') .Append(tx.from.ToBase64String()) .Append('&') .Append(tx.fromAccount.ToBase64String()) .Append('&') .Append(tx.to.ToBase64String()) .Append('&') .Append(tx.toAccount.ToBase64String()) .Append('&') .Append(tx.amount.ToString()); if (tx.meta != null) builder.Append('&').Append(tx.meta.ToBase64String()); } return builder.ToString(); } } public class User { public bool ProblematicTransactions { get; internal set; } public string Name { get; internal set; } public bool IsAdministrator { get; set; } public string PasswordHash { get; internal set; } public string Salt { get; internal set; } public List accounts = new List(); private User() { } public User(User copy) { this.ProblematicTransactions = copy.ProblematicTransactions; this.Name = copy.Name; this.IsAdministrator = copy.IsAdministrator; this.PasswordHash = copy.PasswordHash; this.Salt = copy.Salt; foreach (var acc in copy.accounts) accounts.Add(new Account(acc)); } public User(string name, string passHash, string salt, bool generatePass = false, bool admin = false) : this(name, passHash, Encoding.UTF8.GetBytes(salt), generatePass, admin) { } public User(string name, string passHash, byte[] salt, bool generatePass = false, bool admin = false) { Name = name; IsAdministrator = admin; Salt = Convert.ToBase64String(salt); PasswordHash = generatePass ? ComputePass(passHash) : passHash; } public string ComputePass(string pass) => Convert.ToBase64String(KDF.PBKDF2(KDF.HMAC_SHA1, Encoding.UTF8.GetBytes(pass), Encoding.UTF8.GetBytes(Salt), 8192, 320)); public bool Authenticate(string password) => Convert.ToBase64String(KDF.PBKDF2(KDF.HMAC_SHA1, Encoding.UTF8.GetBytes(password), Encoding.UTF8.GetBytes(Salt), 8192, 320)).Equals(PasswordHash); public void AddAccount(Account a) => accounts.Add(a); public Account GetAccount(string name) => accounts.FirstOrDefault(a => a.name.Equals(name)); private Entry Serialize() { Entry root = new Entry("User") .AddNested(new Entry("Name", Name)); foreach (var account in accounts) { Entry acc = new Entry("Account") .AddNested("Name", account.name) .AddNested(new Entry("Balance", account.UncomputedBalance.ToString()).AddAttribute("omit", "true")) .AddNested("ChangeTime", account.dateUpdated.ToString()) .AddNested("Type", ((int)account.type).ToString()); foreach (var transaction in account.History) { Entry tx = new Entry("Transaction") .AddAttribute("omit", "true") .AddNested(transaction.to.Equals(Name) ? "From" : "To", transaction.to.Equals(Name) ? transaction.from : transaction.to) .AddNested("FromAccount", transaction.fromAccount) .AddNested("ToAccount", transaction.toAccount) .AddNested("Balance", transaction.amount.ToString()); if (transaction.meta != null) tx.AddNested("Meta", transaction.meta); acc.AddNested(tx); } root.AddNested(acc); } return root; } internal static User Parse(Entry e, Database db) { if (!e.Name.Equals("User")) return null; User user = new User(); foreach (var attribute in e.Attributes) if (attribute.Key.Equals("admin") && attribute.Value.Equals("true")) user.IsAdministrator = true; foreach (var entry in e.NestedEntries) { if (entry.Name.Equals("Name")) user.Name = entry.Text; else if (entry.Name.Equals("Account")) { string name = null; decimal balance = 0; long changeTime = -1; Account.AccountType type = Account.AccountType.Savings; List history = new List(); foreach (var accountData in entry.NestedEntries) { if (accountData.Name.Equals("Name")) name = accountData.Text; else if (accountData.Name.Equals("Transaction")) { string fromAccount = null; string toAccount = null; string from = null; string to = null; decimal amount = -1; string meta = ""; foreach (var e1 in accountData.NestedEntries) { if (e1.Name.Equals("To")) to = e1.Text; else if (e1.Name.Equals("From")) from = e1.Text; else if (e1.Name.Equals("FromAccount")) fromAccount = e1.Text; else if (e1.Name.Equals("ToAccount")) toAccount = e1.Text; else if (e1.Name.Equals("Balance")) amount = decimal.TryParse(e1.Text, out amount) ? amount : 0; else if (e1.Name.Equals("Meta")) meta = e1.Text; } if ( // Errant states for transaction data (from == null && to == null) || (from != null && to != null) || amount <= 0 || fromAccount == null || toAccount == null ) user.ProblematicTransactions = true; else history.Add(new Transaction(from, to, amount, meta, fromAccount, toAccount)); } else if (accountData.Name.Equals("Balance")) balance = decimal.TryParse(accountData.Text, out decimal l) ? l : 0; else if (accountData.Name.Equals("ChangeTime")) changeTime = long.TryParse(accountData.Text, out changeTime) ? changeTime : -1; else if (accountData.Name.Equals("Type") && int.TryParse(accountData.Text, out int result)) type = (Account.AccountType)result; } if (name == null || balance < 0) { Output.Fatal($"Found errant account entry! Detected user name: {user.Name}"); return null; // This is a hard error } Account a = new Account(user, balance, name, type, changeTime); a.History.AddRange(history); user.AddAccount(a); } else if (entry.Name.Equals("Password")) user.PasswordHash = entry.Text; else if (entry.Name.Equals("Salt")) user.Salt = entry.Text; } if (user.Name == null || user.Name.Length == 0 || user.PasswordHash == null || user.Salt == null || user.PasswordHash.Length==0 || user.Salt.Length==0) return null; // Populate transaction names foreach (var account in user.accounts) foreach (var transaction in account.History) if (transaction.from == null) transaction.from = user.Name; else if (transaction.to == null) transaction.to = user.Name; return user; } public Transaction CreateTransaction(User recipient, long amount, Account fromAccount, Account toAccount, string message = null) => new Transaction(this.Name, recipient.Name, amount, message, fromAccount.name, toAccount.name); public override bool Equals(object obj) => obj is User && ((User)obj).Name.Equals(Name); public override int GetHashCode() { return 539060726 + EqualityComparer.Default.GetHashCode(Name); } } public class Transaction { public string fromAccount; public string toAccount; public string from; public string to; public decimal amount; public string meta; public Transaction(string from, string to, decimal amount, string meta, string fromAccount, string toAccount) { this.fromAccount = fromAccount; this.toAccount = toAccount; this.from = from; this.to = to; this.amount = amount; this.meta = meta; } public User GetTxToUser(Database db) => db.FirstUser(u => u.Name.Equals(to)); public User GetTxFromUser(Database db) => db.FirstUser(u => u.Name.Equals(from)); } } }