From 5235e5a5dbdb1da0f19aa70bd948d44968dafcd6 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 26 Sep 2016 21:19:04 -0500 Subject: [PATCH] Moved to ASP.NET Core MVC Thanks to an outstanding presentation from Enrico Sada, I was inspired to use ASP.NET Core MVC for this F# project --- .gitignore | 4 + src/MyPrayerJournal/.bowerrc | 3 + src/MyPrayerJournal/.gitignore | 234 ++++++++++++++++++ src/MyPrayerJournal/App.fs | 166 +++++-------- src/MyPrayerJournal/Config.fs | 90 ------- .../Configuration/Configuration.fs | 45 ++++ src/MyPrayerJournal/Configuration/Keys.fs | 12 + .../Controllers/ApplicationController.fs | 30 +++ .../Controllers/HomeController.fs | 12 + .../Controllers/UserController.fs | 40 +++ src/MyPrayerJournal/{ => Data}/Data.fs | 62 +++-- src/MyPrayerJournal/{ => Data}/Entities.fs | 8 +- src/MyPrayerJournal/Helpers/Extensions.fs | 19 ++ src/MyPrayerJournal/HomeModule.fs | 15 -- src/MyPrayerJournal/Keys.fs | 14 -- .../Models/ViewModels/AppViewModel.fs | 50 ++++ .../Models/ViewModels/User/LogOnViewModel.fs | 17 ++ src/MyPrayerJournal/Strings.fs | 40 --- src/MyPrayerJournal/UserModule.fs | 18 -- src/MyPrayerJournal/ViewModels.fs | 140 ----------- src/MyPrayerJournal/Views/Home/Index.cshtml | 14 ++ src/MyPrayerJournal/Views/Shared/Error.cshtml | 14 ++ .../Views/Shared/_Layout.cshtml | 80 ++++++ .../Shared/_ValidationScriptsPartial.cshtml | 14 ++ .../Views/User/ShowLogOn.cshtml | 31 +++ src/MyPrayerJournal/Views/_ViewImports.cshtml | 5 + src/MyPrayerJournal/Views/_ViewStart.cshtml | 3 + src/MyPrayerJournal/appsettings.json | 35 +++ src/MyPrayerJournal/bower.json | 10 + src/MyPrayerJournal/config.json | 17 -- src/MyPrayerJournal/gulpfile.js | 45 ++++ src/MyPrayerJournal/package.json | 12 + src/MyPrayerJournal/project.json | 146 ++++++++--- src/MyPrayerJournal/resources/en-US.json | 8 - src/MyPrayerJournal/views/home/index.html | 5 - src/MyPrayerJournal/views/layout.html | 60 ----- src/MyPrayerJournal/views/user/log-on.html | 40 --- src/MyPrayerJournal/web.config | 14 ++ src/MyPrayerJournal/wwwroot/css/site.css | 68 +++++ src/MyPrayerJournal/wwwroot/css/site.min.css | 1 + src/MyPrayerJournal/wwwroot/favicon.ico | Bin 0 -> 32038 bytes src/MyPrayerJournal/wwwroot/js/site.js | 1 + src/MyPrayerJournal/wwwroot/js/site.min.js | 0 43 files changed, 1024 insertions(+), 618 deletions(-) create mode 100644 src/MyPrayerJournal/.bowerrc create mode 100644 src/MyPrayerJournal/.gitignore delete mode 100644 src/MyPrayerJournal/Config.fs create mode 100644 src/MyPrayerJournal/Configuration/Configuration.fs create mode 100644 src/MyPrayerJournal/Configuration/Keys.fs create mode 100644 src/MyPrayerJournal/Controllers/ApplicationController.fs create mode 100644 src/MyPrayerJournal/Controllers/HomeController.fs create mode 100644 src/MyPrayerJournal/Controllers/UserController.fs rename src/MyPrayerJournal/{ => Data}/Data.fs (55%) rename src/MyPrayerJournal/{ => Data}/Entities.fs (87%) create mode 100644 src/MyPrayerJournal/Helpers/Extensions.fs delete mode 100644 src/MyPrayerJournal/HomeModule.fs delete mode 100644 src/MyPrayerJournal/Keys.fs create mode 100644 src/MyPrayerJournal/Models/ViewModels/AppViewModel.fs create mode 100644 src/MyPrayerJournal/Models/ViewModels/User/LogOnViewModel.fs delete mode 100644 src/MyPrayerJournal/Strings.fs delete mode 100644 src/MyPrayerJournal/UserModule.fs delete mode 100644 src/MyPrayerJournal/ViewModels.fs create mode 100644 src/MyPrayerJournal/Views/Home/Index.cshtml create mode 100644 src/MyPrayerJournal/Views/Shared/Error.cshtml create mode 100644 src/MyPrayerJournal/Views/Shared/_Layout.cshtml create mode 100644 src/MyPrayerJournal/Views/Shared/_ValidationScriptsPartial.cshtml create mode 100644 src/MyPrayerJournal/Views/User/ShowLogOn.cshtml create mode 100644 src/MyPrayerJournal/Views/_ViewImports.cshtml create mode 100644 src/MyPrayerJournal/Views/_ViewStart.cshtml create mode 100644 src/MyPrayerJournal/appsettings.json create mode 100644 src/MyPrayerJournal/bower.json delete mode 100644 src/MyPrayerJournal/config.json create mode 100644 src/MyPrayerJournal/gulpfile.js create mode 100644 src/MyPrayerJournal/package.json delete mode 100644 src/MyPrayerJournal/resources/en-US.json delete mode 100644 src/MyPrayerJournal/views/home/index.html delete mode 100644 src/MyPrayerJournal/views/layout.html delete mode 100644 src/MyPrayerJournal/views/user/log-on.html create mode 100644 src/MyPrayerJournal/web.config create mode 100644 src/MyPrayerJournal/wwwroot/css/site.css create mode 100644 src/MyPrayerJournal/wwwroot/css/site.min.css create mode 100644 src/MyPrayerJournal/wwwroot/favicon.ico create mode 100644 src/MyPrayerJournal/wwwroot/js/site.js create mode 100644 src/MyPrayerJournal/wwwroot/js/site.min.js diff --git a/.gitignore b/.gitignore index f1e3d20..4478423 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ bld/ # Visual Studio 2015 cache/options directory .vs/ +.vscode/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ @@ -250,3 +251,6 @@ paket-files/ # JetBrains Rider .idea/ *.sln.iml + +# wwwroot/lib +src/MyPrayerJournal/wwwroot/lib \ No newline at end of file diff --git a/src/MyPrayerJournal/.bowerrc b/src/MyPrayerJournal/.bowerrc new file mode 100644 index 0000000..6406626 --- /dev/null +++ b/src/MyPrayerJournal/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "wwwroot/lib" +} diff --git a/src/MyPrayerJournal/.gitignore b/src/MyPrayerJournal/.gitignore new file mode 100644 index 0000000..0ca27f0 --- /dev/null +++ b/src/MyPrayerJournal/.gitignore @@ -0,0 +1,234 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/src/MyPrayerJournal/App.fs b/src/MyPrayerJournal/App.fs index f3bb363..2da040c 100644 --- a/src/MyPrayerJournal/App.fs +++ b/src/MyPrayerJournal/App.fs @@ -1,127 +1,79 @@ -module MyPrayerJournal.App open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting -open Nancy -open Nancy.Authentication.Forms -open Nancy.Bootstrapper -open Nancy.Cryptography -open Nancy.Owin -open Nancy.Security -open Nancy.Session.Persistable -open Nancy.Session.RethinkDB -open Nancy.TinyIoc -open Nancy.ViewEngines.SuperSimpleViewEngine -open NodaTime -open RethinkDb.Driver.Net +open Microsoft.AspNetCore.Localization +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Logging +open Microsoft.Extensions.Options open System -open System.Reflection -open System.Security.Claims -open System.Text.RegularExpressions +open System.IO -/// Establish the configuration -let cfg = AppConfig.FromJson (System.IO.File.ReadAllText "config.json") - -do - cfg.DataConfig.Conn.EstablishEnvironment () |> Async.RunSynchronously - -/// Support i18n/l10n via the @Translate SSVE alias -type TranslateTokenViewEngineMatcher() = - static let regex = Regex("@Translate\.(?[a-zA-Z0-9-_]+);?", RegexOptions.Compiled) - interface ISuperSimpleViewEngineMatcher with - member this.Invoke (content, model, host) = - let translate (m : Match) = Strings.get m.Groups.["TranslationKey"].Value - regex.Replace(content, translate) - -/// Handle forms authentication -type AppUser(name, claims) = - inherit ClaimsPrincipal() - member this.UserName with get() = name - member this.Claims with get() = claims - -type AppUserMapper(container : TinyIoCContainer) = +/// Startup class for myPrayerJournal +type Startup(env : IHostingEnvironment) = - interface IUserMapper with - member this.GetUserFromIdentifier (identifier, context) = - match context.Request.PersistableSession.GetOrDefault(Keys.User, User.Empty) with - | user when user.Id = string identifier -> upcast AppUser(user.Name, [ "LoggedIn" ]) - | _ -> null + /// Configuration for this application + member this.Configuration = + let builder = + ConfigurationBuilder() + .SetBasePath(env.ContentRootPath) + .AddJsonFile("appsettings.json", optional = true, reloadOnChange = true) + .AddJsonFile(sprintf "appsettings.%s.json" env.EnvironmentName, optional = true) + // For more details on using the user secret store see https://go.microsoft.com/fwlink/?LinkID=532709 + match env.IsDevelopment () with true -> ignore <| builder.AddUserSecrets () | _ -> () + ignore <| builder.AddEnvironmentVariables () + builder.Build () + // This method gets called by the runtime. Use this method to add services to the container. + member this.ConfigureServices (services : IServiceCollection) = + ignore <| services.AddOptions () + ignore <| services.Configure(this.Configuration.GetSection("MyPrayerJournal")) + ignore <| services.AddLocalization (fun options -> options.ResourcesPath <- "Resources") + ignore <| services.AddMvc () + ignore <| services.AddDistributedMemoryCache () + ignore <| services.AddSession () + // RethinkDB connection + async { + let cfg = services.BuildServiceProvider().GetService>().Value + let! conn = DataConfig.Connect cfg.DataConfig + do! conn.EstablishEnvironment cfg + ignore <| services.AddSingleton conn + } |> Async.RunSynchronously -/// Set up the application environment -type AppBootstrapper() = - inherit DefaultNancyBootstrapper() - - override this.ConfigureRequestContainer (container, context) = - base.ConfigureRequestContainer (container, context) - /// User mapper for forms authentication - ignore <| container.Register() + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + member this.Configure (app : IApplicationBuilder, env : IHostingEnvironment, loggerFactory : ILoggerFactory) = + ignore <| loggerFactory.AddConsole(this.Configuration.GetSection "Logging") + ignore <| loggerFactory.AddDebug () - override this.ConfigureApplicationContainer (container) = - base.ConfigureApplicationContainer container - ignore <| container.Register(cfg) - ignore <| container.Register(cfg.DataConfig.Conn) - // NodaTime - ignore <| container.Register(SystemClock.Instance) - // I18N in SSVE - ignore <| container.Register> - (fun _ _ -> - Seq.singleton (TranslateTokenViewEngineMatcher() :> ISuperSimpleViewEngineMatcher)) - - override this.ApplicationStartup (container, pipelines) = - base.ApplicationStartup (container, pipelines) - // Forms authentication configuration - let auth = - FormsAuthenticationConfiguration( - CryptographyConfiguration = - CryptographyConfiguration( - AesEncryptionProvider(PassphraseKeyGenerator(cfg.AuthEncryptionPassphrase, cfg.AuthSalt)), - DefaultHmacProvider(PassphraseKeyGenerator(cfg.AuthHmacPassphrase, cfg.AuthSalt))), - RedirectUrl = "~/user/log-on", - UserMapper = container.Resolve()) - FormsAuthentication.Enable (pipelines, auth) - // CSRF - Csrf.Enable pipelines - // Sessions - let sessions = RethinkDBSessionConfiguration(cfg.DataConfig.Conn) - sessions.Database <- cfg.DataConfig.Database - PersistableSessions.Enable (pipelines, sessions) - () + match env.IsDevelopment () with + | true -> ignore <| app.UseDeveloperExceptionPage () + ignore <| app.UseBrowserLink () + | _ -> ignore <| app.UseExceptionHandler("/error") - override this.Configure (environment) = - base.Configure environment - environment.Tracing(true, true) + ignore <| app.UseStaticFiles () + // Add external authentication middleware below. To configure them please see https://go.microsoft.com/fwlink/?LinkID=532715 -let version = - let v = typeof.GetType().GetTypeInfo().Assembly.GetName().Version - match v.Build with - | 0 -> match v.Minor with 0 -> string v.Major | _ -> sprintf "%d.%d" v.Major v.Minor - | _ -> sprintf "%d.%d.%d" v.Major v.Minor v.Build - |> sprintf "v%s" + ignore <| app.UseMvc(fun routes -> + ignore <| routes.MapRoute(name = "default", template = "{controller=Home}/{action=Index}/{id?}")) -/// Set up the request environment -type RequestEnvironment() = - interface IRequestStartup with - member this.Initialize (pipelines, context) = - pipelines.BeforeRequest.AddItemToStartOfPipeline - (fun ctx -> - ctx.Items.[Keys.RequestStart] <- DateTime.Now.Ticks - ctx.Items.[Keys.Version] <- version - null) - -type Startup() = - member this.Configure (app : IApplicationBuilder) = - ignore <| app.UseOwin(fun x -> x.UseNancy(fun opt -> opt.Bootstrapper <- new AppBootstrapper()) |> ignore) +/// Default to Development environment +let defaults = seq { yield WebHostDefaults.EnvironmentKey, "Development" } + |> dict [] -let main argv = -// let app = OwinApp.ofMidFunc "/" (NancyMiddleware.UseNancy(fun opt -> opt.Bootstrapper <- new AppBootstrapper())) -// startWebServer defaultConfig app -// 0 // return an integer exit code +let main argv = + let cfg = + ConfigurationBuilder() + .AddInMemoryCollection(defaults) + .AddEnvironmentVariables("ASPNETCORE_") + .AddCommandLine(argv) + .Build() + WebHostBuilder() - .UseContentRoot(System.IO.Directory.GetCurrentDirectory()) + .UseConfiguration(cfg) .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) .UseStartup() .Build() .Run() diff --git a/src/MyPrayerJournal/Config.fs b/src/MyPrayerJournal/Config.fs deleted file mode 100644 index 3454959..0000000 --- a/src/MyPrayerJournal/Config.fs +++ /dev/null @@ -1,90 +0,0 @@ -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/Configuration/Configuration.fs b/src/MyPrayerJournal/Configuration/Configuration.fs new file mode 100644 index 0000000..d438336 --- /dev/null +++ b/src/MyPrayerJournal/Configuration/Configuration.fs @@ -0,0 +1,45 @@ +namespace MyPrayerJournal + +open RethinkDb.Driver +open RethinkDb.Driver.Net +open System +open System.Text + +/// Data configuration +type DataConfig() = + /// The hostname for the RethinkDB server + member val Hostname = "" with get, set + /// The port for the RethinkDB server + member val Port = 0 with get, set + /// The authorization key to use when connecting to the server + member val AuthKey = "" with get, set + /// How long an attempt to connect to the server should wait before giving up + member val Timeout = 0 with get, set + /// The name of the default database to use on the connection + member val Database = "" with get, set + + /// Use RethinkDB defaults for non-provided options, and connect to the server + static member Connect (cfg : DataConfig) = + async { + let builder = + seq Connection.Builder> { + yield fun b -> if String.IsNullOrEmpty cfg.Hostname then b else b.Hostname cfg.Hostname + yield fun b -> if String.IsNullOrEmpty cfg.AuthKey then b else b.AuthKey cfg.AuthKey + yield fun b -> if String.IsNullOrEmpty cfg.Database then b else b.Db cfg.Database + yield fun b -> if 0 = cfg.Port then b else b.Port cfg.Port + yield fun b -> if 0 = cfg.Timeout then b else b.Timeout cfg.Timeout + } + |> Seq.fold (fun curr block -> block curr) (RethinkDB.R.Connection()) + let! conn = builder.ConnectAsync() + return conn :> IConnection + } + +/// Application configuration +type AppConfig() = + /// The text from which to derive salt to use for passwords + member val PasswordSalt = "" with get, set + /// The data configuration + member val DataConfig = DataConfig() with get, set + /// The salt to use for passwords + member this.PasswordSaltBytes = Encoding.UTF8.GetBytes this.PasswordSalt + \ No newline at end of file diff --git a/src/MyPrayerJournal/Configuration/Keys.fs b/src/MyPrayerJournal/Configuration/Keys.fs new file mode 100644 index 0000000..0b2dba0 --- /dev/null +++ b/src/MyPrayerJournal/Configuration/Keys.fs @@ -0,0 +1,12 @@ +/// Magic strings? Look behind the curtain... +[] +module MyPrayerJournal.Keys + +/// The current user +let CurrentUser = "mpj-user" + +/// The page generator +let Generator = "mpj-generator" + +/// The request start ticks +let RequestTimer = "mpj-request-timer" \ No newline at end of file diff --git a/src/MyPrayerJournal/Controllers/ApplicationController.fs b/src/MyPrayerJournal/Controllers/ApplicationController.fs new file mode 100644 index 0000000..ff410b0 --- /dev/null +++ b/src/MyPrayerJournal/Controllers/ApplicationController.fs @@ -0,0 +1,30 @@ +namespace MyPrayerJournal.Controllers + +open Microsoft.AspNetCore.Mvc +open Microsoft.AspNetCore.Mvc.Filters +open Microsoft.Extensions.Localization +open MyPrayerJournal +open RethinkDb.Driver.Net +open System +open System.Reflection + +/// Base controller for all myPrayerJournal controllers +type ApplicationController(data : IConnection) = + inherit Controller() + + let version = + let v = typeof.GetType().GetTypeInfo().Assembly.GetName().Version + match v.Build with + | 0 -> match v.Minor with 0 -> string v.Major | _ -> sprintf "%d.%d" v.Major v.Minor + | _ -> sprintf "%d.%d.%d" v.Major v.Minor v.Build + |> sprintf "v%s" + + /// Fill common items for every request + override this.OnActionExecuting (context : ActionExecutingContext) = + let sw = System.Diagnostics.Stopwatch() + sw.Start() + base.OnActionExecuting context + this.ViewData.[Keys.CurrentUser] <- Option.None + this.ViewData.[Keys.Generator] <- sprintf "myPrayerJournal %s" version + this.ViewData.[Keys.RequestTimer] <- sw + diff --git a/src/MyPrayerJournal/Controllers/HomeController.fs b/src/MyPrayerJournal/Controllers/HomeController.fs new file mode 100644 index 0000000..17dec6c --- /dev/null +++ b/src/MyPrayerJournal/Controllers/HomeController.fs @@ -0,0 +1,12 @@ +namespace MyPrayerJournal.Controllers + +open Microsoft.AspNetCore.Mvc +open RethinkDb.Driver.Net + +/// Home controller +[] +type HomeController(data : IConnection) = + inherit ApplicationController(data) + + [] + member this.Index() = this.View() \ No newline at end of file diff --git a/src/MyPrayerJournal/Controllers/UserController.fs b/src/MyPrayerJournal/Controllers/UserController.fs new file mode 100644 index 0000000..cceffae --- /dev/null +++ b/src/MyPrayerJournal/Controllers/UserController.fs @@ -0,0 +1,40 @@ +namespace MyPrayerJournal.Controllers + +open Microsoft.AspNetCore.Mvc +open Microsoft.Extensions.Options +open MyPrayerJournal +open MyPrayerJournal.ViewModels +open RethinkDb.Driver.Net + +/// Controller for all /user URLs +[] +type UserController(data : IConnection, cfg : IOptions) = + inherit ApplicationController(data) + + [] + member this.ShowLogOn () = + this.View(LogOnViewModel()) + + + [] + [] + member this.DoLogOn (form : LogOnViewModel) = + async { + let! user = data.LogOnUser form.Email (User.HashPassword form.Password cfg.Value.PasswordSaltBytes) + match user with + | Some usr -> (* this.Session.[Keys.User] <- usr + { UserMessage.Empty with Level = Level.Info + Message = Strings.get "LogOnSuccess" } + |> model.AddMessage + this.Redirect "" model |> ignore // Save the messages in the session before the Nancy redirect + // TODO: investigate if addMessage should update the session when it's called + return this.LoginAndRedirect (System.Guid.Parse usr.Id, fallbackRedirectUrl = "/") :> obj + *) + return this.Redirect "/" :> IActionResult + | _ -> (*{ UserMessage.Empty with Level = Level.Error + Message = Strings.get "LogOnFailure" } + |> model.AddMessage + return this.Redirect "/user/log-on" model *) + return upcast this.RedirectToAction("ShowLogOn") + //return this.View() + } |> Async.StartAsTask diff --git a/src/MyPrayerJournal/Data.fs b/src/MyPrayerJournal/Data/Data.fs similarity index 55% rename from src/MyPrayerJournal/Data.fs rename to src/MyPrayerJournal/Data/Data.fs index 56ad052..d465407 100644 --- a/src/MyPrayerJournal/Data.fs +++ b/src/MyPrayerJournal/Data/Data.fs @@ -1,7 +1,6 @@ [] -module Data +module MyPrayerJournal.Data -open MyPrayerJournal open Newtonsoft.Json open RethinkDb.Driver open RethinkDb.Driver.Ast @@ -28,59 +27,74 @@ type IConnection with let! user = r.Table(DataTable.User) .GetAll(email).OptArg("index", "Email") .Filter(ReqlFunction1(fun usr -> upcast usr.["PasswordHash"].Eq(passwordHash))) - .RunResultAsync(this) - |> Async.AwaitTask - return match box user with null -> None | _ -> Some user + .RunResultAsync(this) + return user |> List.tryHead } /// Set up the environment for MyPrayerJournal - member this.EstablishEnvironment () = + member this.EstablishEnvironment (cfg : AppConfig) = /// 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 + // Be chatty about what we're doing + let mkStep step = sprintf "[MyPrayerJournal] %s" step + let logStep step = mkStep step |> Console.WriteLine + let logStepStart step = mkStep step |> Console.Write + let logStepEnd () = Console.WriteLine " done" /// Ensure the database exists let checkDatabase () = async { logStep "|> Checking database" - let! dbList = r.DbList().RunResultAsync(this) |> Async.AwaitTask + let! dbList = r.DbList().RunResultAsync(this) match dbList |> List.contains "MyPrayerJournal" with | true -> () - | _ -> logStep " Database not found - creating..." - do! r.DbCreate("MyPrayerJournal").RunResultAsync(this) |> Async.AwaitTask |> Async.Ignore - logStep " ...done" + | _ -> logStepStart " Database not found - creating..." + do! r.DbCreate("MyPrayerJournal").RunResultAsync(this) + logStepEnd () } /// Ensure all tables exit let checkTables () = async { logStep "|> Checking tables" - let! tables = db().TableList().RunResultAsync(this) |> Async.AwaitTask + let! tables = db().TableList().RunResultAsync(this) [ 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" + logStepStart <| sprintf " %s table not found - creating..." tbl + do! db().TableCreate(tbl).RunResultAsync(this) + logStepEnd() }) |> List.iter Async.RunSynchronously + // Seed the user table if it is empty + let! userCount = db().Table(DataTable.User).Count().RunResultAsync(this) + match int64 0 = userCount with + | true -> logStepStart " No users found - seeding..." + do! db().Table(DataTable.User).Insert( + { User.Empty with + Id = Guid.NewGuid().ToString () + Email = "test@example.com" + PasswordHash = User.HashPassword "password" cfg.PasswordSaltBytes + Name = "Default User" + TimeZone = "America/Chicago" }).RunResultAsync(this) + logStepEnd () + | _ -> () } /// Ensure the proper indexes exist let checkIndexes () = async { logStep "|> Checking indexes" - let! reqIdx = db().Table(DataTable.Request).IndexList().RunResultAsync(this) |> Async.AwaitTask + let! reqIdx = db().Table(DataTable.Request).IndexList().RunResultAsync(this) 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 + | _ -> logStepStart <| sprintf " %s.UserId index not found - creating..." DataTable.Request + do! db().Table(DataTable.Request).IndexCreate("UserId").RunResultAsync(this) + logStepEnd () + let! usrIdx = db().Table(DataTable.User).IndexList().RunResultAsync(this) 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" + | _ -> logStepStart <| sprintf " %s.Email index not found - creating..." DataTable.User + do! db().Table(DataTable.User).IndexCreate("Email").RunResultAsync(this) + logStepEnd () } async { logStep "Database checks starting" diff --git a/src/MyPrayerJournal/Entities.fs b/src/MyPrayerJournal/Data/Entities.fs similarity index 87% rename from src/MyPrayerJournal/Entities.fs rename to src/MyPrayerJournal/Data/Entities.fs index 75d08f0..2d68481 100644 --- a/src/MyPrayerJournal/Entities.fs +++ b/src/MyPrayerJournal/Data/Entities.fs @@ -1,6 +1,7 @@ namespace MyPrayerJournal open Newtonsoft.Json +open System.Security.Cryptography /// A user type User = { @@ -27,8 +28,13 @@ type User = { Name = "" TimeZone = "" LastSeenOn = int64 0 } + /// Hash a user's password + static member HashPassword (pw : string) (salt : byte[]) = + use hash = new Rfc2898DeriveBytes(pw, salt, 4096) + hash.GetBytes 512 + |> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) "" + - /// Request history entry type History = { /// The instant at which the update was made diff --git a/src/MyPrayerJournal/Helpers/Extensions.fs b/src/MyPrayerJournal/Helpers/Extensions.fs new file mode 100644 index 0000000..3b5137f --- /dev/null +++ b/src/MyPrayerJournal/Helpers/Extensions.fs @@ -0,0 +1,19 @@ +[] +module MyPrayerJournal.Extensions + +open Microsoft.FSharp.Control +open System.Threading.Tasks + +// H/T: Suave +type AsyncBuilder with + /// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on + /// a standard .NET task + member x.Bind(t : Task<'T>, f:'T -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f) + + /// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on + /// a standard .NET task which does not commpute a value + member x.Bind(t : Task, f : unit -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f) + +/// Object to which common IStringLocalizer calls cal be made +type I18N() = + member this.X = () \ No newline at end of file diff --git a/src/MyPrayerJournal/HomeModule.fs b/src/MyPrayerJournal/HomeModule.fs deleted file mode 100644 index 4d83fc6..0000000 --- a/src/MyPrayerJournal/HomeModule.fs +++ /dev/null @@ -1,15 +0,0 @@ -namespace MyPrayerJournal - -open Nancy - -type HomeModule() as this = - inherit NancyModule() - - do - this.Get ("/", fun _ -> this.Home ()) - - member this.Home () : obj = - let model = MyPrayerJournalModel(this.Context) - model.PageTitle <- Strings.get "Welcome" - upcast this.View.["home/index", model] - \ No newline at end of file diff --git a/src/MyPrayerJournal/Keys.fs b/src/MyPrayerJournal/Keys.fs deleted file mode 100644 index 27568ab..0000000 --- a/src/MyPrayerJournal/Keys.fs +++ /dev/null @@ -1,14 +0,0 @@ -[] -module MyPrayerJournal.Keys - -/// Messages stored in the session -let Messages = "messages" - -/// The request start time (stored in the context for each request) -let RequestStart = "request-start" - -/// The current user -let User = "user" - -/// The version of myPrayerJournal -let Version = "version" diff --git a/src/MyPrayerJournal/Models/ViewModels/AppViewModel.fs b/src/MyPrayerJournal/Models/ViewModels/AppViewModel.fs new file mode 100644 index 0000000..543b6e0 --- /dev/null +++ b/src/MyPrayerJournal/Models/ViewModels/AppViewModel.fs @@ -0,0 +1,50 @@ +namespace MyPrayerJournal.ViewModels + +//open MyPrayerJournal + +/// Parent view model for all myPrayerJournal view models +[] +type AppViewModel() = + member this.Q = "X" + (* + /// User messages + member val Messages = getMessages () with get, set + /// The currently logged in user + member val User = Option.None with get, set + /// The title of the page + member val PageTitle = "" with get, set + /// The name and version of the application + member this.Generator = sprintf "myPrayerJournal %s" (ctx.Items.[Keys.Version].ToString ()) + /// The request start time + member this.RequestStart = ctx.Items.[Keys.RequestStart] :?> int64 + /// Is a user authenticated for this request? + member this.IsAuthenticated = "" <> this.User.Id + /// Add a message to the output + member this.AddMessage message = this.Messages <- message :: this.Messages + + /// Display a long date + member this.DisplayLongDate ticks = FormatDateTime.longDate this.User.TimeZone ticks + /// Display a short date + member this.DisplayShortDate ticks = FormatDateTime.shortDate this.User.TimeZone ticks + /// Display the time + member this.DisplayTime ticks = FormatDateTime.time this.User.TimeZone ticks + /// The page title with the application name appended + member this.DisplayPageTitle = + match this.PageTitle with + | "" -> Strings.get "myPrayerJournal" + | pt -> sprintf "%s | %s" pt (Strings.get "myPrayerJournal") + + /// An image with the version and load time in the tool tip + member this.FooterLogo = + seq { + yield "myPrayerJournal" + } + |> Seq.reduce (+) + *) \ No newline at end of file diff --git a/src/MyPrayerJournal/Models/ViewModels/User/LogOnViewModel.fs b/src/MyPrayerJournal/Models/ViewModels/User/LogOnViewModel.fs new file mode 100644 index 0000000..ab98974 --- /dev/null +++ b/src/MyPrayerJournal/Models/ViewModels/User/LogOnViewModel.fs @@ -0,0 +1,17 @@ +namespace MyPrayerJournal.ViewModels + +open System.ComponentModel.DataAnnotations + +[] +type LogOnViewModel() = + inherit AppViewModel() + + [] + [] + [] + member val Email = "" with get, set + + [] + [] + [] + member val Password = "" with get, set diff --git a/src/MyPrayerJournal/Strings.fs b/src/MyPrayerJournal/Strings.fs deleted file mode 100644 index f7ccec5..0000000 --- a/src/MyPrayerJournal/Strings.fs +++ /dev/null @@ -1,40 +0,0 @@ -module MyPrayerJournal.Strings - -open Newtonsoft.Json -open System.Collections.Generic -open System.IO - -/// The locales we'll try to load -let private supportedLocales = [ "en-US" ] - -/// The fallback locale, if a key is not found in a non-default locale -let private fallbackLocale = "en-US" - -/// Get an embedded JSON file as a string -let private getEmbedded locale = - use stream = new FileStream((sprintf "resources/%s.json" locale), FileMode.Open) - use rdr = new StreamReader(stream) - rdr.ReadToEnd() - -/// The dictionary of localized strings -let private strings = - supportedLocales - |> List.map (fun loc -> loc, JsonConvert.DeserializeObject>(getEmbedded loc)) - |> dict - -/// Get a key from the resources file for the given locale -let getForLocale locale key = - let getString thisLocale = - match strings.ContainsKey thisLocale with - | true -> match strings.[thisLocale].ContainsKey key with - | true -> Some strings.[thisLocale].[key] - | _ -> None - | _ -> None - match getString locale with - | Some xlat -> Some xlat - | _ when locale <> fallbackLocale -> getString fallbackLocale - | _ -> None - |> function Some xlat -> xlat | _ -> sprintf "%s.%s" locale key - -/// Translate the key for the current locale -let get key = getForLocale System.Globalization.CultureInfo.CurrentCulture.Name key diff --git a/src/MyPrayerJournal/UserModule.fs b/src/MyPrayerJournal/UserModule.fs deleted file mode 100644 index 3c5bd71..0000000 --- a/src/MyPrayerJournal/UserModule.fs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MyPrayerJournal - -open Nancy - -type UserModule() as this = - inherit NancyModule("user") - - do - this.Get ("/log-on", fun _ -> this.ShowLogOn ()) - this.Post("/log-on", fun parms -> this.DoLogOn (downcast parms)) - - member this.ShowLogOn () : obj = - let model = MyPrayerJournalModel(this.Context) - model.PageTitle <- Strings.get "LogOn" - upcast this.View.["user/log-on", model] - - member this.DoLogOn (parms : DynamicDictionary) : obj = - upcast "X" \ No newline at end of file diff --git a/src/MyPrayerJournal/ViewModels.fs b/src/MyPrayerJournal/ViewModels.fs deleted file mode 100644 index 4a3faa3..0000000 --- a/src/MyPrayerJournal/ViewModels.fs +++ /dev/null @@ -1,140 +0,0 @@ -namespace MyPrayerJournal - -open Nancy -open Nancy.Session.Persistable -open Newtonsoft.Json -open NodaTime -open NodaTime.Text -open System - -/// Levels for a user message -[] -module Level = - /// An informational message - let Info = "Info" - /// A message regarding a non-fatal but non-optimal condition - let Warning = "WARNING" - /// A message regarding a failure of the expected result - let Error = "ERROR" - - -/// A message for the user -type UserMessage = - { /// The level of the message (use Level module constants) - Level : string - /// The text of the message - Message : string - /// Further details regarding the message - Details : string option } -with - /// An empty message - static member Empty = - { Level = Level.Info - Message = "" - Details = None } - - /// Display version - [] - member this.ToDisplay = - let classAndLabel = - dict [ - Level.Error, ("danger", Strings.get "Error") - Level.Warning, ("warning", Strings.get "Warning") - Level.Info, ("info", "") - ] - seq { - yield "
" - match snd classAndLabel.[this.Level] with - | "" -> () - | lbl -> yield lbl.ToUpper () - yield " » " - yield this.Message - yield "" - match this.Details with - | Some d -> yield "
" - yield d - | None -> () - yield "
" - } - |> Seq.reduce (+) - - -/// Helpers to format local date/time using NodaTime -module FormatDateTime = - - /// Convert ticks to a zoned date/time - let zonedTime timeZone ticks = Instant.FromUnixTimeTicks(ticks).InZone(DateTimeZoneProviders.Tzdb.[timeZone]) - - /// Display a long date - let longDate timeZone ticks = - zonedTime timeZone ticks - |> ZonedDateTimePattern.CreateWithCurrentCulture("MMMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format - - /// Display a short date - let shortDate timeZone ticks = - zonedTime timeZone ticks - |> ZonedDateTimePattern.CreateWithCurrentCulture("MMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format - - /// Display the time - let time timeZone ticks = - (zonedTime timeZone ticks - |> ZonedDateTimePattern.CreateWithCurrentCulture("h':'mmtt", DateTimeZoneProviders.Tzdb).Format).ToLower() - - -/// Parent view model for all myPrayerJournal view models -type MyPrayerJournalModel(ctx : NancyContext) = - - /// Get the messages from the session - let getMessages () = - let msg = ctx.Request.PersistableSession.GetOrDefault(Keys.Messages, []) - match List.length msg with - | 0 -> () - | _ -> ctx.Request.Session.Delete Keys.Messages - msg - - /// User messages - member val Messages = getMessages () with get, set - /// The currently logged in user - member this.User = ctx.Request.PersistableSession.GetOrDefault(Keys.User, User.Empty) - /// The title of the page - member val PageTitle = "" with get, set - /// The name and version of the application - member this.Generator = sprintf "myPrayerJournal %s" (ctx.Items.[Keys.Version].ToString ()) - /// The request start time - member this.RequestStart = ctx.Items.[Keys.RequestStart] :?> int64 - /// Is a user authenticated for this request? - member this.IsAuthenticated = "" <> this.User.Id - /// Add a message to the output - member this.AddMessage message = this.Messages <- message :: this.Messages - - /// Display a long date - member this.DisplayLongDate ticks = FormatDateTime.longDate this.User.TimeZone ticks - /// Display a short date - member this.DisplayShortDate ticks = FormatDateTime.shortDate this.User.TimeZone ticks - /// Display the time - member this.DisplayTime ticks = FormatDateTime.time this.User.TimeZone ticks - /// The page title with the web log name appended - member this.DisplayPageTitle = this.PageTitle (* - match this.PageTitle with - | "" -> match this.WebLog.Subtitle with - | Some st -> sprintf "%s | %s" this.WebLog.Name st - | None -> this.WebLog.Name - | pt -> sprintf "%s | %s" pt this.WebLog.Name *) - - /// An image with the version and load time in the tool tip - member this.FooterLogo = - seq { - yield "\"myWebLog\"" - } - |> Seq.reduce (+) diff --git a/src/MyPrayerJournal/Views/Home/Index.cshtml b/src/MyPrayerJournal/Views/Home/Index.cshtml new file mode 100644 index 0000000..4cf2c11 --- /dev/null +++ b/src/MyPrayerJournal/Views/Home/Index.cshtml @@ -0,0 +1,14 @@ +@{ + ViewData["Title"] = ""; +} +
+
+

 

+

myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, + update them as God moves in the situation, and record a final answer received on that request.  It will also + allow individuals to review their answered prayers.

+

This site is currently in very limited alpha, as it is being developed with a core group of test + users.  If this is something you are interested in using, check back around mid-November 2016 to check on the + development progress.

+
+
\ No newline at end of file diff --git a/src/MyPrayerJournal/Views/Shared/Error.cshtml b/src/MyPrayerJournal/Views/Shared/Error.cshtml new file mode 100644 index 0000000..229c2de --- /dev/null +++ b/src/MyPrayerJournal/Views/Shared/Error.cshtml @@ -0,0 +1,14 @@ +@{ + ViewData["Title"] = "Error"; +} + +

Error.

+

An error occurred while processing your request.

+ +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ Development environment should not be enabled in deployed applications, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the ASPNETCORE_ENVIRONMENT environment variable to Development, and restarting the application. +

diff --git a/src/MyPrayerJournal/Views/Shared/_Layout.cshtml b/src/MyPrayerJournal/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..395ef09 --- /dev/null +++ b/src/MyPrayerJournal/Views/Shared/_Layout.cshtml @@ -0,0 +1,80 @@ +@{ + var title = ViewData["Title"] as string; + var pageTitle = string.IsNullOrEmpty(title) ? "myPrayerJournal" : String.Format("{0} - myPrayerJournal", title); + var sw = ViewData[Keys.RequestTimer] as System.Diagnostics.Stopwatch; +} + + + + + + + @pageTitle + + + + + + + + + + + + + +
+ @if (!string.IsNullOrEmpty(ViewData["Title"] as string)) { +

@ViewData["Title"]

+ } + @RenderBody() +
+

@ViewData[Keys.Generator]

+
+
+ + + + + + + + + + + + + @RenderSection("scripts", required: false) + + diff --git a/src/MyPrayerJournal/Views/Shared/_ValidationScriptsPartial.cshtml b/src/MyPrayerJournal/Views/Shared/_ValidationScriptsPartial.cshtml new file mode 100644 index 0000000..289b220 --- /dev/null +++ b/src/MyPrayerJournal/Views/Shared/_ValidationScriptsPartial.cshtml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/MyPrayerJournal/Views/User/ShowLogOn.cshtml b/src/MyPrayerJournal/Views/User/ShowLogOn.cshtml new file mode 100644 index 0000000..8ee0554 --- /dev/null +++ b/src/MyPrayerJournal/Views/User/ShowLogOn.cshtml @@ -0,0 +1,31 @@ +@model LogOnViewModel +@{ + ViewData["Title"] = "Log On"; +} +
+
+
+
+ email + +
+
+
+
+
+
+
+ security + +
+
+
+
+
+

+
+ +

+
+
+
\ No newline at end of file diff --git a/src/MyPrayerJournal/Views/_ViewImports.cshtml b/src/MyPrayerJournal/Views/_ViewImports.cshtml new file mode 100644 index 0000000..7eb6668 --- /dev/null +++ b/src/MyPrayerJournal/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using Microsoft.Extensions.Localization +@using Microsoft.FSharp.Core +@using MyPrayerJournal +@using MyPrayerJournal.ViewModels +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/MyPrayerJournal/Views/_ViewStart.cshtml b/src/MyPrayerJournal/Views/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/src/MyPrayerJournal/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/MyPrayerJournal/appsettings.json b/src/MyPrayerJournal/appsettings.json new file mode 100644 index 0000000..b58798a --- /dev/null +++ b/src/MyPrayerJournal/appsettings.json @@ -0,0 +1,35 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "MyPrayerJournal": { + /* + * myPrayerJournal Configuration + * + * *** SECURITY OPTIONS *** + * + * https://www.grc.com/passwords.htm is a great source of high-entropy passwords. Although what is here looks + * strong, keep in mind that it's what's in source control, so all instances of myPrayerJournal could be using this + * value; that severly decreases its usefulness. :) + * + * WARNING: Changing this will render every single user's login inaccessible, including yours. Do not change it + * once you have started this instance for the first time. + */ + "PasswordSalt": "oIvatPlrBh5DjeBVWvX3vvePHAgbbzUm7BazZM2IKlUsTtDuPJFbF3KvIiQPdLt", + /* + * *** DATA OPTIONS *** + * + * Configure RethinkDB options here; any options not specified will take the driver's default. Available options are + * Hostname (string), Port (int), Database (string), AuthKey (string), and Timeout (int). + */ + "DataConfig": { + "Database": "MyPrayerJournal", + "Hostname": "severus-server" + } + } +} diff --git a/src/MyPrayerJournal/bower.json b/src/MyPrayerJournal/bower.json new file mode 100644 index 0000000..ea22f87 --- /dev/null +++ b/src/MyPrayerJournal/bower.json @@ -0,0 +1,10 @@ +{ + "name": "myprayerjournal", + "private": true, + "dependencies": { + "bootstrap": "3.3.6", + "jquery": "2.2.3", + "jquery-validation": "1.15.0", + "jquery-validation-unobtrusive": "3.2.6" + } +} diff --git a/src/MyPrayerJournal/config.json b/src/MyPrayerJournal/config.json deleted file mode 100644 index 33da497..0000000 --- a/src/MyPrayerJournal/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - // 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/gulpfile.js b/src/MyPrayerJournal/gulpfile.js new file mode 100644 index 0000000..faf2955 --- /dev/null +++ b/src/MyPrayerJournal/gulpfile.js @@ -0,0 +1,45 @@ +/// +"use strict"; + +var gulp = require("gulp"), + rimraf = require("rimraf"), + concat = require("gulp-concat"), + cssmin = require("gulp-cssmin"), + uglify = require("gulp-uglify"); + +var webroot = "./wwwroot/"; + +var paths = { + js: webroot + "js/**/*.js", + minJs: webroot + "js/**/*.min.js", + css: webroot + "css/**/*.css", + minCss: webroot + "css/**/*.min.css", + concatJsDest: webroot + "js/site.min.js", + concatCssDest: webroot + "css/site.min.css" +}; + +gulp.task("clean:js", function (cb) { + rimraf(paths.concatJsDest, cb); +}); + +gulp.task("clean:css", function (cb) { + rimraf(paths.concatCssDest, cb); +}); + +gulp.task("clean", ["clean:js", "clean:css"]); + +gulp.task("min:js", function () { + return gulp.src([paths.js, "!" + paths.minJs], { base: "." }) + .pipe(concat(paths.concatJsDest)) + .pipe(uglify()) + .pipe(gulp.dest(".")); +}); + +gulp.task("min:css", function () { + return gulp.src([paths.css, "!" + paths.minCss]) + .pipe(concat(paths.concatCssDest)) + .pipe(cssmin()) + .pipe(gulp.dest(".")); +}); + +gulp.task("min", ["min:js", "min:css"]); diff --git a/src/MyPrayerJournal/package.json b/src/MyPrayerJournal/package.json new file mode 100644 index 0000000..213d84a --- /dev/null +++ b/src/MyPrayerJournal/package.json @@ -0,0 +1,12 @@ +{ + "name": "myprayerjournal", + "version": "0.8.0", + "private": true, + "devDependencies": { + "gulp": "3.9.1", + "gulp-concat": "2.6.0", + "gulp-cssmin": "0.1.7", + "gulp-uglify": "1.5.3", + "rimraf": "2.5.2" + } +} diff --git a/src/MyPrayerJournal/project.json b/src/MyPrayerJournal/project.json index e3eb47f..e00e953 100644 --- a/src/MyPrayerJournal/project.json +++ b/src/MyPrayerJournal/project.json @@ -1,50 +1,118 @@ { - "buildOptions": { - "compile": { - "includeFiles": [ - "Entities.fs", - "Strings.fs", - "Config.fs", - "Data.fs", - "Keys.fs", - "ViewModels.fs", - "HomeModule.fs", - "UserModule.fs", - "App.fs" - ] - }, - "compilerName": "fsc", - "copyToOutput": { - "include": [ "views", "resources" ] - }, - "debugType": "portable", - "emitEntryPoint": true - }, + "userSecretsId": "aspnet-WebApplication-0799fe3e-6eaf-4c5f-b40e-7c6bfd5dfa9a", + "dependencies": { - "Microsoft.AspNetCore.Hosting": "1.0.0", - "Microsoft.AspNetCore.Owin": "1.0.0", + "Microsoft.NETCore.App": { + "version": "1.0.1", + "type": "platform" + }, + "Microsoft.AspNetCore.Authentication.Cookies": "1.0.0", + "Microsoft.AspNetCore.Diagnostics": "1.0.0", + "Microsoft.AspNetCore.Mvc": "1.0.1", + "Microsoft.AspNetCore.Razor.Tools": { + "version": "1.0.0-preview2-final", + "type": "build" + }, + "Microsoft.AspNetCore.Routing": "1.0.1", "Microsoft.AspNetCore.Server.Kestrel": "1.0.1", - "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", + "Microsoft.AspNetCore.Session": "1.0.0", + "Microsoft.AspNetCore.StaticFiles": "1.0.0", + "Microsoft.Extensions.Configuration.CommandLine": "1.0.0", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0", + "Microsoft.Extensions.Configuration.Json": "1.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "1.0.0", + "Microsoft.Extensions.Logging": "1.0.0", + "Microsoft.Extensions.Logging.Console": "1.0.0", + "Microsoft.Extensions.Logging.Debug": "1.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0", + "Microsoft.FSharp.Core.netcore": "1.0.0-alpha-160831", + "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0", + "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { + "version": "1.0.0-preview2-update1", + "type": "build" + }, + "Microsoft.VisualStudio.Web.CodeGenerators.Mvc": { + "version": "1.0.0-preview2-update1", + "type": "build" + }, "Newtonsoft.Json": "9.0.1", "NodaTime": "2.0.0-alpha20160729", "RethinkDb.Driver": "2.3.15" }, - "frameworks": { - "netcoreapp1.0": { - "dependencies": { - "Microsoft.FSharp.Core.netcore": "1.0.0-alpha-160831", - "Microsoft.NETCore.App": { - "type": "platform", - "version": "1.0.1" - } - } + + "tools": { + "dotnet-compile-fsc":"1.0.0-preview2-*", + "Microsoft.AspNetCore.Razor.Tools": { + "version": "1.0.0-preview2-final", + "imports": "portable-net45+win8+dnxcore50" + }, + "Microsoft.Extensions.SecretManager.Tools": { + "version": "1.0.0-preview2-final", + "imports": "portable-net45+win8+dnxcore50" + }, + "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { + "version": "1.0.0-preview2-final", + "imports": [ + "portable-net45+win8+dnxcore50", + "portable-net45+win8" + ] } }, - "tools": { - "dotnet-compile-fsc":"1.0.0-preview2-*" + + "frameworks": { + "netcoreapp1.0": { + "imports": [ + "dotnet5.6", + "dnxcore50", + "portable-net45+win8" + ] + } }, - "version": "1.0.0-*" + + "buildOptions": { + "compile": { + "exclude": "**/*", + "includeFiles": [ + "Helpers/Extensions.fs", + "Configuration/Configuration.fs", + "Configuration/Keys.fs", + "Data/Entities.fs", + "Data/Data.fs", + "Models/ViewModels/AppViewModel.fs", + "Models/ViewModels/User/LogOnViewModel.fs", + "Controllers/ApplicationController.fs", + "Controllers/HomeController.fs", + "Controllers/UserController.fs", + "App.fs" + ] + }, + "compilerName": "fsc", + "debugType": "portable", + "emitEntryPoint": true, + "preserveCompilationContext": true + }, + + "runtimeOptions": { + "configProperties": { + "System.GC.Server": true + } + }, + + "publishOptions": { + "include": [ + "wwwroot", + "**/*.cshtml", + "appsettings.json", + "web.config" + ] + }, + + "scripts": { + "prepublish": [ "npm install", "bower install", "gulp clean", "gulp min" ], + "postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ] + }, + + "tooling": { + "defaultNamespace": "MyPrayerJournal" + } } diff --git a/src/MyPrayerJournal/resources/en-US.json b/src/MyPrayerJournal/resources/en-US.json deleted file mode 100644 index 2c13253..0000000 --- a/src/MyPrayerJournal/resources/en-US.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "ChangeYourPassword": "Change Your Password", - "EmailAddress": "E-mail Address", - "LogOff": "Log Off", - "LogOn": "Log On", - "MyPrayerJournal": "MyPrayerJournal", - "Password": "Password" -} \ No newline at end of file diff --git a/src/MyPrayerJournal/views/home/index.html b/src/MyPrayerJournal/views/home/index.html deleted file mode 100644 index e51872e..0000000 --- a/src/MyPrayerJournal/views/home/index.html +++ /dev/null @@ -1,5 +0,0 @@ -@Master['layout'] - -@Section['Content'] -

Hi

-@EndSection \ No newline at end of file diff --git a/src/MyPrayerJournal/views/layout.html b/src/MyPrayerJournal/views/layout.html deleted file mode 100644 index b927bbc..0000000 --- a/src/MyPrayerJournal/views/layout.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - @Model.DisplayPageTitle - - - - - @Section['Head']; - - -
- -
-
- @Section['Content']; -
- @Section['Footer']; - - - @Section['Scripts']; - - \ No newline at end of file diff --git a/src/MyPrayerJournal/views/user/log-on.html b/src/MyPrayerJournal/views/user/log-on.html deleted file mode 100644 index 4e4af65..0000000 --- a/src/MyPrayerJournal/views/user/log-on.html +++ /dev/null @@ -1,40 +0,0 @@ -@Master['layout'] - -@Section['Content'] -
- @AntiForgeryToken -
-
-
- email - -
-
-
-
-
-
-
- security - -
-
-
-
-
-

-
- -

-
-
-
-@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyPrayerJournal/web.config b/src/MyPrayerJournal/web.config new file mode 100644 index 0000000..a8d6672 --- /dev/null +++ b/src/MyPrayerJournal/web.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/MyPrayerJournal/wwwroot/css/site.css b/src/MyPrayerJournal/wwwroot/css/site.css new file mode 100644 index 0000000..8712175 --- /dev/null +++ b/src/MyPrayerJournal/wwwroot/css/site.css @@ -0,0 +1,68 @@ +body { + padding-top: 50px; + padding-bottom: 20px; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +/* Wrapping element */ +/* Set some basic padding to keep content from hitting the edges */ +.body-content { + padding-left: 15px; + padding-right: 15px; +} + +/* Set widths on the form inputs since otherwise they're 100% wide */ +/*input, +select, +textarea { + max-width: 280px; +}*/ + +/* Carousel */ +.carousel-caption p { + font-size: 20px; + line-height: 1.4; +} + +/* buttons and links extension to use brackets: [ click me ] */ +.btn-bracketed::before { + display:inline-block; + content: "["; + padding-right: 0.5em; +} +.btn-bracketed::after { + display:inline-block; + content: "]"; + padding-left: 0.5em; +} + +/* Hide/rearrange for smaller screens */ +@media screen and (max-width: 767px) { + /* Hide captions */ + .carousel-caption { + display: none + } +} +.material-icons.md-18 { + font-size: 18px; +} +.material-icons.md-24 { + font-size: 24px; +} +.material-icons.md-36 { + font-size: 36px; +} +.material-icons.md-48 { + font-size: 48px; +} +.material-icons { + vertical-align: middle; +} +.mpj-page-title { + border-bottom: solid 1px lightgray; + margin-bottom: 20px; +} +.mpj-footer { + border-top: solid 1px lightgray; + margin-top: 20px; +} \ No newline at end of file diff --git a/src/MyPrayerJournal/wwwroot/css/site.min.css b/src/MyPrayerJournal/wwwroot/css/site.min.css new file mode 100644 index 0000000..0f497cc --- /dev/null +++ b/src/MyPrayerJournal/wwwroot/css/site.min.css @@ -0,0 +1 @@ +body{padding-top:50px;padding-bottom:20px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}.body-content{padding-left:15px;padding-right:15px}input,select,textarea{max-width:280px}.carousel-caption p{font-size:20px;line-height:1.4}.btn-bracketed::before{display:inline-block;content:"[";padding-right:.5em}.btn-bracketed::after{display:inline-block;content:"]";padding-left:.5em}@media screen and (max-width:767px){.carousel-caption{display:none}}.material-icons.md-18{font-size:18px}.material-icons.md-24{font-size:24px}.material-icons.md-36{font-size:36px}.material-icons.md-48{font-size:48px}.material-icons{vertical-align:middle} \ No newline at end of file diff --git a/src/MyPrayerJournal/wwwroot/favicon.ico b/src/MyPrayerJournal/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a3a799985c43bc7309d701b2cad129023377dc71 GIT binary patch literal 32038 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