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) { entry = ToEncoded(entry); for (int i = 0; i < loadedUsers.Count; ++i) if (entry.Equals(loadedUsers[i])) loadedUsers[i] = entry; for (int i = toRemove.Count - 1; i >= 0; --i) if (toRemove[i].Equals(entry.Name)) toRemove.RemoveAt(i); for (int i = 0; i < changeList.Count; ++i) if (changeList[i].Equals(entry.Name)) return; changeList.Add(entry); if(withFlush) Flush(true); } public void RemoveUser(User entry) => RemoveUser(entry, true); private void RemoveUser(User entry, bool withFlush) { entry = ToEncoded(entry); for (int i = 0; i < loadedUsers.Count; ++i) if (entry.Equals(loadedUsers[i])) loadedUsers.RemoveAt(i); for (int i = changeList.Count - 1; i >= 0; --i) if (changeList[i].Equals(entry.Name)) changeList.RemoveAt(i); for (int i = toRemove.Count - 1; i >= 0; --i) if (toRemove[i].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; while (wn || reader.Read()) { 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) { wroteNode = false; if (reader.Name.Equals("User")) { User u = 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; 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) { 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.balance.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 User u; foreach (var entry in loadedUsers) if (p(u=FromEncoded(entry))) return u; foreach (var entry in changeList) if (p(u=FromEncoded(entry))) { if (!loadedUsers.Contains(entry)) loadedUsers.Add(u); return u; } using (var reader = XmlReader.Create(DatabaseName)) { if (!Traverse(reader, MasterEntry)) return null; while (reader.Read() && reader.NodeType != XmlNodeType.EndElement) { if (reader.Name.Equals("User")) { User n = User.Parse(ReadEntry(reader), this); if (n != null && p(FromEncoded(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, 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.balance p) { List l = new List(); User u; foreach (var entry in changeList) if (p(u=FromEncoded(entry))) l.Add(entry); using (var reader = XmlReader.Create(DatabaseName)) { if (!Traverse(reader, MasterEntry)) return null; while (SkipSpaces(reader) && reader.NodeType != XmlNodeType.EndElement) { if (reader.NodeType == XmlNodeType.EndElement) break; User e = User.Parse(ReadEntry(reader), this); if (e!=null && p(e=FromEncoded(e))) l.Add(e); } } 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 class Account { public User owner; public decimal balance; public string name; public List History { get; } public Account(User owner, decimal balance, string name) { History = new List(); this.owner = owner; this.balance = balance; this.name = name; } public Account(Account copy) : this(copy.owner, copy.balance, copy.name) => History.AddRange(copy.History); public Account AddTransaction(Transaction tx) { History.Add(tx); return this; } } 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; accounts.AddRange(copy.accounts); } 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 ? Convert.ToBase64String(KDF.PBKDF2(KDF.HMAC_SHA1, Encoding.UTF8.GetBytes(passHash), Encoding.UTF8.GetBytes(Salt), 8192, 320)) : passHash; } 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.balance.ToString()).AddAttribute("omit", "true")); 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 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; List history = new List(); foreach (var accountData in entry.NestedEntries) { if (accountData.Name.Equals("Name")) name = accountData.Text; else if (entry.Name.Equals("Transaction")) { string fromAccount = null; string toAccount = null; string from = null; string to = null; decimal amount = -1; string meta = ""; foreach (var e1 in entry.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 (entry.Name.Equals("Balance")) balance = decimal.TryParse(entry.Text, out decimal l) ? l : 0; } 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); 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)); } } }