diff --git a/README.md b/README.md index 99ab06f..26c3a07 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# myPrayerJournal \ No newline at end of file +# myPrayerJournal + +WIP \ No newline at end of file diff --git a/src/MyPrayerJournal/App.fs b/src/MyPrayerJournal/App.fs new file mode 100644 index 0000000..bef701f --- /dev/null +++ b/src/MyPrayerJournal/App.fs @@ -0,0 +1,20 @@ +module App + +open MyPrayerJournal +open Nancy +open Nancy.Owin +open Suave.Web +open Suave.Owin +open System + +/// Establish the configuration +let cfg = AppConfig.FromJson (System.IO.File.ReadAllText "config.json") + +do + cfg.DataConfig.Conn.EstablishEnvironment () |> Async.RunSynchronously + +[] +let main argv = + let app = OwinApp.ofMidFunc "/" (NancyMiddleware.UseNancy(NancyOptions())) + startWebServer defaultConfig app + 0 // return an integer exit code diff --git a/src/MyPrayerJournal/Config.fs b/src/MyPrayerJournal/Config.fs new file mode 100644 index 0000000..3454959 --- /dev/null +++ b/src/MyPrayerJournal/Config.fs @@ -0,0 +1,90 @@ +namespace MyPrayerJournal + +open Newtonsoft.Json +open RethinkDb.Driver +open RethinkDb.Driver.Net +open System.Text + +/// Data configuration +type DataConfig = { + /// The hostname for the RethinkDB server + [] + Hostname : string + /// The port for the RethinkDB server + [] + Port : int + /// The authorization key to use when connecting to the server + [] + AuthKey : string + /// How long an attempt to connect to the server should wait before giving up + [] + Timeout : int + /// The name of the default database to use on the connection + [] + Database : string + /// A connection to the RethinkDB server using the configuration in this object + [] + Conn : IConnection + } +with + /// Use RethinkDB defaults for non-provided options, and connect to the server + static member Connect config = + let ensureHostname cfg = match cfg.Hostname with + | null -> { cfg with Hostname = RethinkDBConstants.DefaultHostname } + | _ -> cfg + let ensurePort cfg = match cfg.Port with + | 0 -> { cfg with Port = RethinkDBConstants.DefaultPort } + | _ -> cfg + let ensureAuthKey cfg = match cfg.AuthKey with + | null -> { cfg with AuthKey = RethinkDBConstants.DefaultAuthkey } + | _ -> cfg + let ensureTimeout cfg = match cfg.Timeout with + | 0 -> { cfg with Timeout = RethinkDBConstants.DefaultTimeout } + | _ -> cfg + let ensureDatabase cfg = match cfg.Database with + | null -> { cfg with Database = RethinkDBConstants.DefaultDbName } + | _ -> cfg + let connect cfg = { cfg with Conn = RethinkDB.R.Connection() + .Hostname(cfg.Hostname) + .Port(cfg.Port) + .AuthKey(cfg.AuthKey) + .Db(cfg.Database) + .Timeout(cfg.Timeout) + .Connect() } + config + |> ensureHostname + |> ensurePort + |> ensureAuthKey + |> ensureTimeout + |> ensureDatabase + |> connect + +/// Application configuration +type AppConfig = { + /// The text from which to derive salt to use for passwords + [] + PasswordSaltString : string + /// The text from which to derive salt to use for forms authentication + [] + AuthSaltString : string + /// The encryption passphrase to use for forms authentication + [] + AuthEncryptionPassphrase : string + /// The HMAC passphrase to use for forms authentication + [] + AuthHmacPassphrase : string + /// The data configuration + [] + DataConfig : DataConfig + } +with + /// The salt to use for passwords + member this.PasswordSalt = Encoding.UTF8.GetBytes this.PasswordSaltString + /// The salt to use for forms authentication + member this.AuthSalt = Encoding.UTF8.GetBytes this.AuthSaltString + + /// Deserialize the configuration from the JSON file + static member FromJson json = + let cfg = JsonConvert.DeserializeObject json + { cfg with DataConfig = DataConfig.Connect cfg.DataConfig } + \ No newline at end of file diff --git a/src/MyPrayerJournal/Data.fs b/src/MyPrayerJournal/Data.fs new file mode 100644 index 0000000..bbaf113 --- /dev/null +++ b/src/MyPrayerJournal/Data.fs @@ -0,0 +1,78 @@ +[] +module Data + +open Newtonsoft.Json +open RethinkDb.Driver +open RethinkDb.Driver.Net +open System + +let private r = RethinkDB.R + +/// Tables for data storage +module DataTable = + /// The table for prayer requests + [] + let Request = "Request" + /// The table for users + [] + let User = "User" + +/// Extensions for the RethinkDB connection +type IConnection with + + /// Set up the environment for MyPrayerJournal + member this.EstablishEnvironment () = + /// Shorthand for the database + let db () = r.Db("MyPrayerJournal") + /// Log a step in the database environment set up + let logStep step = sprintf "[MyPrayerJournal] %s" step |> Console.WriteLine + /// Ensure the database exists + let checkDatabase () = + async { + logStep "|> Checking database..." + let! dbList = r.DbList().RunResultAsync(this) |> Async.AwaitTask + match dbList |> List.contains "MyPrayerJournal" with + | true -> () + | _ -> logStep " Database not found - creating..." + do! r.DbCreate("MyPrayerJournal").RunResultAsync(this) |> Async.AwaitTask |> Async.Ignore + logStep " ...done" + } + /// Ensure all tables exit + let checkTables () = + async { + logStep "|> Checking tables..." + let! tables = db().TableList().RunResultAsync(this) |> Async.AwaitTask + [ DataTable.Request; DataTable.User ] + |> List.filter (fun tbl -> not (tables |> List.contains tbl)) + |> List.map (fun tbl -> + async { + logStep <| sprintf " %s table not found - creating..." tbl + do! db().TableCreate(tbl).RunResultAsync(this) |> Async.AwaitTask |> Async.Ignore + logStep " ...done" + }) + |> List.iter Async.RunSynchronously + } + /// Ensure the proper indexes exist + let checkIndexes () = + async { + logStep "|> Checking indexes..." + let! reqIdx = db().Table(DataTable.Request).IndexList().RunResultAsync(this) |> Async.AwaitTask + match reqIdx |> List.contains "UserId" with + | true -> () + | _ -> logStep <| sprintf " %s.UserId index not found - creating..." DataTable.Request + do! db().Table(DataTable.Request).IndexCreate("UserId").RunResultAsync(this) |> Async.AwaitTask |> Async.Ignore + logStep " ...done" + let! usrIdx = db().Table(DataTable.User).IndexList().RunResultAsync(this) |> Async.AwaitTask + match usrIdx |> List.contains "Email" with + | true -> () + | _ -> logStep <| sprintf " %s.Email index not found - creating..." DataTable.User + do! db().Table(DataTable.User).IndexCreate("Email").RunResultAsync(this) |> Async.AwaitTask |> Async.Ignore + logStep " ...done" + } + async { + logStep "Database checks starting" + do! checkDatabase () + do! checkTables () + do! checkIndexes () + logStep "Database checks complete" + } diff --git a/src/MyPrayerJournal/Entities.fs b/src/MyPrayerJournal/Entities.fs new file mode 100644 index 0000000..8381e24 --- /dev/null +++ b/src/MyPrayerJournal/Entities.fs @@ -0,0 +1,66 @@ +namespace MyPrayerJournal + +open Newtonsoft.Json + +/// A user +type User = { + /// The Id of the user + [] + Id : string + /// The user's e-mail address + Email : string + /// A hash of the user's password + PasswordHash : string + /// The user's name + Name : string + /// The last time the user logged on + LastSeenOn : int64 +} + +/// Request history entry +type History = { + /// The instant at which the update was made + AsOf : int64 + /// The action that was taken on the request + Action : string list + /// The status of the request (filled if it changed) + Status : string option + /// The text of the request (filled if it changed) + Text : string option +} + +/// A prayer request +type Request = { + /// The Id of the request + [] + Id : string + /// The Id of the user to whom this request belongs + UserId : string + /// The instant this request was entered + EnteredOn : int64 + /// The history for this request + History : History list +} + with + /// The current status of the prayer request + member this.Status = + this.History + |> List.sortBy (fun item -> -item.AsOf) + |> List.map (fun item -> item.Status) + |> List.filter Option.isSome + |> List.map Option.get + |> List.head + /// The current text of the prayer request + member this.Text = + this.History + |> List.sortBy (fun item -> -item.AsOf) + |> List.map (fun item -> item.Text) + |> List.filter Option.isSome + |> List.map Option.get + |> List.head + member this.LastActionOn = + this.History + |> List.sortBy (fun item -> -item.AsOf) + |> List.map (fun item -> item.AsOf) + |> List.head + \ No newline at end of file diff --git a/src/MyPrayerJournal/config.json b/src/MyPrayerJournal/config.json new file mode 100644 index 0000000..33da497 --- /dev/null +++ b/src/MyPrayerJournal/config.json @@ -0,0 +1,17 @@ +{ + // https://www.grc.com/passwords.htm is a great source of high-entropy passwords for these first 4 settings. + // Although what is there looks strong, keep in mind that it's what's in source control, so all instances of + // MyPrayerJournal could be using these values; that severly decreases their usefulness. :) + // + // WARNING: Changing this first one will render every single user's login inaccessible, including yours. Only do + // this if you are editing this file before setting up an instance, or if that is what you intend to do. + "password-salt": "oIvatPlrBh5DjeBVWvX3vvePHAgbbzUm7BazZM2IKlUsTtDuPJFbF3KvIiQPdLt", + // Changing any of these next 3 will render all current logins invalid, and the user will be force to reauthenticate. + "auth-salt": "oQnn82bjs1WysxU0buIdG0DOzsfqIH1pGUnzoavrSDkBIyhWqHffYksRXEAfCup", + "encryption-passphrase": "iIIPDrOfke6xP0ElRPMpyMci49CEqBqjmhoxvCiYHZTPSgkTnG2s2m8UOejcFE2", + "hmac-passphrase": "0UTc2QfbnHoq4sgTtfkk5JCjoBI2nfm5o0gQsINsBHzwIkU7mAVp61R7UQ1HVrv", + "data": { + "database": "MyPrayerJournal", + "hostname": "severus-server" + } +} \ No newline at end of file diff --git a/src/MyPrayerJournal/project.json b/src/MyPrayerJournal/project.json new file mode 100644 index 0000000..cce3476 --- /dev/null +++ b/src/MyPrayerJournal/project.json @@ -0,0 +1,39 @@ +{ + "version": "1.0.0-*", + "buildOptions": { + "debugType": "portable", + "emitEntryPoint": true, + "compilerName": "fsc", + "compile": { + "includeFiles": [ + "Entities.fs", + "Config.fs", + "Data.fs", + "App.fs" + ] + } + }, + "dependencies": { + "Nancy": "2.0.0-barneyrubble", + "Nancy.Authentication.Forms": "2.0.0-barneyrubble", + "Nancy.Session.Persistable": "0.9.1-pre", + "Nancy.Session.RethinkDB": "0.9.1-pre", + "Newtonsoft.Json": "9.0.1", + "RethinkDb.Driver": "2.3.15", + "Suave": "2.0.0-alpha5" + }, + "tools": { + "dotnet-compile-fsc":"1.0.0-preview2-*" + }, + "frameworks": { + "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.0.1" + }, + "Microsoft.FSharp.Core.netcore": "1.0.0-alpha-160831" + } + } + } +}