Version 2, ready for beta
This commit was merged in pull request #1.
This commit is contained in:
157
src/MyWebLog/Caches.fs
Normal file
157
src/MyWebLog/Caches.fs
Normal file
@@ -0,0 +1,157 @@
|
||||
namespace MyWebLog
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Extension properties on HTTP context for web log
|
||||
[<AutoOpen>]
|
||||
module Extensions =
|
||||
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
|
||||
type HttpContext with
|
||||
/// The web log for the current request
|
||||
member this.WebLog = this.Items["webLog"] :?> WebLog
|
||||
|
||||
/// The data implementation
|
||||
member this.Data = this.RequestServices.GetRequiredService<IData> ()
|
||||
|
||||
|
||||
open System.Collections.Concurrent
|
||||
|
||||
/// <summary>
|
||||
/// In-memory cache of web log details
|
||||
/// </summary>
|
||||
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
|
||||
/// settings update page</remarks>
|
||||
module WebLogCache =
|
||||
|
||||
/// The cache of web log details
|
||||
let mutable private _cache : WebLog list = []
|
||||
|
||||
/// Try to get the web log for the current request (longest matching URL base wins)
|
||||
let tryGet (path : string) =
|
||||
_cache
|
||||
|> List.filter (fun wl -> path.StartsWith wl.urlBase)
|
||||
|> List.sortByDescending (fun wl -> wl.urlBase.Length)
|
||||
|> List.tryHead
|
||||
|
||||
/// Cache the web log for a particular host
|
||||
let set webLog =
|
||||
_cache <- webLog :: (_cache |> List.filter (fun wl -> wl.id <> webLog.id))
|
||||
|
||||
/// Fill the web log cache from the database
|
||||
let fill (data : IData) = backgroundTask {
|
||||
let! webLogs = data.WebLog.all ()
|
||||
_cache <- webLogs
|
||||
}
|
||||
|
||||
|
||||
/// A cache of page information needed to display the page list in templates
|
||||
module PageListCache =
|
||||
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Cache of displayed pages
|
||||
let private _cache = ConcurrentDictionary<string, DisplayPage[]> ()
|
||||
|
||||
/// Are there pages cached for this web log?
|
||||
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.urlBase
|
||||
|
||||
/// Get the pages for the web log for this request
|
||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.urlBase]
|
||||
|
||||
/// Update the pages for the current web log
|
||||
let update (ctx : HttpContext) = backgroundTask {
|
||||
let webLog = ctx.WebLog
|
||||
let! pages = ctx.Data.Page.findListed webLog.id
|
||||
_cache[webLog.urlBase] <-
|
||||
pages
|
||||
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with text = "" })
|
||||
|> Array.ofList
|
||||
}
|
||||
|
||||
|
||||
/// Cache of all categories, indexed by web log
|
||||
module CategoryCache =
|
||||
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// The cache itself
|
||||
let private _cache = ConcurrentDictionary<string, DisplayCategory[]> ()
|
||||
|
||||
/// Are there categories cached for this web log?
|
||||
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.urlBase
|
||||
|
||||
/// Get the categories for the web log for this request
|
||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.urlBase]
|
||||
|
||||
/// Update the cache with fresh data
|
||||
let update (ctx : HttpContext) = backgroundTask {
|
||||
let! cats = ctx.Data.Category.findAllForView ctx.WebLog.id
|
||||
_cache[ctx.WebLog.urlBase] <- cats
|
||||
}
|
||||
|
||||
|
||||
/// Cache for parsed templates
|
||||
module TemplateCache =
|
||||
|
||||
open System
|
||||
open System.Text.RegularExpressions
|
||||
open DotLiquid
|
||||
|
||||
/// Cache of parsed templates
|
||||
let private _cache = ConcurrentDictionary<string, Template> ()
|
||||
|
||||
/// Custom include parameter pattern
|
||||
let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2)
|
||||
|
||||
/// Get a template for the given theme and template name
|
||||
let get (themeId : string) (templateName : string) (data : IData) = backgroundTask {
|
||||
let templatePath = $"{themeId}/{templateName}"
|
||||
match _cache.ContainsKey templatePath with
|
||||
| true -> ()
|
||||
| false ->
|
||||
match! data.Theme.findById (ThemeId themeId) with
|
||||
| Some theme ->
|
||||
let mutable text = (theme.templates |> List.find (fun t -> t.name = templateName)).text
|
||||
while hasInclude.IsMatch text do
|
||||
let child = hasInclude.Match text
|
||||
let childText = (theme.templates |> List.find (fun t -> t.name = child.Groups[1].Value)).text
|
||||
text <- text.Replace (child.Value, childText)
|
||||
_cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22)
|
||||
| None -> ()
|
||||
return _cache[templatePath]
|
||||
}
|
||||
|
||||
/// Invalidate all template cache entries for the given theme ID
|
||||
let invalidateTheme (themeId : string) =
|
||||
_cache.Keys
|
||||
|> Seq.filter (fun key -> key.StartsWith themeId)
|
||||
|> List.ofSeq
|
||||
|> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ())
|
||||
|
||||
|
||||
/// A cache of asset names by themes
|
||||
module ThemeAssetCache =
|
||||
|
||||
/// A list of asset names for each theme
|
||||
let private _cache = ConcurrentDictionary<ThemeId, string list> ()
|
||||
|
||||
/// Retrieve the assets for the given theme ID
|
||||
let get themeId = _cache[themeId]
|
||||
|
||||
/// Refresh the list of assets for the given theme
|
||||
let refreshTheme themeId (data : IData) = backgroundTask {
|
||||
let! assets = data.ThemeAsset.findByTheme themeId
|
||||
_cache[themeId] <- assets |> List.map (fun a -> match a.id with ThemeAssetId (_, path) -> path)
|
||||
}
|
||||
|
||||
/// Fill the theme asset cache
|
||||
let fill (data : IData) = backgroundTask {
|
||||
let! assets = data.ThemeAsset.all ()
|
||||
for asset in assets do
|
||||
let (ThemeAssetId (themeId, path)) = asset.id
|
||||
if not (_cache.ContainsKey themeId) then _cache[themeId] <- []
|
||||
_cache[themeId] <- path :: _cache[themeId]
|
||||
}
|
||||
237
src/MyWebLog/DotLiquidBespoke.fs
Normal file
237
src/MyWebLog/DotLiquidBespoke.fs
Normal file
@@ -0,0 +1,237 @@
|
||||
/// Custom DotLiquid filters and tags
|
||||
module MyWebLog.DotLiquidBespoke
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Web
|
||||
open DotLiquid
|
||||
open Giraffe.ViewEngine
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Get the current web log from the DotLiquid context
|
||||
let webLog (ctx : Context) =
|
||||
ctx.Environments[0].["web_log"] :?> WebLog
|
||||
|
||||
/// Does an asset exist for the current theme?
|
||||
let assetExists fileName (webLog : WebLog) =
|
||||
ThemeAssetCache.get (ThemeId webLog.themePath) |> List.exists (fun it -> it = fileName)
|
||||
|
||||
/// Obtain the link from known types
|
||||
let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) =
|
||||
match item with
|
||||
| :? String as link -> Some link
|
||||
| :? DisplayPage as page -> Some page.permalink
|
||||
| :? PostListItem as post -> Some post.permalink
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["permalink"] |> Option.map string
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some link -> linkFunc (webLog ctx) (Permalink link)
|
||||
| None -> $"alert('unknown item type {item.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate an absolute link
|
||||
type AbsoluteLinkFilter () =
|
||||
static member AbsoluteLink (ctx : Context, item : obj) =
|
||||
permalink ctx item WebLog.absoluteUrl
|
||||
|
||||
|
||||
/// A filter to generate a link with posts categorized under the given category
|
||||
type CategoryLinkFilter () =
|
||||
static member CategoryLink (ctx : Context, catObj : obj) =
|
||||
match catObj with
|
||||
| :? DisplayCategory as cat -> Some cat.slug
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["slug"] |> Option.map string
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some slug -> WebLog.relativeUrl (webLog ctx) (Permalink $"category/{slug}/")
|
||||
| None -> $"alert('unknown category object type {catObj.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate a link that will edit a page
|
||||
type EditPageLinkFilter () =
|
||||
static member EditPageLink (ctx : Context, pageObj : obj) =
|
||||
match pageObj with
|
||||
| :? DisplayPage as page -> Some page.id
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["id"] |> Option.map string
|
||||
| :? String as theId -> Some theId
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some pageId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/page/{pageId}/edit")
|
||||
| None -> $"alert('unknown page object type {pageObj.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate a link that will edit a post
|
||||
type EditPostLinkFilter () =
|
||||
static member EditPostLink (ctx : Context, postObj : obj) =
|
||||
match postObj with
|
||||
| :? PostListItem as post -> Some post.id
|
||||
| :? DropProxy as proxy -> Option.ofObj proxy["id"] |> Option.map string
|
||||
| :? String as theId -> Some theId
|
||||
| _ -> None
|
||||
|> function
|
||||
| Some postId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/post/{postId}/edit")
|
||||
| None -> $"alert('unknown post object type {postObj.GetType().Name}')"
|
||||
|
||||
|
||||
/// A filter to generate nav links, highlighting the active link (exact match)
|
||||
type NavLinkFilter () =
|
||||
static member NavLink (ctx : Context, url : string, text : string) =
|
||||
let webLog = webLog ctx
|
||||
let _, path = WebLog.hostAndPath webLog
|
||||
let path = if path = "" then path else $"{path.Substring 1}/"
|
||||
seq {
|
||||
"<li class=\"nav-item\"><a class=\"nav-link"
|
||||
if (string ctx.Environments[0].["current_page"]).StartsWith $"{path}{url}" then " active"
|
||||
"\" href=\""
|
||||
WebLog.relativeUrl webLog (Permalink url)
|
||||
"\">"
|
||||
text
|
||||
"</a></li>"
|
||||
}
|
||||
|> Seq.fold (+) ""
|
||||
|
||||
|
||||
/// A filter to generate a link for theme asset (image, stylesheet, script, etc.)
|
||||
type ThemeAssetFilter () =
|
||||
static member ThemeAsset (ctx : Context, asset : string) =
|
||||
let webLog = webLog ctx
|
||||
WebLog.relativeUrl webLog (Permalink $"themes/{webLog.themePath}/{asset}")
|
||||
|
||||
|
||||
/// Create various items in the page header based on the state of the page being generated
|
||||
type PageHeadTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
let webLog = webLog context
|
||||
// spacer
|
||||
let s = " "
|
||||
let getBool name =
|
||||
context.Environments[0].[name] |> Option.ofObj |> Option.map Convert.ToBoolean |> Option.defaultValue false
|
||||
|
||||
result.WriteLine $"""<meta name="generator" content="{context.Environments[0].["generator"]}">"""
|
||||
|
||||
// Theme assets
|
||||
if assetExists "style.css" webLog then
|
||||
result.WriteLine $"""{s}<link rel="stylesheet" href="{ThemeAssetFilter.ThemeAsset (context, "style.css")}">"""
|
||||
if assetExists "favicon.ico" webLog then
|
||||
result.WriteLine $"""{s}<link rel="icon" href="{ThemeAssetFilter.ThemeAsset (context, "favicon.ico")}">"""
|
||||
|
||||
// RSS feeds and canonical URLs
|
||||
let feedLink title url =
|
||||
let escTitle = HttpUtility.HtmlAttributeEncode title
|
||||
let relUrl = WebLog.relativeUrl webLog (Permalink url)
|
||||
$"""{s}<link rel="alternate" type="application/rss+xml" title="{escTitle}" href="{relUrl}">"""
|
||||
|
||||
if webLog.rss.feedEnabled && getBool "is_home" then
|
||||
result.WriteLine (feedLink webLog.name webLog.rss.feedName)
|
||||
result.WriteLine $"""{s}<link rel="canonical" href="{WebLog.absoluteUrl webLog Permalink.empty}">"""
|
||||
|
||||
if webLog.rss.categoryEnabled && getBool "is_category_home" then
|
||||
let slug = context.Environments[0].["slug"] :?> string
|
||||
result.WriteLine (feedLink webLog.name $"category/{slug}/{webLog.rss.feedName}")
|
||||
|
||||
if webLog.rss.tagEnabled && getBool "is_tag_home" then
|
||||
let slug = context.Environments[0].["slug"] :?> string
|
||||
result.WriteLine (feedLink webLog.name $"tag/{slug}/{webLog.rss.feedName}")
|
||||
|
||||
if getBool "is_post" then
|
||||
let post = context.Environments[0].["model"] :?> PostDisplay
|
||||
let url = WebLog.absoluteUrl webLog (Permalink post.posts[0].permalink)
|
||||
result.WriteLine $"""{s}<link rel="canonical" href="{url}">"""
|
||||
|
||||
if getBool "is_page" then
|
||||
let page = context.Environments[0].["page"] :?> DisplayPage
|
||||
let url = WebLog.absoluteUrl webLog (Permalink page.permalink)
|
||||
result.WriteLine $"""{s}<link rel="canonical" href="{url}">"""
|
||||
|
||||
|
||||
/// Create various items in the page header based on the state of the page being generated
|
||||
type PageFootTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
let webLog = webLog context
|
||||
// spacer
|
||||
let s = " "
|
||||
|
||||
if webLog.autoHtmx then
|
||||
result.WriteLine $"{s}{RenderView.AsString.htmlNode Htmx.Script.minified}"
|
||||
|
||||
if assetExists "script.js" webLog then
|
||||
result.WriteLine $"""{s}<script src="{ThemeAssetFilter.ThemeAsset (context, "script.js")}"></script>"""
|
||||
|
||||
|
||||
/// A filter to generate a relative link
|
||||
type RelativeLinkFilter () =
|
||||
static member RelativeLink (ctx : Context, item : obj) =
|
||||
permalink ctx item WebLog.relativeUrl
|
||||
|
||||
|
||||
/// A filter to generate a link with posts tagged with the given tag
|
||||
type TagLinkFilter () =
|
||||
static member TagLink (ctx : Context, tag : string) =
|
||||
ctx.Environments[0].["tag_mappings"] :?> TagMap list
|
||||
|> List.tryFind (fun it -> it.tag = tag)
|
||||
|> function
|
||||
| Some tagMap -> tagMap.urlValue
|
||||
| None -> tag.Replace (" ", "+")
|
||||
|> function tagUrl -> WebLog.relativeUrl (webLog ctx) (Permalink $"tag/{tagUrl}/")
|
||||
|
||||
|
||||
/// Create links for a user to log on or off, and a dashboard link if they are logged off
|
||||
type UserLinksTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
let webLog = webLog context
|
||||
let link it = WebLog.relativeUrl webLog (Permalink it)
|
||||
seq {
|
||||
"""<ul class="navbar-nav flex-grow-1 justify-content-end">"""
|
||||
match Convert.ToBoolean context.Environments[0].["logged_on"] with
|
||||
| true ->
|
||||
$"""<li class="nav-item"><a class="nav-link" href="{link "admin/dashboard"}">Dashboard</a></li>"""
|
||||
$"""<li class="nav-item"><a class="nav-link" href="{link "user/log-off"}">Log Off</a></li>"""
|
||||
| false ->
|
||||
$"""<li class="nav-item"><a class="nav-link" href="{link "user/log-on"}">Log On</a></li>"""
|
||||
"</ul>"
|
||||
}
|
||||
|> Seq.iter result.WriteLine
|
||||
|
||||
/// A filter to retrieve the value of a meta item from a list
|
||||
// (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`)
|
||||
type ValueFilter () =
|
||||
static member Value (_ : Context, items : MetaItem list, name : string) =
|
||||
match items |> List.tryFind (fun it -> it.name = name) with
|
||||
| Some item -> item.value
|
||||
| None -> $"-- {name} not found --"
|
||||
|
||||
|
||||
open System.Collections.Generic
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
|
||||
/// Register custom filters/tags and safe types
|
||||
let register () =
|
||||
[ typeof<AbsoluteLinkFilter>; typeof<CategoryLinkFilter>; typeof<EditPageLinkFilter>; typeof<EditPostLinkFilter>
|
||||
typeof<NavLinkFilter>; typeof<RelativeLinkFilter>; typeof<TagLinkFilter>; typeof<ThemeAssetFilter>
|
||||
typeof<ValueFilter>
|
||||
]
|
||||
|> List.iter Template.RegisterFilter
|
||||
|
||||
Template.RegisterTag<PageHeadTag> "page_head"
|
||||
Template.RegisterTag<PageFootTag> "page_foot"
|
||||
Template.RegisterTag<UserLinksTag> "user_links"
|
||||
|
||||
[ // Domain types
|
||||
typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog>
|
||||
// View models
|
||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
||||
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditPageModel>; typeof<EditPostModel>
|
||||
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
|
||||
typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
|
||||
typeof<UserMessage>
|
||||
// Framework types
|
||||
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
|
||||
typeof<string list>; typeof<string option>; typeof<TagMap list>
|
||||
]
|
||||
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
|
||||
504
src/MyWebLog/Handlers/Admin.fs
Normal file
504
src/MyWebLog/Handlers/Admin.fs
Normal file
@@ -0,0 +1,504 @@
|
||||
/// Handlers to manipulate admin functions
|
||||
module MyWebLog.Handlers.Admin
|
||||
|
||||
open System.Threading.Tasks
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// GET /admin
|
||||
let dashboard : HttpHandler = fun next ctx -> task {
|
||||
let webLogId = ctx.WebLog.id
|
||||
let data = ctx.Data
|
||||
let getCount (f : WebLogId -> Task<int>) = f webLogId
|
||||
let! posts = data.Post.countByStatus Published |> getCount
|
||||
let! drafts = data.Post.countByStatus Draft |> getCount
|
||||
let! pages = data.Page.countAll |> getCount
|
||||
let! listed = data.Page.countListed |> getCount
|
||||
let! cats = data.Category.countAll |> getCount
|
||||
let! topCats = data.Category.countTopLevel |> getCount
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
page_title = "Dashboard"
|
||||
model =
|
||||
{ posts = posts
|
||||
drafts = drafts
|
||||
pages = pages
|
||||
listedPages = listed
|
||||
categories = cats
|
||||
topLevelCategories = topCats
|
||||
}
|
||||
|}
|
||||
|> viewForTheme "admin" "dashboard" next ctx
|
||||
}
|
||||
|
||||
// -- CATEGORIES --
|
||||
|
||||
// GET /admin/categories
|
||||
let listCategories : HttpHandler = fun next ctx -> task {
|
||||
let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data
|
||||
let hash = Hash.FromAnonymousObject {|
|
||||
web_log = ctx.WebLog
|
||||
categories = CategoryCache.get ctx
|
||||
page_title = "Categories"
|
||||
csrf = csrfToken ctx
|
||||
|}
|
||||
hash.Add ("category_list", catListTemplate.Render hash)
|
||||
return! viewForTheme "admin" "category-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/categories/bare
|
||||
let listCategoriesBare : HttpHandler = fun next ctx -> task {
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
categories = CategoryCache.get ctx
|
||||
csrf = csrfToken ctx
|
||||
|}
|
||||
|> bareForTheme "admin" "category-list-body" next ctx
|
||||
}
|
||||
|
||||
|
||||
// GET /admin/category/{id}/edit
|
||||
let editCategory catId : HttpHandler = fun next ctx -> task {
|
||||
let! result = task {
|
||||
match catId with
|
||||
| "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" })
|
||||
| _ ->
|
||||
match! ctx.Data.Category.findById (CategoryId catId) ctx.WebLog.id with
|
||||
| Some cat -> return Some ("Edit Category", cat)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, cat) ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditCategoryModel.fromCategory cat
|
||||
page_title = title
|
||||
categories = CategoryCache.get ctx
|
||||
|}
|
||||
|> bareForTheme "admin" "category-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/category/save
|
||||
let saveCategory : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditCategoryModel> ()
|
||||
let! category = task {
|
||||
match model.categoryId with
|
||||
| "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLog.id }
|
||||
| catId -> return! data.Category.findById (CategoryId catId) webLog.id
|
||||
}
|
||||
match category with
|
||||
| Some cat ->
|
||||
let cat =
|
||||
{ cat with
|
||||
name = model.name
|
||||
slug = model.slug
|
||||
description = if model.description = "" then None else Some model.description
|
||||
parentId = if model.parentId = "" then None else Some (CategoryId model.parentId)
|
||||
}
|
||||
do! (match model.categoryId with "new" -> data.Category.add | _ -> data.Category.update) cat
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Category saved successfully" }
|
||||
return! listCategoriesBare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/category/{id}/delete
|
||||
let deleteCategory catId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.Category.delete (CategoryId catId) ctx.WebLog.id with
|
||||
| true ->
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" }
|
||||
return! listCategoriesBare next ctx
|
||||
}
|
||||
|
||||
// -- PAGES --
|
||||
|
||||
// GET /admin/pages
|
||||
// GET /admin/pages/page/{pageNbr}
|
||||
let listPages pageNbr : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let! pages = ctx.Data.Page.findPageOfPages webLog.id pageNbr
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
pages = pages |> List.map (DisplayPage.fromPageMinimal webLog)
|
||||
page_title = "Pages"
|
||||
page_nbr = pageNbr
|
||||
prev_page = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}"
|
||||
next_page = $"/page/{pageNbr + 1}"
|
||||
|}
|
||||
|> viewForTheme "admin" "page-list" next ctx
|
||||
}
|
||||
|
||||
// GET /admin/page/{id}/edit
|
||||
let editPage pgId : HttpHandler = fun next ctx -> task {
|
||||
let! result = task {
|
||||
match pgId with
|
||||
| "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" })
|
||||
| _ ->
|
||||
match! ctx.Data.Page.findFullById (PageId pgId) ctx.WebLog.id with
|
||||
| Some page -> return Some ("Edit Page", page)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, page) ->
|
||||
let model = EditPageModel.fromPage page
|
||||
let! templates = templatesForTheme ctx "page"
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = model
|
||||
metadata = Array.zip model.metaNames model.metaValues
|
||||
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])
|
||||
page_title = title
|
||||
templates = templates
|
||||
|}
|
||||
|> viewForTheme "admin" "page-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /admin/page/{id}/permalinks
|
||||
let editPagePermalinks pgId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.Page.findFullById (PageId pgId) ctx.WebLog.id with
|
||||
| Some pg ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = ManagePermalinksModel.fromPage pg
|
||||
page_title = $"Manage Prior Permalinks"
|
||||
|}
|
||||
|> viewForTheme "admin" "permalinks" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/page/permalinks
|
||||
let savePagePermalinks : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let! model = ctx.BindFormAsync<ManagePermalinksModel> ()
|
||||
let links = model.prior |> Array.map Permalink |> List.ofArray
|
||||
match! ctx.Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links with
|
||||
| true ->
|
||||
do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{model.id}/permalinks")) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/page/{id}/delete
|
||||
let deletePage pgId : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
match! ctx.Data.Page.delete (PageId pgId) webLog.id with
|
||||
| true ->
|
||||
do! PageListCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx
|
||||
}
|
||||
|
||||
open System
|
||||
|
||||
#nowarn "3511"
|
||||
|
||||
// POST /admin/page/save
|
||||
let savePage : HttpHandler = fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditPageModel> ()
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let now = DateTime.UtcNow
|
||||
let! pg = task {
|
||||
match model.pageId with
|
||||
| "new" ->
|
||||
return Some
|
||||
{ Page.empty with
|
||||
id = PageId.create ()
|
||||
webLogId = webLog.id
|
||||
authorId = userId ctx
|
||||
publishedOn = now
|
||||
}
|
||||
| pgId -> return! data.Page.findFullById (PageId pgId) webLog.id
|
||||
}
|
||||
match pg with
|
||||
| Some page ->
|
||||
let updateList = page.showInPageList <> model.isShownInPageList
|
||||
let revision = { asOf = now; text = MarkupText.parse $"{model.source}: {model.text}" }
|
||||
// Detect a permalink change, and add the prior one to the prior list
|
||||
let page =
|
||||
match Permalink.toString page.permalink with
|
||||
| "" -> page
|
||||
| link when link = model.permalink -> page
|
||||
| _ -> { page with priorPermalinks = page.permalink :: page.priorPermalinks }
|
||||
let page =
|
||||
{ page with
|
||||
title = model.title
|
||||
permalink = Permalink model.permalink
|
||||
updatedOn = now
|
||||
showInPageList = model.isShownInPageList
|
||||
template = match model.template with "" -> None | tmpl -> Some tmpl
|
||||
text = MarkupText.toHtml revision.text
|
||||
metadata = Seq.zip model.metaNames model.metaValues
|
||||
|> Seq.filter (fun it -> fst it > "")
|
||||
|> Seq.map (fun it -> { name = fst it; value = snd it })
|
||||
|> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}")
|
||||
|> List.ofSeq
|
||||
revisions = match page.revisions |> List.tryHead with
|
||||
| Some r when r.text = revision.text -> page.revisions
|
||||
| _ -> revision :: page.revisions
|
||||
}
|
||||
do! (if model.pageId = "new" then data.Page.add else data.Page.update) page
|
||||
if updateList then do! PageListCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Page saved successfully" }
|
||||
return!
|
||||
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{PageId.toString page.id}/edit")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// -- TAG MAPPINGS --
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Get the hash necessary to render the tag mapping list
|
||||
let private tagMappingHash (ctx : HttpContext) = task {
|
||||
let! mappings = ctx.Data.TagMap.findByWebLog ctx.WebLog.id
|
||||
return Hash.FromAnonymousObject {|
|
||||
web_log = ctx.WebLog
|
||||
csrf = csrfToken ctx
|
||||
mappings = mappings
|
||||
mapping_ids = mappings |> List.map (fun it -> { name = it.tag; value = TagMapId.toString it.id })
|
||||
|}
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mappings
|
||||
let tagMappings : HttpHandler = fun next ctx -> task {
|
||||
let! hash = tagMappingHash ctx
|
||||
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data
|
||||
|
||||
hash.Add ("tag_mapping_list", listTemplate.Render hash)
|
||||
hash.Add ("page_title", "Tag Mappings")
|
||||
|
||||
return! viewForTheme "admin" "tag-mapping-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mappings/bare
|
||||
let tagMappingsBare : HttpHandler = fun next ctx -> task {
|
||||
let! hash = tagMappingHash ctx
|
||||
return! bareForTheme "admin" "tag-mapping-list-body" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mapping/{id}/edit
|
||||
let editMapping tagMapId : HttpHandler = fun next ctx -> task {
|
||||
let isNew = tagMapId = "new"
|
||||
let tagMap =
|
||||
if isNew then
|
||||
Task.FromResult (Some { TagMap.empty with id = TagMapId "new" })
|
||||
else
|
||||
ctx.Data.TagMap.findById (TagMapId tagMapId) ctx.WebLog.id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditTagMapModel.fromMapping tm
|
||||
page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.tag} Tag"
|
||||
|}
|
||||
|> bareForTheme "admin" "tag-mapping-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/save
|
||||
let saveMapping : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditTagMapModel> ()
|
||||
let tagMap =
|
||||
if model.id = "new" then
|
||||
Task.FromResult (Some { TagMap.empty with id = TagMapId.create (); webLogId = ctx.WebLog.id })
|
||||
else
|
||||
data.TagMap.findById (TagMapId model.id) ctx.WebLog.id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
do! data.TagMap.save { tm with tag = model.tag.ToLower (); urlValue = model.urlValue.ToLower () }
|
||||
do! addMessage ctx { UserMessage.success with message = "Tag mapping saved successfully" }
|
||||
return! tagMappingsBare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/{id}/delete
|
||||
let deleteMapping tagMapId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.TagMap.delete (TagMapId tagMapId) ctx.WebLog.id with
|
||||
| true -> do! addMessage ctx { UserMessage.success with message = "Tag mapping deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Tag mapping not found; nothing deleted" }
|
||||
return! tagMappingsBare next ctx
|
||||
}
|
||||
|
||||
// -- THEMES --
|
||||
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open System.Text.RegularExpressions
|
||||
open MyWebLog.Data
|
||||
|
||||
// GET /admin/theme/update
|
||||
let themeUpdatePage : HttpHandler = fun next ctx -> task {
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
page_title = "Upload Theme"
|
||||
|}
|
||||
|> viewForTheme "admin" "upload-theme" next ctx
|
||||
}
|
||||
|
||||
/// Update the name and version for a theme based on the version.txt file, if present
|
||||
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
|
||||
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
|
||||
| Some versionItem ->
|
||||
use versionFile = new StreamReader(versionItem.Open ())
|
||||
let! versionText = versionFile.ReadToEndAsync ()
|
||||
let parts = versionText.Trim().Replace("\r", "").Split "\n"
|
||||
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.id
|
||||
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
|
||||
return { theme with name = displayName; version = version }
|
||||
| None ->
|
||||
return { theme with name = ThemeId.toString theme.id; version = now () }
|
||||
}
|
||||
|
||||
/// Delete all theme assets, and remove templates from theme
|
||||
let private checkForCleanLoad (theme : Theme) cleanLoad (data : IData) = backgroundTask {
|
||||
if cleanLoad then
|
||||
do! data.ThemeAsset.deleteByTheme theme.id
|
||||
return { theme with templates = [] }
|
||||
else
|
||||
return theme
|
||||
}
|
||||
|
||||
/// Update the theme with all templates from the ZIP archive
|
||||
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let tasks =
|
||||
zip.Entries
|
||||
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|
||||
|> Seq.map (fun templateItem -> backgroundTask {
|
||||
use templateFile = new StreamReader (templateItem.Open ())
|
||||
let! template = templateFile.ReadToEndAsync ()
|
||||
return { name = templateItem.Name.Replace (".liquid", ""); text = template }
|
||||
})
|
||||
let! templates = Task.WhenAll tasks
|
||||
return
|
||||
templates
|
||||
|> Array.fold (fun t template ->
|
||||
{ t with templates = template :: (t.templates |> List.filter (fun it -> it.name <> template.name)) })
|
||||
theme
|
||||
}
|
||||
|
||||
/// Update theme assets from the ZIP archive
|
||||
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
|
||||
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
|
||||
let assetName = asset.FullName.Replace ("wwwroot/", "")
|
||||
if assetName <> "" && not (assetName.EndsWith "/") then
|
||||
use stream = new MemoryStream ()
|
||||
do! asset.Open().CopyToAsync stream
|
||||
do! data.ThemeAsset.save
|
||||
{ id = ThemeAssetId (themeId, assetName)
|
||||
updatedOn = asset.LastWriteTime.DateTime
|
||||
data = stream.ToArray ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the theme name from the file name given
|
||||
let getThemeName (fileName : string) =
|
||||
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
|
||||
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok themeName else Error $"Theme name {fileName} is invalid"
|
||||
|
||||
/// Load a theme from the given stream, which should contain a ZIP archive
|
||||
let loadThemeFromZip themeName file clean (data : IData) = backgroundTask {
|
||||
use zip = new ZipArchive (file, ZipArchiveMode.Read)
|
||||
let themeId = ThemeId themeName
|
||||
let! theme = backgroundTask {
|
||||
match! data.Theme.findById themeId with
|
||||
| Some t -> return t
|
||||
| None -> return { Theme.empty with id = themeId }
|
||||
}
|
||||
let! theme = updateNameAndVersion theme zip
|
||||
let! theme = checkForCleanLoad theme clean data
|
||||
let! theme = updateTemplates theme zip
|
||||
do! data.Theme.save theme
|
||||
do! updateAssets themeId zip data
|
||||
}
|
||||
|
||||
// POST /admin/theme/update
|
||||
let updateTheme : HttpHandler = fun next ctx -> task {
|
||||
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
||||
let themeFile = Seq.head ctx.Request.Form.Files
|
||||
match getThemeName themeFile.FileName with
|
||||
| Ok themeName when themeName <> "admin" ->
|
||||
let data = ctx.Data
|
||||
use stream = new MemoryStream ()
|
||||
do! themeFile.CopyToAsync stream
|
||||
do! loadThemeFromZip themeName stream true data
|
||||
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data
|
||||
do! addMessage ctx { UserMessage.success with message = "Theme updated successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/dashboard")) next ctx
|
||||
| Ok _ ->
|
||||
do! addMessage ctx { UserMessage.error with message = "You may not replace the admin theme" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/theme/update")) next ctx
|
||||
| Error message ->
|
||||
do! addMessage ctx { UserMessage.error with message = message }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/theme/update")) next ctx
|
||||
else
|
||||
return! RequestErrors.BAD_REQUEST "Bad request" next ctx
|
||||
}
|
||||
|
||||
// -- WEB LOG SETTINGS --
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
// GET /admin/settings
|
||||
let settings : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! allPages = data.Page.all webLog.id
|
||||
let! themes = data.Theme.all ()
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = SettingsModel.fromWebLog webLog
|
||||
pages =
|
||||
seq {
|
||||
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
||||
yield! allPages
|
||||
|> List.sortBy (fun p -> p.title.ToLower ())
|
||||
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title))
|
||||
}
|
||||
|> Array.ofSeq
|
||||
themes = themes
|
||||
|> Seq.ofList
|
||||
|> Seq.map (fun it ->
|
||||
KeyValuePair.Create (ThemeId.toString it.id, $"{it.name} (v{it.version})"))
|
||||
|> Array.ofSeq
|
||||
web_log = webLog
|
||||
page_title = "Web Log Settings"
|
||||
|}
|
||||
|> viewForTheme "admin" "settings" next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings
|
||||
let saveSettings : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||
match! data.WebLog.findById webLog.id with
|
||||
| Some webLog ->
|
||||
let webLog = model.update webLog
|
||||
do! data.WebLog.updateSettings webLog
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set webLog
|
||||
|
||||
do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
22
src/MyWebLog/Handlers/Error.fs
Normal file
22
src/MyWebLog/Handlers/Error.fs
Normal file
@@ -0,0 +1,22 @@
|
||||
/// Handlers for error conditions
|
||||
module MyWebLog.Handlers.Error
|
||||
|
||||
open System.Net
|
||||
open System.Threading.Tasks
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler = fun next ctx -> task {
|
||||
if ctx.Request.Method = "GET" then
|
||||
let returnUrl = WebUtility.UrlEncode ctx.Request.Path
|
||||
return!
|
||||
redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx
|
||||
else
|
||||
return! (setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None) next ctx
|
||||
}
|
||||
|
||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||
let notFound : HttpHandler =
|
||||
setStatusCode 404 >=> text "Not found"
|
||||
459
src/MyWebLog/Handlers/Feed.fs
Normal file
459
src/MyWebLog/Handlers/Feed.fs
Normal file
@@ -0,0 +1,459 @@
|
||||
/// Functions to support generating RSS feeds
|
||||
module MyWebLog.Handlers.Feed
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Net
|
||||
open System.ServiceModel.Syndication
|
||||
open System.Text.RegularExpressions
|
||||
open System.Xml
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// ~~ FEED GENERATION ~~
|
||||
|
||||
/// The type of feed to generate
|
||||
type FeedType =
|
||||
| StandardFeed of string
|
||||
| CategoryFeed of CategoryId * string
|
||||
| TagFeed of string * string
|
||||
| Custom of CustomFeed * string
|
||||
|
||||
/// Derive the type of RSS feed requested
|
||||
let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option =
|
||||
let webLog = ctx.WebLog
|
||||
let debug = debug "Feed" ctx
|
||||
let name = $"/{webLog.rss.feedName}"
|
||||
let postCount = defaultArg webLog.rss.itemsInFeed webLog.postsPerPage
|
||||
debug (fun () -> $"Considering potential feed for {feedPath} (configured feed name {name})")
|
||||
// Standard feed
|
||||
match webLog.rss.feedEnabled && feedPath = name with
|
||||
| true ->
|
||||
debug (fun () -> "Found standard feed")
|
||||
Some (StandardFeed feedPath, postCount)
|
||||
| false ->
|
||||
// Category and tag feeds are handled by defined routes; check for custom feed
|
||||
match webLog.rss.customFeeds
|
||||
|> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with
|
||||
| Some feed ->
|
||||
debug (fun () -> "Found custom feed")
|
||||
Some (Custom (feed, feedPath),
|
||||
feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount)
|
||||
| None ->
|
||||
debug (fun () -> $"No matching feed found")
|
||||
None
|
||||
|
||||
/// Determine the function to retrieve posts for the given feed
|
||||
let private getFeedPosts ctx feedType =
|
||||
let childIds catId =
|
||||
let cat = CategoryCache.get ctx |> Array.find (fun c -> c.id = CategoryId.toString catId)
|
||||
getCategoryIds cat.slug ctx
|
||||
let data = ctx.Data
|
||||
match feedType with
|
||||
| StandardFeed _ -> data.Post.findPageOfPublishedPosts ctx.WebLog.id 1
|
||||
| CategoryFeed (catId, _) -> data.Post.findPageOfCategorizedPosts ctx.WebLog.id (childIds catId) 1
|
||||
| TagFeed (tag, _) -> data.Post.findPageOfTaggedPosts ctx.WebLog.id tag 1
|
||||
| Custom (feed, _) ->
|
||||
match feed.source with
|
||||
| Category catId -> data.Post.findPageOfCategorizedPosts ctx.WebLog.id (childIds catId) 1
|
||||
| Tag tag -> data.Post.findPageOfTaggedPosts ctx.WebLog.id tag 1
|
||||
|
||||
/// Strip HTML from a string
|
||||
let private stripHtml text = WebUtility.HtmlDecode <| Regex.Replace (text, "<(.|\n)*?>", "")
|
||||
|
||||
/// XML namespaces for building RSS feeds
|
||||
[<RequireQualifiedAccess>]
|
||||
module private Namespace =
|
||||
|
||||
/// Enables encoded (HTML) content
|
||||
let content = "http://purl.org/rss/1.0/modules/content/"
|
||||
|
||||
/// The dc XML namespace
|
||||
let dc = "http://purl.org/dc/elements/1.1/"
|
||||
|
||||
/// iTunes elements
|
||||
let iTunes = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
|
||||
/// Enables chapters
|
||||
let psc = "http://podlove.org/simple-chapters/"
|
||||
|
||||
/// Enables another "subscribe" option
|
||||
let rawVoice = "http://www.rawvoice.com/rawvoiceRssModule/"
|
||||
|
||||
/// Create a feed item from the given post
|
||||
let private toFeedItem webLog (authors : MetaItem list) (cats : DisplayCategory[]) (tagMaps : TagMap list)
|
||||
(post : Post) =
|
||||
let plainText =
|
||||
let endingP = post.text.IndexOf "</p>"
|
||||
stripHtml <| if endingP >= 0 then post.text[..(endingP - 1)] else post.text
|
||||
let item = SyndicationItem (
|
||||
Id = WebLog.absoluteUrl webLog post.permalink,
|
||||
Title = TextSyndicationContent.CreateHtmlContent post.title,
|
||||
PublishDate = DateTimeOffset post.publishedOn.Value,
|
||||
LastUpdatedTime = DateTimeOffset post.updatedOn,
|
||||
Content = TextSyndicationContent.CreatePlaintextContent plainText)
|
||||
item.AddPermalink (Uri item.Id)
|
||||
|
||||
let xmlDoc = XmlDocument ()
|
||||
|
||||
let encoded =
|
||||
let txt =
|
||||
post.text
|
||||
.Replace("src=\"/", $"src=\"{webLog.urlBase}/")
|
||||
.Replace ("href=\"/", $"href=\"{webLog.urlBase}/")
|
||||
let it = xmlDoc.CreateElement ("content", "encoded", Namespace.content)
|
||||
let _ = it.AppendChild (xmlDoc.CreateCDataSection txt)
|
||||
it
|
||||
item.ElementExtensions.Add encoded
|
||||
|
||||
item.Authors.Add (SyndicationPerson (
|
||||
Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value))
|
||||
[ post.categoryIds
|
||||
|> List.map (fun catId ->
|
||||
let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId)
|
||||
SyndicationCategory (cat.name, WebLog.absoluteUrl webLog (Permalink $"category/{cat.slug}/"), cat.name))
|
||||
post.tags
|
||||
|> List.map (fun tag ->
|
||||
let urlTag =
|
||||
match tagMaps |> List.tryFind (fun tm -> tm.tag = tag) with
|
||||
| Some tm -> tm.urlValue
|
||||
| None -> tag.Replace (" ", "+")
|
||||
SyndicationCategory (tag, WebLog.absoluteUrl webLog (Permalink $"tag/{urlTag}/"), $"{tag} (tag)"))
|
||||
]
|
||||
|> List.concat
|
||||
|> List.iter item.Categories.Add
|
||||
item
|
||||
|
||||
/// Add episode information to a podcast feed item
|
||||
let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : SyndicationItem) =
|
||||
let podcast = Option.get feed.podcast
|
||||
let meta name = post.metadata |> List.tryFind (fun it -> it.name = name)
|
||||
let value (item : MetaItem) = item.value
|
||||
let epMediaUrl =
|
||||
match (meta >> Option.get >> value) "episode_media_file" with
|
||||
| link when link.StartsWith "http" -> link
|
||||
| link when Option.isSome podcast.mediaBaseUrl -> $"{podcast.mediaBaseUrl.Value}{link}"
|
||||
| link -> WebLog.absoluteUrl webLog (Permalink link)
|
||||
let epMediaType =
|
||||
match meta "episode_media_type", podcast.defaultMediaType with
|
||||
| Some epType, _ -> Some epType.value
|
||||
| None, Some defType -> Some defType
|
||||
| _ -> None
|
||||
let epImageUrl =
|
||||
match defaultArg ((meta >> Option.map value) "episode_image") (Permalink.toString podcast.imageUrl) with
|
||||
| link when link.StartsWith "http" -> link
|
||||
| link -> WebLog.absoluteUrl webLog (Permalink link)
|
||||
let epExplicit =
|
||||
try
|
||||
(meta >> Option.map (value >> ExplicitRating.parse)) "episode_explicit"
|
||||
|> Option.defaultValue podcast.explicit
|
||||
|> ExplicitRating.toString
|
||||
with :? ArgumentException -> ExplicitRating.toString podcast.explicit
|
||||
|
||||
let xmlDoc = XmlDocument ()
|
||||
let enclosure =
|
||||
let it = xmlDoc.CreateElement "enclosure"
|
||||
it.SetAttribute ("url", epMediaUrl)
|
||||
meta "episode_media_length" |> Option.iter (fun len -> it.SetAttribute ("length", len.value))
|
||||
epMediaType |> Option.iter (fun typ -> it.SetAttribute ("type", typ))
|
||||
it
|
||||
let image =
|
||||
let it = xmlDoc.CreateElement ("itunes", "image", Namespace.iTunes)
|
||||
it.SetAttribute ("href", epImageUrl)
|
||||
it
|
||||
|
||||
item.ElementExtensions.Add enclosure
|
||||
item.ElementExtensions.Add image
|
||||
item.ElementExtensions.Add ("creator", Namespace.dc, podcast.displayedAuthor)
|
||||
item.ElementExtensions.Add ("author", Namespace.iTunes, podcast.displayedAuthor)
|
||||
item.ElementExtensions.Add ("explicit", Namespace.iTunes, epExplicit)
|
||||
meta "episode_subtitle"
|
||||
|> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it.value))
|
||||
meta "episode_duration"
|
||||
|> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it.value))
|
||||
|
||||
if post.metadata |> List.exists (fun it -> it.name = "chapter") then
|
||||
try
|
||||
let chapters = xmlDoc.CreateElement ("psc", "chapters", Namespace.psc)
|
||||
chapters.SetAttribute ("version", "1.2")
|
||||
|
||||
post.metadata
|
||||
|> List.filter (fun it -> it.name = "chapter")
|
||||
|> List.map (fun it ->
|
||||
TimeSpan.Parse (it.value.Split(" ")[0]), it.value.Substring (it.value.IndexOf(" ") + 1))
|
||||
|> List.sortBy fst
|
||||
|> List.iter (fun chap ->
|
||||
let chapter = xmlDoc.CreateElement ("psc", "chapter", Namespace.psc)
|
||||
chapter.SetAttribute ("start", (fst chap).ToString "hh:mm:ss")
|
||||
chapter.SetAttribute ("title", snd chap)
|
||||
chapters.AppendChild chapter |> ignore)
|
||||
|
||||
item.ElementExtensions.Add chapters
|
||||
with _ -> ()
|
||||
item
|
||||
|
||||
/// Add a namespace to the feed
|
||||
let private addNamespace (feed : SyndicationFeed) alias nsUrl =
|
||||
feed.AttributeExtensions.Add (XmlQualifiedName (alias, "http://www.w3.org/2000/xmlns/"), nsUrl)
|
||||
|
||||
/// Add items to the top of the feed required for podcasts
|
||||
let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) =
|
||||
let addChild (doc : XmlDocument) ns prefix name value (elt : XmlElement) =
|
||||
let child =
|
||||
if ns = "" then doc.CreateElement name else doc.CreateElement (prefix, name, ns)
|
||||
|> elt.AppendChild
|
||||
child.InnerText <- value
|
||||
elt
|
||||
|
||||
let podcast = Option.get feed.podcast
|
||||
let feedUrl = WebLog.absoluteUrl webLog feed.path
|
||||
let imageUrl =
|
||||
match podcast.imageUrl with
|
||||
| Permalink link when link.StartsWith "http" -> link
|
||||
| Permalink _ -> WebLog.absoluteUrl webLog podcast.imageUrl
|
||||
|
||||
let xmlDoc = XmlDocument ()
|
||||
|
||||
[ "dc", Namespace.dc; "itunes", Namespace.iTunes; "psc", Namespace.psc; "rawvoice", Namespace.rawVoice ]
|
||||
|> List.iter (fun (alias, nsUrl) -> addNamespace rssFeed alias nsUrl)
|
||||
|
||||
let categorization =
|
||||
let it = xmlDoc.CreateElement ("itunes", "category", Namespace.iTunes)
|
||||
it.SetAttribute ("text", podcast.iTunesCategory)
|
||||
podcast.iTunesSubcategory
|
||||
|> Option.iter (fun subCat ->
|
||||
let subCatElt = xmlDoc.CreateElement ("itunes", "category", Namespace.iTunes)
|
||||
subCatElt.SetAttribute ("text", subCat)
|
||||
it.AppendChild subCatElt |> ignore)
|
||||
it
|
||||
let image =
|
||||
[ "title", podcast.title
|
||||
"url", imageUrl
|
||||
"link", feedUrl
|
||||
]
|
||||
|> List.fold (fun elt (name, value) -> addChild xmlDoc "" "" name value elt) (xmlDoc.CreateElement "image")
|
||||
let iTunesImage =
|
||||
let it = xmlDoc.CreateElement ("itunes", "image", Namespace.iTunes)
|
||||
it.SetAttribute ("href", imageUrl)
|
||||
it
|
||||
let owner =
|
||||
[ "name", podcast.displayedAuthor
|
||||
"email", podcast.email
|
||||
]
|
||||
|> List.fold (fun elt (name, value) -> addChild xmlDoc Namespace.iTunes "itunes" name value elt)
|
||||
(xmlDoc.CreateElement ("itunes", "owner", Namespace.iTunes))
|
||||
let rawVoice =
|
||||
let it = xmlDoc.CreateElement ("rawvoice", "subscribe", Namespace.rawVoice)
|
||||
it.SetAttribute ("feed", feedUrl)
|
||||
it.SetAttribute ("itunes", "")
|
||||
it
|
||||
|
||||
rssFeed.ElementExtensions.Add image
|
||||
rssFeed.ElementExtensions.Add owner
|
||||
rssFeed.ElementExtensions.Add categorization
|
||||
rssFeed.ElementExtensions.Add iTunesImage
|
||||
rssFeed.ElementExtensions.Add rawVoice
|
||||
rssFeed.ElementExtensions.Add ("summary", Namespace.iTunes, podcast.summary)
|
||||
rssFeed.ElementExtensions.Add ("author", Namespace.iTunes, podcast.displayedAuthor)
|
||||
rssFeed.ElementExtensions.Add ("explicit", Namespace.iTunes, ExplicitRating.toString podcast.explicit)
|
||||
podcast.subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", Namespace.iTunes, sub))
|
||||
|
||||
/// Get the feed's self reference and non-feed link
|
||||
let private selfAndLink webLog feedType ctx =
|
||||
let withoutFeed (it : string) = Permalink (it.Replace ($"/{webLog.rss.feedName}", ""))
|
||||
match feedType with
|
||||
| StandardFeed path
|
||||
| CategoryFeed (_, path)
|
||||
| TagFeed (_, path) -> Permalink path[1..], withoutFeed path
|
||||
| Custom (feed, _) ->
|
||||
match feed.source with
|
||||
| Category (CategoryId catId) ->
|
||||
feed.path, Permalink $"category/{(CategoryCache.get ctx |> Array.find (fun c -> c.id = catId)).slug}"
|
||||
| Tag tag -> feed.path, Permalink $"""tag/{tag.Replace(" ", "+")}/"""
|
||||
|
||||
/// Set the title and description of the feed based on its source
|
||||
let private setTitleAndDescription feedType (webLog : WebLog) (cats : DisplayCategory[]) (feed : SyndicationFeed) =
|
||||
let cleanText opt def = TextSyndicationContent (stripHtml (defaultArg opt def))
|
||||
match feedType with
|
||||
| StandardFeed _ ->
|
||||
feed.Title <- cleanText None webLog.name
|
||||
feed.Description <- cleanText webLog.subtitle webLog.name
|
||||
| CategoryFeed (CategoryId catId, _) ->
|
||||
let cat = cats |> Array.find (fun it -> it.id = catId)
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category"""
|
||||
feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """
|
||||
| TagFeed (tag, _) ->
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag"""
|
||||
feed.Description <- cleanText None $"""Posts with the "{tag}" tag"""
|
||||
| Custom (custom, _) ->
|
||||
match custom.podcast with
|
||||
| Some podcast ->
|
||||
feed.Title <- cleanText None podcast.title
|
||||
feed.Description <- cleanText podcast.subtitle podcast.title
|
||||
| None ->
|
||||
match custom.source with
|
||||
| Category (CategoryId catId) ->
|
||||
let cat = cats |> Array.find (fun it -> it.id = catId)
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category"""
|
||||
feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """
|
||||
| Tag tag ->
|
||||
feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag"""
|
||||
feed.Description <- cleanText None $"""Posts with the "{tag}" tag"""
|
||||
|
||||
/// Create a feed with a known non-zero-length list of posts
|
||||
let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! authors = getAuthors webLog posts data
|
||||
let! tagMaps = getTagMappings webLog posts data
|
||||
let cats = CategoryCache.get ctx
|
||||
let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None
|
||||
let self, link = selfAndLink webLog feedType ctx
|
||||
|
||||
let toItem post =
|
||||
let item = toFeedItem webLog authors cats tagMaps post
|
||||
match podcast with
|
||||
| Some feed when post.metadata |> List.exists (fun it -> it.name = "episode_media_file") ->
|
||||
addEpisode webLog feed post item
|
||||
| Some _ ->
|
||||
warn "Feed" ctx $"[{webLog.name} {Permalink.toString self}] \"{stripHtml post.title}\" has no media"
|
||||
item
|
||||
| _ -> item
|
||||
|
||||
let feed = SyndicationFeed ()
|
||||
addNamespace feed "content" Namespace.content
|
||||
setTitleAndDescription feedType webLog cats feed
|
||||
|
||||
feed.LastUpdatedTime <- (List.head posts).updatedOn |> DateTimeOffset
|
||||
feed.Generator <- generator ctx
|
||||
feed.Items <- posts |> Seq.ofList |> Seq.map toItem
|
||||
feed.Language <- "en"
|
||||
feed.Id <- WebLog.absoluteUrl webLog link
|
||||
webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy)
|
||||
|
||||
feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L))
|
||||
feed.ElementExtensions.Add ("link", "", WebLog.absoluteUrl webLog link)
|
||||
|
||||
podcast |> Option.iter (addPodcast webLog feed)
|
||||
|
||||
use mem = new MemoryStream ()
|
||||
use xml = XmlWriter.Create mem
|
||||
feed.SaveAsRss20 xml
|
||||
xml.Close ()
|
||||
|
||||
let _ = mem.Seek (0L, SeekOrigin.Begin)
|
||||
let rdr = new StreamReader(mem)
|
||||
let! output = rdr.ReadToEndAsync ()
|
||||
|
||||
return! (setHttpHeader "Content-Type" "text/xml" >=> setStatusCode 200 >=> setBodyFromString output) next ctx
|
||||
}
|
||||
|
||||
// GET {any-prescribed-feed}
|
||||
let generate (feedType : FeedType) postCount : HttpHandler = fun next ctx -> backgroundTask {
|
||||
match! getFeedPosts ctx feedType postCount with
|
||||
| posts when List.length posts > 0 -> return! createFeed feedType posts next ctx
|
||||
| _ -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// ~~ FEED ADMINISTRATION ~~
|
||||
|
||||
open DotLiquid
|
||||
|
||||
// GET: /admin/rss/settings
|
||||
let editSettings : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let feeds =
|
||||
webLog.rss.customFeeds
|
||||
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|
||||
|> Array.ofList
|
||||
return! Hash.FromAnonymousObject
|
||||
{| csrf = csrfToken ctx
|
||||
page_title = "RSS Settings"
|
||||
model = EditRssModel.fromRssOptions webLog.rss
|
||||
custom_feeds = feeds
|
||||
|}
|
||||
|> viewForTheme "admin" "rss-settings" next ctx
|
||||
}
|
||||
|
||||
// POST: /admin/rss/settings
|
||||
let saveSettings : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditRssModel> ()
|
||||
match! data.WebLog.findById ctx.WebLog.id with
|
||||
| Some webLog ->
|
||||
let webLog = { webLog with rss = model.updateOptions webLog.rss }
|
||||
do! data.WebLog.updateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
do! addMessage ctx { UserMessage.success with message = "RSS settings updated successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /admin/rss/{id}/edit
|
||||
let editCustomFeed feedId : HttpHandler = fun next ctx -> task {
|
||||
let customFeed =
|
||||
match feedId with
|
||||
| "new" -> Some { CustomFeed.empty with id = CustomFeedId "new" }
|
||||
| _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId)
|
||||
match customFeed with
|
||||
| Some f ->
|
||||
return! Hash.FromAnonymousObject
|
||||
{| csrf = csrfToken ctx
|
||||
page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed"""
|
||||
model = EditCustomFeedModel.fromFeed f
|
||||
categories = CategoryCache.get ctx
|
||||
|}
|
||||
|> viewForTheme "admin" "custom-feed-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /admin/rss/save
|
||||
let saveCustomFeed : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
match! data.WebLog.findById ctx.WebLog.id with
|
||||
| Some webLog ->
|
||||
let! model = ctx.BindFormAsync<EditCustomFeedModel> ()
|
||||
let theFeed =
|
||||
match model.id with
|
||||
| "new" -> Some { CustomFeed.empty with id = CustomFeedId.create () }
|
||||
| _ -> webLog.rss.customFeeds |> List.tryFind (fun it -> CustomFeedId.toString it.id = model.id)
|
||||
match theFeed with
|
||||
| Some feed ->
|
||||
let feeds = model.updateFeed feed :: (webLog.rss.customFeeds |> List.filter (fun it -> it.id <> feed.id))
|
||||
let webLog = { webLog with rss = { webLog.rss with customFeeds = feeds } }
|
||||
do! data.WebLog.updateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
do! addMessage ctx {
|
||||
UserMessage.success with
|
||||
message = $"""Successfully {if model.id = "new" then "add" else "sav"}ed custom feed"""
|
||||
}
|
||||
let nextUrl = $"admin/settings/rss/{CustomFeedId.toString feed.id}/edit"
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink nextUrl)) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/rss/{id}/delete
|
||||
let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
match! data.WebLog.findById ctx.WebLog.id with
|
||||
| Some webLog ->
|
||||
let customId = CustomFeedId feedId
|
||||
if webLog.rss.customFeeds |> List.exists (fun f -> f.id = customId) then
|
||||
let webLog = {
|
||||
webLog with
|
||||
rss = {
|
||||
webLog.rss with
|
||||
customFeeds = webLog.rss.customFeeds |> List.filter (fun f -> f.id <> customId)
|
||||
}
|
||||
}
|
||||
do! data.WebLog.updateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" }
|
||||
else
|
||||
do! addMessage ctx { UserMessage.warning with message = "Custom feed not found; no action taken" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
256
src/MyWebLog/Handlers/Helpers.fs
Normal file
256
src/MyWebLog/Handlers/Helpers.fs
Normal file
@@ -0,0 +1,256 @@
|
||||
[<AutoOpen>]
|
||||
module private MyWebLog.Handlers.Helpers
|
||||
|
||||
open System.Text.Json
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Session extensions to get and set objects
|
||||
type ISession with
|
||||
|
||||
/// Set an item in the session
|
||||
member this.Set<'T> (key, item : 'T) =
|
||||
this.SetString (key, JsonSerializer.Serialize item)
|
||||
|
||||
/// Get an item from the session
|
||||
member this.Get<'T> key =
|
||||
match this.GetString key with
|
||||
| null -> None
|
||||
| item -> Some (JsonSerializer.Deserialize<'T> item)
|
||||
|
||||
|
||||
/// The HTTP item key for loading the session
|
||||
let private sessionLoadedKey = "session-loaded"
|
||||
|
||||
/// Load the session if it has not been loaded already; ensures async access but not excessive loading
|
||||
let private loadSession (ctx : HttpContext) = task {
|
||||
if not (ctx.Items.ContainsKey sessionLoadedKey) then
|
||||
do! ctx.Session.LoadAsync ()
|
||||
ctx.Items.Add (sessionLoadedKey, "yes")
|
||||
}
|
||||
|
||||
/// Ensure that the session is committed
|
||||
let private commitSession (ctx : HttpContext) = task {
|
||||
if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync ()
|
||||
}
|
||||
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Add a message to the user's session
|
||||
let addMessage (ctx : HttpContext) message = task {
|
||||
do! loadSession ctx
|
||||
let msg = match ctx.Session.Get<UserMessage list> "messages" with Some it -> it | None -> []
|
||||
ctx.Session.Set ("messages", message :: msg)
|
||||
}
|
||||
|
||||
/// Get any messages from the user's session, removing them in the process
|
||||
let messages (ctx : HttpContext) = task {
|
||||
do! loadSession ctx
|
||||
match ctx.Session.Get<UserMessage list> "messages" with
|
||||
| Some msg ->
|
||||
ctx.Session.Remove "messages"
|
||||
return msg |> (List.rev >> Array.ofList)
|
||||
| None -> return [||]
|
||||
}
|
||||
|
||||
/// Hold variable for the configured generator string
|
||||
let mutable private generatorString : string option = None
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
|
||||
/// Get the generator string
|
||||
let generator (ctx : HttpContext) =
|
||||
match generatorString with
|
||||
| Some gen -> gen
|
||||
| None ->
|
||||
let cfg = ctx.RequestServices.GetRequiredService<IConfiguration> ()
|
||||
generatorString <-
|
||||
match Option.ofObj cfg["Generator"] with
|
||||
| Some gen -> Some gen
|
||||
| None -> Some "generator not configured"
|
||||
generatorString.Value
|
||||
|
||||
open MyWebLog
|
||||
open DotLiquid
|
||||
|
||||
/// Either get the web log from the hash, or get it from the cache and add it to the hash
|
||||
let private deriveWebLogFromHash (hash : Hash) (ctx : HttpContext) =
|
||||
if hash.ContainsKey "web_log" then () else hash.Add ("web_log", ctx.WebLog)
|
||||
hash["web_log"] :?> WebLog
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Htmx
|
||||
open Giraffe.ViewEngine
|
||||
|
||||
/// htmx script tag
|
||||
let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified
|
||||
|
||||
/// Populate the DotLiquid hash with standard information
|
||||
let private populateHash hash ctx = task {
|
||||
// Don't need the web log, but this adds it to the hash if the function is called directly
|
||||
let _ = deriveWebLogFromHash hash ctx
|
||||
let! messages = messages ctx
|
||||
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
|
||||
hash.Add ("page_list", PageListCache.get ctx)
|
||||
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
|
||||
hash.Add ("messages", messages)
|
||||
hash.Add ("generator", generator ctx)
|
||||
hash.Add ("htmx_script", htmxScript)
|
||||
|
||||
do! commitSession ctx
|
||||
}
|
||||
|
||||
/// Render a view for the specified theme, using the specified template, layout, and hash
|
||||
let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
|
||||
do! populateHash hash ctx
|
||||
|
||||
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
|
||||
// the net effect is a "layout" capability similar to Razor or Pug
|
||||
|
||||
// Render view content...
|
||||
let! contentTemplate = TemplateCache.get theme template ctx.Data
|
||||
hash.Add ("content", contentTemplate.Render hash)
|
||||
|
||||
// ...then render that content with its layout
|
||||
let isHtmx = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||
let! layoutTemplate = TemplateCache.get theme (if isHtmx then "layout-partial" else "layout") ctx.Data
|
||||
|
||||
return! htmlString (layoutTemplate.Render hash) next ctx
|
||||
}
|
||||
|
||||
/// Render a bare view for the specified theme, using the specified template and hash
|
||||
let bareForTheme theme template next ctx = fun (hash : Hash) -> task {
|
||||
do! populateHash hash ctx
|
||||
|
||||
// Bare templates are rendered with layout-bare
|
||||
let! contentTemplate = TemplateCache.get theme template ctx.Data
|
||||
hash.Add ("content", contentTemplate.Render hash)
|
||||
|
||||
let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data
|
||||
|
||||
// add messages as HTTP headers
|
||||
let messages = hash["messages"] :?> UserMessage[]
|
||||
let actions = seq {
|
||||
yield!
|
||||
messages
|
||||
|> Array.map (fun m ->
|
||||
match m.detail with
|
||||
| Some detail -> $"{m.level}|||{m.message}|||{detail}"
|
||||
| None -> $"{m.level}|||{m.message}"
|
||||
|> setHttpHeader "X-Message")
|
||||
withHxNoPush
|
||||
htmlString (layoutTemplate.Render hash)
|
||||
}
|
||||
|
||||
return! (actions |> Seq.reduce (>=>)) next ctx
|
||||
}
|
||||
|
||||
/// Return a view for the web log's default theme
|
||||
let themedView template next ctx = fun (hash : Hash) -> task {
|
||||
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash
|
||||
}
|
||||
|
||||
/// Redirect after doing some action; commits session and issues a temporary redirect
|
||||
let redirectToGet url : HttpHandler = fun next ctx -> task {
|
||||
do! commitSession ctx
|
||||
return! redirectTo false url next ctx
|
||||
}
|
||||
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the user ID for the current request
|
||||
let userId (ctx : HttpContext) =
|
||||
WebLogUserId (ctx.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value
|
||||
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
|
||||
/// Get the Anti-CSRF service
|
||||
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
/// Get the cross-site request forgery token set
|
||||
let csrfToken (ctx : HttpContext) =
|
||||
(antiForgery ctx).GetAndStoreTokens ctx
|
||||
|
||||
/// Validate the cross-site request forgery token in the current request
|
||||
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||
match! (antiForgery ctx).IsRequestValidAsync ctx with
|
||||
| true -> return! next ctx
|
||||
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx
|
||||
}
|
||||
|
||||
/// Require a user to be logged on
|
||||
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
||||
|
||||
open System.Collections.Generic
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Get the templates available for the current web log's theme (in a key/value pair list)
|
||||
let templatesForTheme (ctx : HttpContext) (typ : string) = backgroundTask {
|
||||
match! ctx.Data.Theme.findByIdWithoutText (ThemeId ctx.WebLog.themePath) with
|
||||
| Some theme ->
|
||||
return seq {
|
||||
KeyValuePair.Create ("", $"- Default (single-{typ}) -")
|
||||
yield!
|
||||
theme.templates
|
||||
|> Seq.ofList
|
||||
|> Seq.filter (fun it -> it.name.EndsWith $"-{typ}" && it.name <> $"single-{typ}")
|
||||
|> Seq.map (fun it -> KeyValuePair.Create (it.name, it.name))
|
||||
}
|
||||
|> Array.ofSeq
|
||||
| None -> return [| KeyValuePair.Create ("", $"- Default (single-{typ}) -") |]
|
||||
}
|
||||
|
||||
/// Get all authors for a list of posts as metadata items
|
||||
let getAuthors (webLog : WebLog) (posts : Post list) (data : IData) =
|
||||
posts
|
||||
|> List.map (fun p -> p.authorId)
|
||||
|> List.distinct
|
||||
|> data.WebLogUser.findNames webLog.id
|
||||
|
||||
/// Get all tag mappings for a list of posts as metadata items
|
||||
let getTagMappings (webLog : WebLog) (posts : Post list) (data : IData) =
|
||||
posts
|
||||
|> List.map (fun p -> p.tags)
|
||||
|> List.concat
|
||||
|> List.distinct
|
||||
|> fun tags -> data.TagMap.findMappingForTags tags webLog.id
|
||||
|
||||
/// Get all category IDs for the given slug (includes owned subcategories)
|
||||
let getCategoryIds slug ctx =
|
||||
let allCats = CategoryCache.get ctx
|
||||
let cat = allCats |> Array.find (fun cat -> cat.slug = slug)
|
||||
// Category pages include posts in subcategories
|
||||
allCats
|
||||
|> Seq.ofArray
|
||||
|> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames)
|
||||
|> Seq.map (fun c -> CategoryId c.id)
|
||||
|> List.ofSeq
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Log level for debugging
|
||||
let mutable private debugEnabled : bool option = None
|
||||
|
||||
/// Is debug enabled for handlers?
|
||||
let private isDebugEnabled (ctx : HttpContext) =
|
||||
match debugEnabled with
|
||||
| Some flag -> flag
|
||||
| None ->
|
||||
let fac = ctx.RequestServices.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger "MyWebLog.Handlers"
|
||||
debugEnabled <- Some (log.IsEnabled LogLevel.Debug)
|
||||
debugEnabled.Value
|
||||
|
||||
/// Log a debug message
|
||||
let debug (name : string) ctx msg =
|
||||
if isDebugEnabled ctx then
|
||||
let fac = ctx.RequestServices.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger $"MyWebLog.Handlers.{name}"
|
||||
log.LogDebug (msg ())
|
||||
|
||||
/// Log a warning message
|
||||
let warn (name : string) (ctx : HttpContext) msg =
|
||||
let fac = ctx.RequestServices.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger $"MyWebLog.Handlers.{name}"
|
||||
log.LogWarning msg
|
||||
|
||||
365
src/MyWebLog/Handlers/Post.fs
Normal file
365
src/MyWebLog/Handlers/Post.fs
Normal file
@@ -0,0 +1,365 @@
|
||||
/// Handlers to manipulate posts
|
||||
module MyWebLog.Handlers.Post
|
||||
|
||||
open System
|
||||
open MyWebLog
|
||||
|
||||
/// Parse a slug and page number from an "everything else" URL
|
||||
let private parseSlugAndPage webLog (slugAndPage : string seq) =
|
||||
let fullPath = slugAndPage |> Seq.head
|
||||
let slugPath = slugAndPage |> Seq.skip 1 |> Seq.head
|
||||
let slugs, isFeed =
|
||||
let feedName = $"/{webLog.rss.feedName}"
|
||||
let notBlank = Array.filter (fun it -> it <> "")
|
||||
if ( (webLog.rss.categoryEnabled && fullPath.StartsWith "/category/")
|
||||
|| (webLog.rss.tagEnabled && fullPath.StartsWith "/tag/" ))
|
||||
&& slugPath.EndsWith feedName then
|
||||
notBlank (slugPath.Replace(feedName, "").Split "/"), true
|
||||
else
|
||||
notBlank (slugPath.Split "/"), false
|
||||
let pageIdx = Array.IndexOf (slugs, "page")
|
||||
let pageNbr =
|
||||
match pageIdx with
|
||||
| -1 -> Some 1
|
||||
| idx when idx + 2 = slugs.Length -> Some (int slugs[pageIdx + 1])
|
||||
| _ -> None
|
||||
let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs
|
||||
pageNbr, String.Join ("/", slugParts), isFeed
|
||||
|
||||
/// The type of post list being prepared
|
||||
type ListType =
|
||||
| AdminList
|
||||
| CategoryList
|
||||
| PostList
|
||||
| SinglePost
|
||||
| TagList
|
||||
|
||||
open System.Threading.Tasks
|
||||
open DotLiquid
|
||||
open MyWebLog.Data
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Convert a list of posts into items ready to be displayed
|
||||
let preparePostList webLog posts listType (url : string) pageNbr perPage ctx (data : IData) = task {
|
||||
let! authors = getAuthors webLog posts data
|
||||
let! tagMappings = getTagMappings webLog posts data
|
||||
let relUrl it = Some <| WebLog.relativeUrl webLog (Permalink it)
|
||||
let postItems =
|
||||
posts
|
||||
|> Seq.ofList
|
||||
|> Seq.truncate perPage
|
||||
|> Seq.map (PostListItem.fromPost webLog)
|
||||
|> Array.ofSeq
|
||||
let! olderPost, newerPost =
|
||||
match listType with
|
||||
| SinglePost ->
|
||||
let post = List.head posts
|
||||
let dateTime = defaultArg post.publishedOn post.updatedOn
|
||||
data.Post.findSurroundingPosts webLog.id dateTime
|
||||
| _ -> Task.FromResult (None, None)
|
||||
let newerLink =
|
||||
match listType, pageNbr with
|
||||
| SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink)
|
||||
| _, 1 -> None
|
||||
| PostList, 2 when webLog.defaultPage = "posts" -> Some ""
|
||||
| PostList, _ -> relUrl $"page/{pageNbr - 1}"
|
||||
| CategoryList, 2 -> relUrl $"category/{url}/"
|
||||
| CategoryList, _ -> relUrl $"category/{url}/page/{pageNbr - 1}"
|
||||
| TagList, 2 -> relUrl $"tag/{url}/"
|
||||
| TagList, _ -> relUrl $"tag/{url}/page/{pageNbr - 1}"
|
||||
| AdminList, 2 -> relUrl "admin/posts"
|
||||
| AdminList, _ -> relUrl $"admin/posts/page/{pageNbr - 1}"
|
||||
let olderLink =
|
||||
match listType, List.length posts > perPage with
|
||||
| SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink)
|
||||
| _, false -> None
|
||||
| PostList, true -> relUrl $"page/{pageNbr + 1}"
|
||||
| CategoryList, true -> relUrl $"category/{url}/page/{pageNbr + 1}"
|
||||
| TagList, true -> relUrl $"tag/{url}/page/{pageNbr + 1}"
|
||||
| AdminList, true -> relUrl $"admin/posts/page/{pageNbr + 1}"
|
||||
let model =
|
||||
{ posts = postItems
|
||||
authors = authors
|
||||
subtitle = None
|
||||
newerLink = newerLink
|
||||
newerName = newerPost |> Option.map (fun p -> p.title)
|
||||
olderLink = olderLink
|
||||
olderName = olderPost |> Option.map (fun p -> p.title)
|
||||
}
|
||||
return Hash.FromAnonymousObject {|
|
||||
model = model
|
||||
categories = CategoryCache.get ctx
|
||||
tag_mappings = tagMappings
|
||||
is_post = match listType with SinglePost -> true | _ -> false
|
||||
|}
|
||||
}
|
||||
|
||||
open Giraffe
|
||||
|
||||
// GET /page/{pageNbr}
|
||||
let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! posts = data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage
|
||||
let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx data
|
||||
let title =
|
||||
match pageNbr, webLog.defaultPage with
|
||||
| 1, "posts" -> None
|
||||
| _, "posts" -> Some $"Page {pageNbr}"
|
||||
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
|
||||
if pageNbr = 1 && webLog.defaultPage = "posts" then hash.Add ("is_home", true)
|
||||
return! themedView "index" next ctx hash
|
||||
}
|
||||
|
||||
// GET /page/{pageNbr}/
|
||||
let redirectToPageOfPosts (pageNbr : int) : HttpHandler = fun next ctx ->
|
||||
redirectTo true (WebLog.relativeUrl ctx.WebLog (Permalink $"page/{pageNbr}")) next ctx
|
||||
|
||||
// GET /category/{slug}/
|
||||
// GET /category/{slug}/page/{pageNbr}
|
||||
let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
match parseSlugAndPage webLog slugAndPage with
|
||||
| Some pageNbr, slug, isFeed ->
|
||||
match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = slug) with
|
||||
| Some cat when isFeed ->
|
||||
return! Feed.generate (Feed.CategoryFeed ((CategoryId cat.id), $"category/{slug}/{webLog.rss.feedName}"))
|
||||
(defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx
|
||||
| Some cat ->
|
||||
// Category pages include posts in subcategories
|
||||
match! data.Post.findPageOfCategorizedPosts webLog.id (getCategoryIds slug ctx) pageNbr webLog.postsPerPage
|
||||
with
|
||||
| posts when List.length posts > 0 ->
|
||||
let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx data
|
||||
let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>"""
|
||||
hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}")
|
||||
hash.Add ("subtitle", defaultArg cat.description "")
|
||||
hash.Add ("is_category", true)
|
||||
hash.Add ("is_category_home", (pageNbr = 1))
|
||||
hash.Add ("slug", slug)
|
||||
return! themedView "index" next ctx hash
|
||||
| _ -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None, _, _ -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
open System.Web
|
||||
|
||||
// GET /tag/{tag}/
|
||||
// GET /tag/{tag}/page/{pageNbr}
|
||||
let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
match parseSlugAndPage webLog slugAndPage with
|
||||
| Some pageNbr, rawTag, isFeed ->
|
||||
let urlTag = HttpUtility.UrlDecode rawTag
|
||||
let! tag = backgroundTask {
|
||||
match! data.TagMap.findByUrlValue urlTag webLog.id with
|
||||
| Some m -> return m.tag
|
||||
| None -> return urlTag
|
||||
}
|
||||
if isFeed then
|
||||
return! Feed.generate (Feed.TagFeed (tag, $"tag/{rawTag}/{webLog.rss.feedName}"))
|
||||
(defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx
|
||||
else
|
||||
match! data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage with
|
||||
| posts when List.length posts > 0 ->
|
||||
let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx data
|
||||
let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>"""
|
||||
hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}")
|
||||
hash.Add ("is_tag", true)
|
||||
hash.Add ("is_tag_home", (pageNbr = 1))
|
||||
hash.Add ("slug", rawTag)
|
||||
return! themedView "index" next ctx hash
|
||||
// Other systems use hyphens for spaces; redirect if this is an old tag link
|
||||
| _ ->
|
||||
let spacedTag = tag.Replace ("-", " ")
|
||||
match! data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 with
|
||||
| posts when List.length posts > 0 ->
|
||||
let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}"
|
||||
return!
|
||||
redirectTo true
|
||||
(WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}"""))
|
||||
next ctx
|
||||
| _ -> return! Error.notFound next ctx
|
||||
| None, _, _ -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
match webLog.defaultPage with
|
||||
| "posts" -> return! pageOfPosts 1 next ctx
|
||||
| pageId ->
|
||||
match! ctx.Data.Page.findById (PageId pageId) webLog.id with
|
||||
| Some page ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
page = DisplayPage.fromPage webLog page
|
||||
categories = CategoryCache.get ctx
|
||||
page_title = page.title
|
||||
is_home = true
|
||||
|}
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /admin/posts
|
||||
// GET /admin/posts/page/{pageNbr}
|
||||
let all pageNbr : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! posts = data.Post.findPageOfPosts webLog.id pageNbr 25
|
||||
let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx data
|
||||
hash.Add ("page_title", "Posts")
|
||||
hash.Add ("csrf", csrfToken ctx)
|
||||
return! viewForTheme "admin" "post-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/post/{id}/edit
|
||||
let edit postId : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let! result = task {
|
||||
match postId with
|
||||
| "new" -> return Some ("Write a New Post", { Post.empty with id = PostId "new" })
|
||||
| _ ->
|
||||
match! data.Post.findFullById (PostId postId) webLog.id with
|
||||
| Some post -> return Some ("Edit Post", post)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, post) ->
|
||||
let! cats = data.Category.findAllForView webLog.id
|
||||
let! templates = templatesForTheme ctx "post"
|
||||
let model = EditPostModel.fromPost webLog post
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = model
|
||||
metadata = Array.zip model.metaNames model.metaValues
|
||||
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])
|
||||
page_title = title
|
||||
templates = templates
|
||||
categories = cats
|
||||
|}
|
||||
|> viewForTheme "admin" "post-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /admin/post/{id}/permalinks
|
||||
let editPermalinks postId : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.Post.findFullById (PostId postId) ctx.WebLog.id with
|
||||
| Some post ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = ManagePermalinksModel.fromPost post
|
||||
page_title = $"Manage Prior Permalinks"
|
||||
|}
|
||||
|> viewForTheme "admin" "permalinks" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/post/permalinks
|
||||
let savePermalinks : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
let! model = ctx.BindFormAsync<ManagePermalinksModel> ()
|
||||
let links = model.prior |> Array.map Permalink |> List.ofArray
|
||||
match! ctx.Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links with
|
||||
| true ->
|
||||
do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{model.id}/permalinks")) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/post/{id}/delete
|
||||
let delete postId : HttpHandler = fun next ctx -> task {
|
||||
let webLog = ctx.WebLog
|
||||
match! ctx.Data.Post.delete (PostId postId) webLog.id with
|
||||
| true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" }
|
||||
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/posts")) next ctx
|
||||
}
|
||||
|
||||
#nowarn "3511"
|
||||
|
||||
// POST /admin/post/save
|
||||
let save : HttpHandler = fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditPostModel> ()
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let now = DateTime.UtcNow
|
||||
let! pst = task {
|
||||
match model.postId with
|
||||
| "new" ->
|
||||
return Some
|
||||
{ Post.empty with
|
||||
id = PostId.create ()
|
||||
webLogId = webLog.id
|
||||
authorId = userId ctx
|
||||
}
|
||||
| postId -> return! data.Post.findFullById (PostId postId) webLog.id
|
||||
}
|
||||
match pst with
|
||||
| Some post ->
|
||||
let revision = { asOf = now; text = MarkupText.parse $"{model.source}: {model.text}" }
|
||||
// Detect a permalink change, and add the prior one to the prior list
|
||||
let post =
|
||||
match Permalink.toString post.permalink with
|
||||
| "" -> post
|
||||
| link when link = model.permalink -> post
|
||||
| _ -> { post with priorPermalinks = post.permalink :: post.priorPermalinks }
|
||||
let post =
|
||||
{ post with
|
||||
title = model.title
|
||||
permalink = Permalink model.permalink
|
||||
publishedOn = if model.doPublish then Some now else post.publishedOn
|
||||
updatedOn = now
|
||||
text = MarkupText.toHtml revision.text
|
||||
tags = model.tags.Split ","
|
||||
|> Seq.ofArray
|
||||
|> Seq.map (fun it -> it.Trim().ToLower ())
|
||||
|> Seq.filter (fun it -> it <> "")
|
||||
|> Seq.sort
|
||||
|> List.ofSeq
|
||||
template = match model.template.Trim () with "" -> None | tmpl -> Some tmpl
|
||||
categoryIds = model.categoryIds |> Array.map CategoryId |> List.ofArray
|
||||
status = if model.doPublish then Published else post.status
|
||||
metadata = Seq.zip model.metaNames model.metaValues
|
||||
|> Seq.filter (fun it -> fst it > "")
|
||||
|> Seq.map (fun it -> { name = fst it; value = snd it })
|
||||
|> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}")
|
||||
|> List.ofSeq
|
||||
revisions = match post.revisions |> List.tryHead with
|
||||
| Some r when r.text = revision.text -> post.revisions
|
||||
| _ -> revision :: post.revisions
|
||||
}
|
||||
let post =
|
||||
match model.setPublished with
|
||||
| true ->
|
||||
let dt = WebLog.utcTime webLog model.pubOverride.Value
|
||||
match model.setUpdated with
|
||||
| true ->
|
||||
{ post with
|
||||
publishedOn = Some dt
|
||||
updatedOn = dt
|
||||
revisions = [ { (List.head post.revisions) with asOf = dt } ]
|
||||
}
|
||||
| false -> { post with publishedOn = Some dt }
|
||||
| false -> post
|
||||
do! (if model.postId = "new" then data.Post.add else data.Post.update) post
|
||||
// If the post was published or its categories changed, refresh the category cache
|
||||
if model.doPublish
|
||||
|| not (pst.Value.categoryIds
|
||||
|> List.append post.categoryIds
|
||||
|> List.distinct
|
||||
|> List.length = List.length pst.Value.categoryIds) then
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with message = "Post saved successfully" }
|
||||
return!
|
||||
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{PostId.toString post.id}/edit")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
240
src/MyWebLog/Handlers/Routes.fs
Normal file
240
src/MyWebLog/Handlers/Routes.fs
Normal file
@@ -0,0 +1,240 @@
|
||||
/// Routes for this application
|
||||
module MyWebLog.Handlers.Routes
|
||||
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
open MyWebLog
|
||||
|
||||
/// Module to resolve routes that do not match any other known route (web blog content)
|
||||
module CatchAll =
|
||||
|
||||
open DotLiquid
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Sequence where the first returned value is the proper handler for the link
|
||||
let private deriveAction (ctx : HttpContext) : HttpHandler seq =
|
||||
let webLog = ctx.WebLog
|
||||
let data = ctx.Data
|
||||
let debug = debug "Routes.CatchAll" ctx
|
||||
let textLink =
|
||||
let _, extra = WebLog.hostAndPath webLog
|
||||
let url = string ctx.Request.Path
|
||||
(if extra = "" then url else url.Substring extra.Length).ToLowerInvariant ()
|
||||
let await it = (Async.AwaitTask >> Async.RunSynchronously) it
|
||||
seq {
|
||||
debug (fun () -> $"Considering URL {textLink}")
|
||||
// Home page directory without the directory slash
|
||||
if textLink = "" then yield redirectTo true (WebLog.relativeUrl webLog Permalink.empty)
|
||||
let permalink = Permalink (textLink.Substring 1)
|
||||
// Current post
|
||||
match data.Post.findByPermalink permalink webLog.id |> await with
|
||||
| Some post ->
|
||||
debug (fun () -> $"Found post by permalink")
|
||||
let model = Post.preparePostList webLog [ post ] Post.ListType.SinglePost "" 1 1 ctx data |> await
|
||||
model.Add ("page_title", post.title)
|
||||
yield fun next ctx -> themedView (defaultArg post.template "single-post") next ctx model
|
||||
| None -> ()
|
||||
// Current page
|
||||
match data.Page.findByPermalink permalink webLog.id |> await with
|
||||
| Some page ->
|
||||
debug (fun () -> $"Found page by permalink")
|
||||
yield fun next ctx ->
|
||||
Hash.FromAnonymousObject {|
|
||||
page = DisplayPage.fromPage webLog page
|
||||
categories = CategoryCache.get ctx
|
||||
page_title = page.title
|
||||
is_page = true
|
||||
|}
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None -> ()
|
||||
// RSS feed
|
||||
match Feed.deriveFeedType ctx textLink with
|
||||
| Some (feedType, postCount) ->
|
||||
debug (fun () -> $"Found RSS feed")
|
||||
yield Feed.generate feedType postCount
|
||||
| None -> ()
|
||||
// Post differing only by trailing slash
|
||||
let altLink =
|
||||
Permalink (if textLink.EndsWith "/" then textLink[1..textLink.Length - 2] else $"{textLink[1..]}/")
|
||||
match data.Post.findByPermalink altLink webLog.id |> await with
|
||||
| Some post ->
|
||||
debug (fun () -> $"Found post by trailing-slash-agnostic permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog post.permalink)
|
||||
| None -> ()
|
||||
// Page differing only by trailing slash
|
||||
match data.Page.findByPermalink altLink webLog.id |> await with
|
||||
| Some page ->
|
||||
debug (fun () -> $"Found page by trailing-slash-agnostic permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog page.permalink)
|
||||
| None -> ()
|
||||
// Prior post
|
||||
match data.Post.findCurrentPermalink [ permalink; altLink ] webLog.id |> await with
|
||||
| Some link ->
|
||||
debug (fun () -> $"Found post by prior permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog link)
|
||||
| None -> ()
|
||||
// Prior page
|
||||
match data.Page.findCurrentPermalink [ permalink; altLink ] webLog.id |> await with
|
||||
| Some link ->
|
||||
debug (fun () -> $"Found page by prior permalink")
|
||||
yield redirectTo true (WebLog.relativeUrl webLog link)
|
||||
| None -> ()
|
||||
debug (fun () -> $"No content found")
|
||||
}
|
||||
|
||||
// GET {all-of-the-above}
|
||||
let route : HttpHandler = fun next ctx -> task {
|
||||
match deriveAction ctx |> Seq.tryHead with
|
||||
| Some handler -> return! handler next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Serve theme assets
|
||||
module Asset =
|
||||
|
||||
open System
|
||||
open Microsoft.AspNetCore.Http.Headers
|
||||
open Microsoft.AspNetCore.StaticFiles
|
||||
open Microsoft.Net.Http.Headers
|
||||
|
||||
/// Determine if the asset has been modified since the date/time specified by the If-Modified-Since header
|
||||
let private checkModified asset (ctx : HttpContext) : HttpHandler option =
|
||||
match ctx.Request.Headers.IfModifiedSince with
|
||||
| it when it.Count < 1 -> None
|
||||
| it ->
|
||||
if asset.updatedOn > DateTime.Parse it[0] then
|
||||
None
|
||||
else
|
||||
Some (setStatusCode 304 >=> setBodyFromString "Not Modified")
|
||||
|
||||
/// An instance of ASP.NET Core's file extension to MIME type converter
|
||||
let private mimeMap = FileExtensionContentTypeProvider ()
|
||||
|
||||
// GET /theme/{theme}/{**path}
|
||||
let serveAsset (urlParts : string seq) : HttpHandler = fun next ctx -> task {
|
||||
let path = urlParts |> Seq.skip 1 |> Seq.head
|
||||
match! ctx.Data.ThemeAsset.findById (ThemeAssetId.ofString path) with
|
||||
| Some asset ->
|
||||
match checkModified asset ctx with
|
||||
| Some threeOhFour -> return! threeOhFour next ctx
|
||||
| None ->
|
||||
let mimeType =
|
||||
match mimeMap.TryGetContentType path with
|
||||
| true, typ -> typ
|
||||
| false, _ -> "application/octet-stream"
|
||||
let headers = ResponseHeaders ctx.Response.Headers
|
||||
headers.LastModified <- Some (DateTimeOffset asset.updatedOn) |> Option.toNullable
|
||||
headers.ContentType <- MediaTypeHeaderValue mimeType
|
||||
headers.CacheControl <-
|
||||
let hdr = CacheControlHeaderValue()
|
||||
hdr.MaxAge <- Some (TimeSpan.FromDays 30) |> Option.toNullable
|
||||
hdr
|
||||
return! setBody asset.data next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// The primary myWebLog router
|
||||
let router : HttpHandler = choose [
|
||||
GET >=> choose [
|
||||
route "/" >=> Post.home
|
||||
]
|
||||
subRoute "/admin" (requireUser >=> choose [
|
||||
GET >=> choose [
|
||||
subRoute "/categor" (choose [
|
||||
route "ies" >=> Admin.listCategories
|
||||
route "ies/bare" >=> Admin.listCategoriesBare
|
||||
routef "y/%s/edit" Admin.editCategory
|
||||
])
|
||||
route "/dashboard" >=> Admin.dashboard
|
||||
subRoute "/page" (choose [
|
||||
route "s" >=> Admin.listPages 1
|
||||
routef "s/page/%i" Admin.listPages
|
||||
routef "/%s/edit" Admin.editPage
|
||||
routef "/%s/permalinks" Admin.editPagePermalinks
|
||||
])
|
||||
subRoute "/post" (choose [
|
||||
route "s" >=> Post.all 1
|
||||
routef "s/page/%i" Post.all
|
||||
routef "/%s/edit" Post.edit
|
||||
routef "/%s/permalinks" Post.editPermalinks
|
||||
])
|
||||
subRoute "/settings" (choose [
|
||||
route "" >=> Admin.settings
|
||||
subRoute "/rss" (choose [
|
||||
route "" >=> Feed.editSettings
|
||||
routef "/%s/edit" Feed.editCustomFeed
|
||||
])
|
||||
subRoute "/tag-mapping" (choose [
|
||||
route "s" >=> Admin.tagMappings
|
||||
route "s/bare" >=> Admin.tagMappingsBare
|
||||
routef "/%s/edit" Admin.editMapping
|
||||
])
|
||||
])
|
||||
route "/theme/update" >=> Admin.themeUpdatePage
|
||||
route "/user/edit" >=> User.edit
|
||||
]
|
||||
POST >=> validateCsrf >=> choose [
|
||||
subRoute "/category" (choose [
|
||||
route "/save" >=> Admin.saveCategory
|
||||
routef "/%s/delete" Admin.deleteCategory
|
||||
])
|
||||
subRoute "/page" (choose [
|
||||
route "/save" >=> Admin.savePage
|
||||
route "/permalinks" >=> Admin.savePagePermalinks
|
||||
routef "/%s/delete" Admin.deletePage
|
||||
])
|
||||
subRoute "/post" (choose [
|
||||
route "/save" >=> Post.save
|
||||
route "/permalinks" >=> Post.savePermalinks
|
||||
routef "/%s/delete" Post.delete
|
||||
])
|
||||
subRoute "/settings" (choose [
|
||||
route "" >=> Admin.saveSettings
|
||||
subRoute "/rss" (choose [
|
||||
route "" >=> Feed.saveSettings
|
||||
route "/save" >=> Feed.saveCustomFeed
|
||||
routef "/%s/delete" Feed.deleteCustomFeed
|
||||
])
|
||||
subRoute "/tag-mapping" (choose [
|
||||
route "/save" >=> Admin.saveMapping
|
||||
routef "/%s/delete" Admin.deleteMapping
|
||||
])
|
||||
])
|
||||
route "/theme/update" >=> Admin.updateTheme
|
||||
route "/user/save" >=> User.save
|
||||
]
|
||||
])
|
||||
GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts
|
||||
GET_HEAD >=> routef "/page/%i" Post.pageOfPosts
|
||||
GET_HEAD >=> routef "/page/%i/" Post.redirectToPageOfPosts
|
||||
GET_HEAD >=> routexp "/tag/(.*)" Post.pageOfTaggedPosts
|
||||
GET_HEAD >=> routexp "/themes/(.*)" Asset.serveAsset
|
||||
subRoute "/user" (choose [
|
||||
GET_HEAD >=> choose [
|
||||
route "/log-on" >=> User.logOn None
|
||||
route "/log-off" >=> User.logOff
|
||||
]
|
||||
POST >=> validateCsrf >=> choose [
|
||||
route "/log-on" >=> User.doLogOn
|
||||
]
|
||||
])
|
||||
GET_HEAD >=> CatchAll.route
|
||||
Error.notFound
|
||||
]
|
||||
|
||||
/// Wrap a router in a sub-route
|
||||
let routerWithPath extraPath : HttpHandler =
|
||||
subRoute extraPath router
|
||||
|
||||
/// Handler to apply Giraffe routing with a possible sub-route
|
||||
let handleRoute : HttpHandler = fun next ctx -> task {
|
||||
let _, extraPath = WebLog.hostAndPath ctx.WebLog
|
||||
return! (if extraPath = "" then router else routerWithPath extraPath) next ctx
|
||||
}
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// Endpoint-routed handler to deal with sub-routes
|
||||
let endpoint = [ route "{**url}" handleRoute ]
|
||||
118
src/MyWebLog/Handlers/User.fs
Normal file
118
src/MyWebLog/Handlers/User.fs
Normal file
@@ -0,0 +1,118 @@
|
||||
/// Handlers to manipulate users
|
||||
module MyWebLog.Handlers.User
|
||||
|
||||
open System
|
||||
open System.Security.Cryptography
|
||||
open System.Text
|
||||
|
||||
/// Hash a password for a given user
|
||||
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
|
||||
let allSalt = Array.concat [ salt.ToByteArray (); Encoding.UTF8.GetBytes email ]
|
||||
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
|
||||
Convert.ToBase64String (alg.GetBytes 64)
|
||||
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// GET /user/log-on
|
||||
let logOn returnUrl : HttpHandler = fun next ctx -> task {
|
||||
let returnTo =
|
||||
match returnUrl with
|
||||
| Some _ -> returnUrl
|
||||
| None ->
|
||||
match ctx.Request.Query.ContainsKey "returnUrl" with
|
||||
| true -> Some ctx.Request.Query["returnUrl"].[0]
|
||||
| false -> None
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
model = { LogOnModel.empty with returnTo = returnTo }
|
||||
page_title = "Log On"
|
||||
csrf = csrfToken ctx
|
||||
|}
|
||||
|> viewForTheme "admin" "log-on" next ctx
|
||||
}
|
||||
|
||||
open System.Security.Claims
|
||||
open Microsoft.AspNetCore.Authentication
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open MyWebLog
|
||||
|
||||
// POST /user/log-on
|
||||
let doLogOn : HttpHandler = fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||
let webLog = ctx.WebLog
|
||||
match! ctx.Data.WebLogUser.findByEmail model.emailAddress webLog.id with
|
||||
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
||||
let claims = seq {
|
||||
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id)
|
||||
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
|
||||
Claim (ClaimTypes.GivenName, user.preferredName)
|
||||
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
|
||||
}
|
||||
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity,
|
||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||
do! addMessage ctx
|
||||
{ UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" }
|
||||
return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin/dashboard")))
|
||||
next ctx
|
||||
| _ ->
|
||||
do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" }
|
||||
return! logOn model.returnTo next ctx
|
||||
}
|
||||
|
||||
// GET /user/log-off
|
||||
let logOff : HttpHandler = fun next ctx -> task {
|
||||
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||
do! addMessage ctx { UserMessage.info with message = "Log off successful" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog Permalink.empty) next ctx
|
||||
}
|
||||
|
||||
/// Display the user edit page, with information possibly filled in
|
||||
let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task {
|
||||
hash.Add ("page_title", "Edit Your Information")
|
||||
hash.Add ("csrf", csrfToken ctx)
|
||||
return! viewForTheme "admin" "user-edit" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/user/edit
|
||||
let edit : HttpHandler = fun next ctx -> task {
|
||||
match! ctx.Data.WebLogUser.findById (userId ctx) ctx.WebLog.id with
|
||||
| Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/user/save
|
||||
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditUserModel> ()
|
||||
if model.newPassword = model.newPasswordConfirm then
|
||||
let data = ctx.Data
|
||||
match! data.WebLogUser.findById (userId ctx) ctx.WebLog.id with
|
||||
| Some user ->
|
||||
let pw, salt =
|
||||
if model.newPassword = "" then
|
||||
user.passwordHash, user.salt
|
||||
else
|
||||
let newSalt = Guid.NewGuid ()
|
||||
hashedPassword model.newPassword user.userName newSalt, newSalt
|
||||
let user =
|
||||
{ user with
|
||||
firstName = model.firstName
|
||||
lastName = model.lastName
|
||||
preferredName = model.preferredName
|
||||
passwordHash = pw
|
||||
salt = salt
|
||||
}
|
||||
do! data.WebLogUser.update user
|
||||
let pwMsg = if model.newPassword = "" then "" else " and updated your password"
|
||||
do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" }
|
||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/user/edit")) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
else
|
||||
do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" }
|
||||
return! showEdit (Hash.FromAnonymousObject {|
|
||||
model = { model with newPassword = ""; newPasswordConfirm = "" }
|
||||
|}) next ctx
|
||||
}
|
||||
395
src/MyWebLog/Maintenance.fs
Normal file
395
src/MyWebLog/Maintenance.fs
Normal file
@@ -0,0 +1,395 @@
|
||||
module MyWebLog.Maintenance
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Create the web log information
|
||||
let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
|
||||
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
|
||||
let timeZone =
|
||||
let local = TimeZoneInfo.Local.Id
|
||||
match TimeZoneInfo.Local.HasIanaId with
|
||||
| true -> local
|
||||
| false ->
|
||||
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
|
||||
| true, ianaId -> ianaId
|
||||
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
|
||||
|
||||
// Create the web log
|
||||
let webLogId = WebLogId.create ()
|
||||
let userId = WebLogUserId.create ()
|
||||
let homePageId = PageId.create ()
|
||||
|
||||
do! data.WebLog.add
|
||||
{ WebLog.empty with
|
||||
id = webLogId
|
||||
name = args[2]
|
||||
urlBase = args[1]
|
||||
defaultPage = PageId.toString homePageId
|
||||
timeZone = timeZone
|
||||
}
|
||||
|
||||
// Create the admin user
|
||||
let salt = Guid.NewGuid ()
|
||||
|
||||
do! data.WebLogUser.add
|
||||
{ WebLogUser.empty with
|
||||
id = userId
|
||||
webLogId = webLogId
|
||||
userName = args[3]
|
||||
firstName = "Admin"
|
||||
lastName = "User"
|
||||
preferredName = "Admin"
|
||||
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
|
||||
salt = salt
|
||||
authorizationLevel = Administrator
|
||||
}
|
||||
|
||||
// Create the default home page
|
||||
do! data.Page.add
|
||||
{ Page.empty with
|
||||
id = homePageId
|
||||
webLogId = webLogId
|
||||
authorId = userId
|
||||
title = "Welcome to myWebLog!"
|
||||
permalink = Permalink "welcome-to-myweblog.html"
|
||||
publishedOn = DateTime.UtcNow
|
||||
updatedOn = DateTime.UtcNow
|
||||
text = "<p>This is your default home page.</p>"
|
||||
revisions = [
|
||||
{ asOf = DateTime.UtcNow
|
||||
text = Html "<p>This is your default home page.</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
printfn $"Successfully initialized database for {args[2]} with URL base {args[1]}"
|
||||
}
|
||||
|
||||
/// Create a new web log
|
||||
let createWebLog args sp = task {
|
||||
match args |> Array.length with
|
||||
| 5 -> do! doCreateWebLog args sp
|
||||
| _ -> printfn "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
|
||||
}
|
||||
|
||||
/// Import prior permalinks from a text files with lines in the format "[old] [new]"
|
||||
let importPriorPermalinks urlBase file (sp : IServiceProvider) = task {
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
|
||||
match! data.WebLog.findByHost urlBase with
|
||||
| Some webLog ->
|
||||
|
||||
let mapping =
|
||||
File.ReadAllLines file
|
||||
|> Seq.ofArray
|
||||
|> Seq.map (fun it ->
|
||||
let parts = it.Split " "
|
||||
Permalink parts[0], Permalink parts[1])
|
||||
|
||||
for old, current in mapping do
|
||||
match! data.Post.findByPermalink current webLog.id with
|
||||
| Some post ->
|
||||
let! withLinks = data.Post.findFullById post.id post.webLogId
|
||||
let! _ = data.Post.updatePriorPermalinks post.id post.webLogId
|
||||
(old :: withLinks.Value.priorPermalinks)
|
||||
printfn $"{Permalink.toString old} -> {Permalink.toString current}"
|
||||
| None -> printfn $"Cannot find current post for {Permalink.toString current}"
|
||||
printfn "Done!"
|
||||
| None -> eprintfn $"No web log found at {urlBase}"
|
||||
}
|
||||
|
||||
/// Import permalinks if all is well
|
||||
let importLinks args sp = task {
|
||||
match args |> Array.length with
|
||||
| 3 -> do! importPriorPermalinks args[1] args[2] sp
|
||||
| _ -> printfn "Usage: MyWebLog import-links [url] [file-name]"
|
||||
}
|
||||
|
||||
/// Load a theme from the given ZIP file
|
||||
let loadTheme (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length > 1 then
|
||||
let fileName =
|
||||
match args[1].LastIndexOf Path.DirectorySeparatorChar with
|
||||
| -1 -> args[1]
|
||||
| it -> args[1][(it + 1)..]
|
||||
match Handlers.Admin.getThemeName fileName with
|
||||
| Ok themeName ->
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
let clean = if args.Length > 2 then bool.Parse args[2] else true
|
||||
use stream = File.Open (args[1], FileMode.Open)
|
||||
use copy = new MemoryStream ()
|
||||
do! stream.CopyToAsync copy
|
||||
do! Handlers.Admin.loadThemeFromZip themeName copy clean data
|
||||
printfn $"Theme {themeName} loaded successfully"
|
||||
| Error message -> eprintfn $"{message}"
|
||||
else
|
||||
printfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]"
|
||||
printfn " * optional, defaults to true"
|
||||
}
|
||||
|
||||
/// Back up a web log's data
|
||||
module Backup =
|
||||
|
||||
open System.Threading.Tasks
|
||||
open MyWebLog.Converters
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// A theme asset, with the data base-64 encoded
|
||||
type EncodedAsset =
|
||||
{ /// The ID of the theme asset
|
||||
id : ThemeAssetId
|
||||
|
||||
/// The updated date for this asset
|
||||
updatedOn : DateTime
|
||||
|
||||
/// The data for this asset, base-64 encoded
|
||||
data : string
|
||||
}
|
||||
|
||||
/// Create an encoded theme asset from the original theme asset
|
||||
static member fromAsset (asset : ThemeAsset) =
|
||||
{ id = asset.id
|
||||
updatedOn = asset.updatedOn
|
||||
data = Convert.ToBase64String asset.data
|
||||
}
|
||||
|
||||
/// Create a theme asset from an encoded theme asset
|
||||
static member fromAsset (asset : EncodedAsset) : ThemeAsset =
|
||||
{ id = asset.id
|
||||
updatedOn = asset.updatedOn
|
||||
data = Convert.FromBase64String asset.data
|
||||
}
|
||||
|
||||
|
||||
/// A unified archive for a web log
|
||||
type Archive =
|
||||
{ /// The web log to which this archive belongs
|
||||
webLog : WebLog
|
||||
|
||||
/// The users for this web log
|
||||
users : WebLogUser list
|
||||
|
||||
/// The theme used by this web log at the time the archive was made
|
||||
theme : Theme
|
||||
|
||||
/// Assets for the theme used by this web log at the time the archive was made
|
||||
assets : EncodedAsset list
|
||||
|
||||
/// The categories for this web log
|
||||
categories : Category list
|
||||
|
||||
/// The tag mappings for this web log
|
||||
tagMappings : TagMap list
|
||||
|
||||
/// The pages for this web log (containing only the most recent revision)
|
||||
pages : Page list
|
||||
|
||||
/// The posts for this web log (containing only the most recent revision)
|
||||
posts : Post list
|
||||
}
|
||||
|
||||
/// Create a JSON serializer (uses RethinkDB data implementation's JSON converters)
|
||||
let private getSerializer prettyOutput =
|
||||
let serializer = JsonSerializer.CreateDefault ()
|
||||
Json.all () |> Seq.iter serializer.Converters.Add
|
||||
if prettyOutput then serializer.Formatting <- Formatting.Indented
|
||||
serializer
|
||||
|
||||
/// Display statistics for a backup archive
|
||||
let private displayStats (msg : string) (webLog : WebLog) archive =
|
||||
|
||||
let userCount = List.length archive.users
|
||||
let assetCount = List.length archive.assets
|
||||
let categoryCount = List.length archive.categories
|
||||
let tagMapCount = List.length archive.tagMappings
|
||||
let pageCount = List.length archive.pages
|
||||
let postCount = List.length archive.posts
|
||||
|
||||
// Create a pluralized output based on the count
|
||||
let plural count ifOne ifMany =
|
||||
if count = 1 then ifOne else ifMany
|
||||
|
||||
printfn ""
|
||||
printfn $"""{msg.Replace ("{{NAME}}", webLog.name)}"""
|
||||
printfn $""" - The theme "{archive.theme.name}" with {assetCount} asset{plural assetCount "" "s"}"""
|
||||
printfn $""" - {userCount} user{plural userCount "" "s"}"""
|
||||
printfn $""" - {categoryCount} categor{plural categoryCount "y" "ies"}"""
|
||||
printfn $""" - {tagMapCount} tag mapping{plural tagMapCount "" "s"}"""
|
||||
printfn $""" - {pageCount} page{plural pageCount "" "s"}"""
|
||||
printfn $""" - {postCount} post{plural postCount "" "s"}"""
|
||||
|
||||
/// Create a backup archive
|
||||
let private createBackup webLog (fileName : string) prettyOutput (data : IData) = task {
|
||||
// Create the data structure
|
||||
let themeId = ThemeId webLog.themePath
|
||||
|
||||
printfn "- Exporting theme..."
|
||||
let! theme = data.Theme.findById themeId
|
||||
let! assets = data.ThemeAsset.findByThemeWithData themeId
|
||||
|
||||
printfn "- Exporting users..."
|
||||
let! users = data.WebLogUser.findByWebLog webLog.id
|
||||
|
||||
printfn "- Exporting categories and tag mappings..."
|
||||
let! categories = data.Category.findByWebLog webLog.id
|
||||
let! tagMaps = data.TagMap.findByWebLog webLog.id
|
||||
|
||||
printfn "- Exporting pages..."
|
||||
let! pages = data.Page.findFullByWebLog webLog.id
|
||||
|
||||
printfn "- Exporting posts..."
|
||||
let! posts = data.Post.findFullByWebLog webLog.id
|
||||
|
||||
printfn "- Writing archive..."
|
||||
let archive = {
|
||||
webLog = webLog
|
||||
users = users
|
||||
theme = Option.get theme
|
||||
assets = assets |> List.map EncodedAsset.fromAsset
|
||||
categories = categories
|
||||
tagMappings = tagMaps
|
||||
pages = pages |> List.map (fun p -> { p with revisions = List.truncate 1 p.revisions })
|
||||
posts = posts |> List.map (fun p -> { p with revisions = List.truncate 1 p.revisions })
|
||||
}
|
||||
|
||||
// Write the structure to the backup file
|
||||
if File.Exists fileName then File.Delete fileName
|
||||
let serializer = getSerializer prettyOutput
|
||||
use writer = new StreamWriter (fileName)
|
||||
serializer.Serialize (writer, archive)
|
||||
writer.Close ()
|
||||
|
||||
displayStats "{{NAME}} backup contains:" webLog archive
|
||||
}
|
||||
|
||||
let private doRestore archive newUrlBase (data : IData) = task {
|
||||
let! restore = task {
|
||||
match! data.WebLog.findById archive.webLog.id with
|
||||
| Some webLog when defaultArg newUrlBase webLog.urlBase = webLog.urlBase ->
|
||||
do! data.WebLog.delete webLog.id
|
||||
return archive
|
||||
| Some _ ->
|
||||
// Err'body gets new IDs...
|
||||
let newWebLogId = WebLogId.create ()
|
||||
let newCatIds = archive.categories |> List.map (fun cat -> cat.id, CategoryId.create ()) |> dict
|
||||
let newMapIds = archive.tagMappings |> List.map (fun tm -> tm.id, TagMapId.create ()) |> dict
|
||||
let newPageIds = archive.pages |> List.map (fun page -> page.id, PageId.create ()) |> dict
|
||||
let newPostIds = archive.posts |> List.map (fun post -> post.id, PostId.create ()) |> dict
|
||||
let newUserIds = archive.users |> List.map (fun user -> user.id, WebLogUserId.create ()) |> dict
|
||||
return
|
||||
{ archive with
|
||||
webLog = { archive.webLog with id = newWebLogId; urlBase = Option.get newUrlBase }
|
||||
users = archive.users
|
||||
|> List.map (fun u -> { u with id = newUserIds[u.id]; webLogId = newWebLogId })
|
||||
categories = archive.categories
|
||||
|> List.map (fun c -> { c with id = newCatIds[c.id]; webLogId = newWebLogId })
|
||||
tagMappings = archive.tagMappings
|
||||
|> List.map (fun tm -> { tm with id = newMapIds[tm.id]; webLogId = newWebLogId })
|
||||
pages = archive.pages
|
||||
|> List.map (fun page ->
|
||||
{ page with
|
||||
id = newPageIds[page.id]
|
||||
webLogId = newWebLogId
|
||||
authorId = newUserIds[page.authorId]
|
||||
})
|
||||
posts = archive.posts
|
||||
|> List.map (fun post ->
|
||||
{ post with
|
||||
id = newPostIds[post.id]
|
||||
webLogId = newWebLogId
|
||||
authorId = newUserIds[post.authorId]
|
||||
categoryIds = post.categoryIds |> List.map (fun c -> newCatIds[c])
|
||||
})
|
||||
}
|
||||
| None ->
|
||||
return
|
||||
{ archive with
|
||||
webLog = { archive.webLog with urlBase = defaultArg newUrlBase archive.webLog.urlBase }
|
||||
}
|
||||
}
|
||||
|
||||
// Restore theme and assets (one at a time, as assets can be large)
|
||||
printfn ""
|
||||
printfn "- Importing theme..."
|
||||
do! data.Theme.save restore.theme
|
||||
let! _ = restore.assets |> List.map (EncodedAsset.fromAsset >> data.ThemeAsset.save) |> Task.WhenAll
|
||||
|
||||
// Restore web log data
|
||||
|
||||
printfn "- Restoring web log..."
|
||||
do! data.WebLog.add restore.webLog
|
||||
|
||||
printfn "- Restoring users..."
|
||||
do! data.WebLogUser.restore restore.users
|
||||
|
||||
printfn "- Restoring categories and tag mappings..."
|
||||
do! data.TagMap.restore restore.tagMappings
|
||||
do! data.Category.restore restore.categories
|
||||
|
||||
printfn "- Restoring pages..."
|
||||
do! data.Page.restore restore.pages
|
||||
|
||||
printfn "- Restoring posts..."
|
||||
do! data.Post.restore restore.posts
|
||||
|
||||
// TODO: comments not yet implemented
|
||||
|
||||
displayStats "Restored for {{NAME}}:" restore.webLog restore
|
||||
}
|
||||
|
||||
/// Decide whether to restore a backup
|
||||
let private restoreBackup (fileName : string) newUrlBase promptForOverwrite data = task {
|
||||
|
||||
let serializer = getSerializer false
|
||||
use stream = new FileStream (fileName, FileMode.Open)
|
||||
use reader = new StreamReader (stream)
|
||||
use jsonReader = new JsonTextReader (reader)
|
||||
let archive = serializer.Deserialize<Archive> jsonReader
|
||||
|
||||
let mutable doOverwrite = not promptForOverwrite
|
||||
if promptForOverwrite then
|
||||
printfn "** WARNING: Restoring a web log will delete existing data for that web log"
|
||||
printfn " (unless restoring to a different URL base), and will overwrite the"
|
||||
printfn " theme in either case."
|
||||
printfn ""
|
||||
printf "Continue? [Y/n] "
|
||||
doOverwrite <- not ((Console.ReadKey ()).Key = ConsoleKey.N)
|
||||
|
||||
if doOverwrite then
|
||||
do! doRestore archive newUrlBase data
|
||||
else
|
||||
printfn $"{archive.webLog.name} backup restoration canceled"
|
||||
}
|
||||
|
||||
/// Generate a backup archive
|
||||
let generateBackup (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length = 3 || args.Length = 4 then
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
match! data.WebLog.findByHost args[1] with
|
||||
| Some webLog ->
|
||||
let fileName = if args[2].EndsWith ".json" then args[2] else $"{args[2]}.json"
|
||||
let prettyOutput = args.Length = 4 && args[3] = "pretty"
|
||||
do! createBackup webLog fileName prettyOutput data
|
||||
| None -> printfn $"Error: no web log found for {args[1]}"
|
||||
else
|
||||
printfn """Usage: MyWebLog backup [url-base] [backup-file-name] [*"pretty"]"""
|
||||
printfn """ * optional - default is non-pretty JSON output"""
|
||||
}
|
||||
|
||||
/// Restore a backup archive
|
||||
let restoreFromBackup (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length = 2 || args.Length = 3 then
|
||||
let data = sp.GetRequiredService<IData> ()
|
||||
let newUrlBase = if args.Length = 3 then Some args[2] else None
|
||||
do! restoreBackup args[1] newUrlBase (args[0] <> "do-restore") data
|
||||
else
|
||||
printfn "Usage: MyWebLog restore [backup-file-name] [*url-base]"
|
||||
printfn " * optional - will restore to original URL base if omitted"
|
||||
printfn " (use do-restore to skip confirmation prompt)"
|
||||
}
|
||||
|
||||
44
src/MyWebLog/MyWebLog.fsproj
Normal file
44
src/MyWebLog/MyWebLog.fsproj
Normal file
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>3391</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
|
||||
<Compile Include="Caches.fs" />
|
||||
<Compile Include="Handlers\Error.fs" />
|
||||
<Compile Include="Handlers\Helpers.fs" />
|
||||
<Compile Include="Handlers\Admin.fs" />
|
||||
<Compile Include="Handlers\Feed.fs" />
|
||||
<Compile Include="Handlers\Post.fs" />
|
||||
<Compile Include="Handlers\User.fs" />
|
||||
<Compile Include="Handlers\Routes.fs" />
|
||||
<Compile Include="DotLiquidBespoke.fs" />
|
||||
<Compile Include="Maintenance.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotLiquid" Version="2.2.656" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.7.0" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.7.0" />
|
||||
<PackageReference Include="NeoSmart.Caching.Sqlite" Version="6.0.1" />
|
||||
<PackageReference Include="RethinkDB.DistributedCache" Version="1.0.0-rc1" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
<PackageReference Include="System.ServiceModel.Syndication" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include=".\wwwroot\img\*.png" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB</ProjectGuid>
|
||||
<RootNamespace>MyWebLog</RootNamespace>
|
||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
||||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace MyWebLog
|
||||
{
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
App.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/MyWebLog/Program.fs
Normal file
147
src/MyWebLog/Program.fs
Normal file
@@ -0,0 +1,147 @@
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
|
||||
/// Middleware to derive the current web log
|
||||
type WebLogMiddleware (next : RequestDelegate, log : ILogger<WebLogMiddleware>) =
|
||||
|
||||
/// Is the debug level enabled on the logger?
|
||||
let isDebug = log.IsEnabled LogLevel.Debug
|
||||
|
||||
member this.InvokeAsync (ctx : HttpContext) = task {
|
||||
/// Create the full path of the request
|
||||
let path = $"{ctx.Request.Scheme}://{ctx.Request.Host.Value}{ctx.Request.Path.Value}"
|
||||
match WebLogCache.tryGet path with
|
||||
| Some webLog ->
|
||||
if isDebug then log.LogDebug $"Resolved web log {WebLogId.toString webLog.id} for {path}"
|
||||
ctx.Items["webLog"] <- webLog
|
||||
if PageListCache.exists ctx then () else do! PageListCache.update ctx
|
||||
if CategoryCache.exists ctx then () else do! CategoryCache.update ctx
|
||||
return! next.Invoke ctx
|
||||
| None ->
|
||||
if isDebug then log.LogDebug $"No resolved web log for {path}"
|
||||
ctx.Response.StatusCode <- 404
|
||||
}
|
||||
|
||||
|
||||
open System
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyWebLog.Data
|
||||
|
||||
/// Logic to obtain a data connection and implementation based on configured values
|
||||
module DataImplementation =
|
||||
|
||||
open MyWebLog.Converters
|
||||
open RethinkDb.Driver.FSharp
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Get the configured data implementation
|
||||
let get (sp : IServiceProvider) : IData =
|
||||
let config = sp.GetRequiredService<IConfiguration> ()
|
||||
if (config.GetConnectionString >> isNull >> not) "SQLite" then
|
||||
let conn = new SqliteConnection (config.GetConnectionString "SQLite")
|
||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||
upcast SQLiteData (conn, sp.GetRequiredService<ILogger<SQLiteData>> ())
|
||||
elif (config.GetSection "RethinkDB").Exists () then
|
||||
Json.all () |> Seq.iter Converter.Serializer.Converters.Add
|
||||
let rethinkCfg = DataConfig.FromConfiguration (config.GetSection "RethinkDB")
|
||||
let conn = rethinkCfg.CreateConnectionAsync () |> Async.AwaitTask |> Async.RunSynchronously
|
||||
upcast RethinkDbData (conn, rethinkCfg, sp.GetRequiredService<ILogger<RethinkDbData>> ())
|
||||
else
|
||||
let log = sp.GetRequiredService<ILogger<SQLiteData>> ()
|
||||
log.LogInformation "Using default SQLite database myweblog.db"
|
||||
let conn = new SqliteConnection ("Data Source=./myweblog.db;Cache=Shared")
|
||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||
upcast SQLiteData (conn, log)
|
||||
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.HttpOverrides
|
||||
open NeoSmart.Caching.Sqlite
|
||||
open RethinkDB.DistributedCache
|
||||
|
||||
[<EntryPoint>]
|
||||
let rec main args =
|
||||
|
||||
let builder = WebApplication.CreateBuilder(args)
|
||||
let _ = builder.Services.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
|
||||
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
|
||||
let _ =
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(fun opts ->
|
||||
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 60.
|
||||
opts.SlidingExpiration <- true
|
||||
opts.AccessDeniedPath <- "/forbidden")
|
||||
let _ = builder.Services.AddLogging ()
|
||||
let _ = builder.Services.AddAuthorization ()
|
||||
let _ = builder.Services.AddAntiforgery ()
|
||||
|
||||
let sp = builder.Services.BuildServiceProvider ()
|
||||
let data = DataImplementation.get sp
|
||||
|
||||
task {
|
||||
do! data.startUp ()
|
||||
do! WebLogCache.fill data
|
||||
do! ThemeAssetCache.fill data
|
||||
} |> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
// Define distributed cache implementation based on data implementation
|
||||
match data with
|
||||
| :? RethinkDbData as rethink ->
|
||||
// A RethinkDB connection is designed to work as a singleton
|
||||
builder.Services.AddSingleton<IData> data |> ignore
|
||||
builder.Services.AddDistributedRethinkDBCache (fun opts ->
|
||||
opts.TableName <- "Session"
|
||||
opts.Connection <- rethink.Conn)
|
||||
|> ignore
|
||||
| :? SQLiteData as sql ->
|
||||
// ADO.NET connections are designed to work as per-request instantiation
|
||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||
builder.Services.AddScoped<SqliteConnection> (fun sp ->
|
||||
let conn = new SqliteConnection (sql.Conn.ConnectionString)
|
||||
SQLiteData.setUpConnection conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||
conn)
|
||||
|> ignore
|
||||
builder.Services.AddScoped<IData, SQLiteData> () |> ignore
|
||||
// Use SQLite for caching as well
|
||||
let cachePath = Option.ofObj (cfg.GetConnectionString "SQLiteCachePath") |> Option.defaultValue "./session.db"
|
||||
builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore
|
||||
| _ -> ()
|
||||
|
||||
let _ = builder.Services.AddSession(fun opts ->
|
||||
opts.IdleTimeout <- TimeSpan.FromMinutes 60
|
||||
opts.Cookie.HttpOnly <- true
|
||||
opts.Cookie.IsEssential <- true)
|
||||
let _ = builder.Services.AddGiraffe ()
|
||||
|
||||
// Set up DotLiquid
|
||||
DotLiquidBespoke.register ()
|
||||
|
||||
let app = builder.Build ()
|
||||
|
||||
match args |> Array.tryHead with
|
||||
| Some it when it = "init" -> Maintenance.createWebLog args app.Services
|
||||
| Some it when it = "import-links" -> Maintenance.importLinks args app.Services
|
||||
| Some it when it = "load-theme" -> Maintenance.loadTheme args app.Services
|
||||
| Some it when it = "backup" -> Maintenance.Backup.generateBackup args app.Services
|
||||
| Some it when it = "restore" -> Maintenance.Backup.restoreFromBackup args app.Services
|
||||
| _ ->
|
||||
let _ = app.UseForwardedHeaders ()
|
||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseMiddleware<WebLogMiddleware> ()
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseSession ()
|
||||
let _ = app.UseGiraffe Handlers.Routes.endpoint
|
||||
|
||||
System.Threading.Tasks.Task.FromResult (app.Run ())
|
||||
|> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
0 // Exit code
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("MyWebLog")]
|
||||
[assembly: AssemblyDescription("A lightweight blogging platform built on Nancy, and RethinkDB")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("MyWebLog")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2016")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("b9f6db52-65a1-4c2a-8c97-739e08a1d4fb")]
|
||||
[assembly: AssemblyVersion("0.9.2.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
12
src/MyWebLog/appsettings.json
Normal file
12
src/MyWebLog/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"RethinkDB": {
|
||||
"hostname": "data02.bitbadger.solutions",
|
||||
"database": "myWebLog_dev"
|
||||
},
|
||||
"Generator": "myWebLog 2.0-alpha36",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"MyWebLog.Handlers": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 myWebLog
|
||||
// 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": "3RVkw1jESpLFHr8F3WTThSbFnO3tFrMIckQsKzc9dymzEEXUoUS7nurF4rGpJ8Z",
|
||||
// Changing any of these next 3 will render all current logins invalid, and the user will be force to reauthenticate.
|
||||
"auth-salt": "2TweL5wcyGWg5CmMqZSZMonbe9xqQ2Q4vDNeysFRaUgVs4BpFZL85Iew79tn2IJ",
|
||||
"encryption-passphrase": "jZjY6XyqUZypBcT0NaDXjEKc8xUjB4eb4V9EDHDedadRLuRUeRvIQx67yhx6bQP",
|
||||
"hmac-passphrase": "42dzKb93X8YUkK8ms8JldjtkEvCKnPQGWCkO2yFaZ7lkNwECGCX00xzrx5ZSElO",
|
||||
"data": {
|
||||
"database": "myWebLog",
|
||||
"hostname": "localhost"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"buildOptions": {
|
||||
"emitEntryPoint": true,
|
||||
"copyToOutput": {
|
||||
"include": [ "views", "content", "config.json" ]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"MyWebLog.App": "0.9.2",
|
||||
},
|
||||
"frameworks": {
|
||||
"netcoreapp1.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.NETCore.App": {
|
||||
"type": "platform",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
},
|
||||
"imports": "dnxcore50"
|
||||
}
|
||||
},
|
||||
"publishOptions": {
|
||||
"include": [ "views", "content", "config.json" ]
|
||||
},
|
||||
"version": "0.9.2"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>@Model.PageTitle | @Translate.Admin | @Model.WebLog.Name</title>
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.css" />
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootswatch/3.3.4/cosmo/bootstrap.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/admin/content/admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-inverse">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/">@Model.WebLog.Name</a>
|
||||
</div>
|
||||
<div class="navbar-left">
|
||||
<p class="navbar-text">@Model.PageTitle</p>
|
||||
</div>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
@If.IsAuthenticated
|
||||
<li><a href="/admin">@Translate.Dashboard</a></li>
|
||||
<li><a href="/user/log-off">@Translate.LogOff</a></li>
|
||||
@EndIf
|
||||
@IfNot.IsAuthenticated
|
||||
<li><a href="/user/log-on">@Translate.LogOn</a></li>
|
||||
@EndIf
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
@Each.Messages
|
||||
@Current.ToDisplay
|
||||
@EndEach
|
||||
@Section['Content'];
|
||||
</div>
|
||||
<footer>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-right">@Model.FooterLogoLight </div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="text/javascript" src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.3.min.js"></script>
|
||||
<script type="text/javascript" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="//cdn.tinymce.com/4/tinymce.min.js"></script>
|
||||
@Section['Scripts'];
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action="/category/@Model.Category.Id/edit" method="post">
|
||||
@AntiForgeryToken
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<a href="/categories" class="btn btn-default">
|
||||
<i class="fa fa-list-ul"></i> @Translate.BackToCategoryList
|
||||
</a>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Name">@Translate.Name</label>
|
||||
<input type="text" class="form-control" id="Name" name="Name" value="@Model.Form.Name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-8">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Slug">@Translate.Slug</label>
|
||||
<input type="text" class="form-control" id="Slug" name="Slug" value="@Model.Form.Slug" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Description">@Translate.Description</label>
|
||||
<textarea class="form-control" rows="4" id="Description" name="Description">@Model.Form.Description</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="ParentId">@Translate.ParentCategory</label>
|
||||
<select class="form-control" id="ParentId" name="ParentId">
|
||||
<option value="">— @Translate.NoParent —</option>
|
||||
@Each.Categories
|
||||
@Current.Option
|
||||
@EndEach
|
||||
</select>
|
||||
</div>
|
||||
<br />
|
||||
<p class="text-center">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa fa-floppy-o"></i> @Translate.Save
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
$(document).ready(function () { $("#Name").focus() })
|
||||
/* ]] */
|
||||
</script>
|
||||
@EndSection
|
||||
@@ -1,51 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<p><a class="btn btn-primary" href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>@Translate.Action</th>
|
||||
<th>@Translate.Category</th>
|
||||
<th>@Translate.Description</th>
|
||||
</tr>
|
||||
@Each.Categories
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/category/@Current.Category.Id/edit">@Translate.Edit</a>
|
||||
<a href="javascript:void(0)" onclick="deleteCategory('@Current.Category.Id', '@Current.Category.Name')">
|
||||
@Translate.Delete
|
||||
</a>
|
||||
</td>
|
||||
<td>@Current.ListName</td>
|
||||
<td>
|
||||
@If.HasDescription
|
||||
@Current.Category.Description.Value
|
||||
@EndIf
|
||||
@IfNot.HasDescription
|
||||
|
||||
@EndIf
|
||||
</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
</div>
|
||||
<form method="post" id="deleteForm">
|
||||
@AntiForgeryToken
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
function deleteCategory(id, title) {
|
||||
if (confirm('@Translate.CategoryDeleteWarning "' + title + '"?')) {
|
||||
document.getElementById("deleteForm").action = "/category/" + id + "/delete"
|
||||
document.getElementById("deleteForm").submit()
|
||||
}
|
||||
}
|
||||
/* ]] */
|
||||
</script>
|
||||
@EndSection
|
||||
@@ -1,5 +0,0 @@
|
||||
footer {
|
||||
background-color: #808080;
|
||||
border-top: solid 1px black;
|
||||
color: white;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
tinymce.init({
|
||||
menubar: false,
|
||||
plugins: [
|
||||
"advlist autolink link image lists charmap print preview hr anchor pagebreak spellchecker",
|
||||
"searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking",
|
||||
"save table contextmenu directionality emoticons template paste textcolor"
|
||||
],
|
||||
selector: "textarea",
|
||||
toolbar: "styleselect | forecolor backcolor | bullist numlist | link unlink anchor | paste pastetext | spellchecker | visualblocks visualchars | code fullscreen"
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<div class="col-xs-4 text-center">
|
||||
<h3>@Translate.Posts <span class="badge">@Model.Posts</span></h3>
|
||||
<p>
|
||||
<a href="/posts/list"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
||||
|
||||
<a href="/post/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<h3>@Translate.Pages <span class="badge">@Model.Pages</span></h3>
|
||||
<p>
|
||||
<a href="/pages"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
||||
|
||||
<a href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<h3>@Translate.Categories <span class="badge">@Model.Categories</span></h3>
|
||||
<p>
|
||||
<a href="/categories"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
||||
|
||||
<a href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
@EndSection
|
||||
@@ -1,61 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action="/page/@Model.Page.Id/edit" method="post">
|
||||
@AntiForgeryToken
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<a href="/pages" class="btn btn-default">
|
||||
<i class="fa fa-list-ul"></i> @Translate.BackToPageList
|
||||
</a>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Title">@Translate.Title</label>
|
||||
<input type="text" name="Title" id="Title" class="form-control" value="@Model.Form.Title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Permalink">@Translate.Permalink</label>
|
||||
<input type="text" name="Permalink" id="Permalink" class="form-control" value="@Model.Form.Permalink" />
|
||||
<p class="form-hint"><em>@Translate.startingWith</em> //@Model.WebLog.UrlBase/</p>
|
||||
</div>
|
||||
<!-- // TODO: Markdown / HTML choice -->
|
||||
<input type="hidden" name="Source" value="html" />
|
||||
<div class="form-group">
|
||||
<textarea name="Text" id="Text" rows="15" class="form-control">@Model.Form.Text</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">@Translate.PageDetails</div>
|
||||
<div class="panel-body">
|
||||
@IfNot.isNew
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.PublishedDate</label>
|
||||
<p class="static-control">@Model.PublishedDate<br />@Model.PublishedTime</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.LastUpdatedDate</label>
|
||||
<p class="static-control">@Model.LastUpdatedDate<br />@Model.LastUpdatedTime</p>
|
||||
</div>
|
||||
@EndIf
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="ShowInPageList" id="ShowInPageList" value="true" @Model.PageListChecked />
|
||||
<label for="ShowInPageList">@Translate.ShowInPageList</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p><button class="btn btn-primary" type="submit"><i class="fa fa-floppy-o"></i> @Translate.Save</button></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript" src="/admin/content/tinymce-init.js"></script>
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
$(document).ready(function () { $("#Title").focus() })
|
||||
/* ]]> */
|
||||
</script>
|
||||
@EndSection
|
||||
@@ -1,42 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<p><a class="btn btn-primary" href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>@Translate.Title</th>
|
||||
<th>@Translate.LastUpdated</th>
|
||||
</tr>
|
||||
@Each.Pages
|
||||
<tr>
|
||||
<td>
|
||||
@Current.Page.Title<br />
|
||||
<a href="/@Current.Page.Permalink">@Translate.View</a>
|
||||
<a href="/page/@Current.Page.Id/edit">@Translate.Edit</a>
|
||||
<a href="javascript:void(0)" onclick="deletePage('@Current.Page.Id', '@Current.Page.Title')">@Translate.Delete</a>
|
||||
</td>
|
||||
<td>@Current.UpdatedDate<br />@Translate.at @Current.UpdatedTime</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
</div>
|
||||
<form method="delete" id="deleteForm">
|
||||
@AntiForgeryToken
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
function deletePage(id, title) {
|
||||
if (confirm('@Translate.PageDeleteWarning "' + title + '"?')) {
|
||||
document.getElementById("deleteForm").action = "/page/" + id + "/delete"
|
||||
document.getElementById("deleteForm").submit()
|
||||
}
|
||||
}
|
||||
/* ]] */
|
||||
</script>
|
||||
@EndSection
|
||||
@@ -1,90 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action='/post/@Model.Post.Id/edit' method="post">
|
||||
@AntiForgeryToken
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<a href="/posts/list" class="btn btn-default">
|
||||
<i class="fa fa-list-ul"></i> @Translate.BackToPostList
|
||||
</a>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Title">@Translate.Title</label>
|
||||
<input type="text" name="Title" id="Title" class="form-control" value="@Model.Form.Title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Permalink">@Translate.Permalink</label>
|
||||
<input type="text" name="Permalink" id="Permalink" class="form-control" value="@Model.Form.Permalink" />
|
||||
<p class="form-hint"><em>@Translate.startingWith</em> //@Model.WebLog.UrlBase/ </p>
|
||||
</div>
|
||||
<!-- // TODO: Markdown / HTML choice -->
|
||||
<input type="hidden" name="Source" value="html" />
|
||||
<div class="form-group">
|
||||
<textarea name="Text" id="Text" rows="15">@Model.Form.Text</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="Tags">@Translate.Tags</label>
|
||||
<input type="text" name="Tags" id="Tags" class="form-control" value="@Model.Form.Tags" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">@Translate.PostDetails</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.PostStatus</label>
|
||||
<p class="static-control">@Model.Post.Status</p>
|
||||
</div>
|
||||
@If.IsPublished
|
||||
<div class="form-group">
|
||||
<label class="control-label">@Translate.PublishedDate</label>
|
||||
<p class="static-control">@Model.PublishedDate<br />@Model.PublishedTime</p>
|
||||
</div>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">@Translate.Categories</h4>
|
||||
</div>
|
||||
<div class="panel-body" style="max-height:350px;overflow:scroll;">
|
||||
@Each.Categories
|
||||
@Current.Indent
|
||||
<input type="checkbox" id="Category-@Current.Id" name="Categories" value="@Current.Id" @Current.CheckedAttr />
|
||||
|
||||
<label for="Category-@Current.Id" title="@Current.Description">@Current.Name</label>
|
||||
<br/>
|
||||
@EndEach
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
@If.IsPublished
|
||||
<input type="hidden" name="PublishNow" value="true" />
|
||||
@EndIf
|
||||
@IfNot.IsPublished
|
||||
<div>
|
||||
<input type="checkbox" name="PublishNow" id="PublishNow" value="true" @Model.PublishNowCheckedAttr />
|
||||
<label for="PublishNow">@Translate.PublishThisPost</label>
|
||||
</div>
|
||||
@EndIf
|
||||
<p>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-floppy-o"></i> @Translate.Save
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript" src="/admin/content/tinymce-init.js"></script>
|
||||
<script type="text/javascript">
|
||||
/** <![CDATA[ */
|
||||
$(document).ready(function () { $("#Title").focus() })
|
||||
/** ]]> */
|
||||
</script>
|
||||
@EndSection
|
||||
@@ -1,49 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<div class="row">
|
||||
<p>
|
||||
<a class="btn btn-primary" href="/post/new/edit">
|
||||
<i class="fa fa-plus"></i> @Translate.AddNew
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>@Translate.Date</th>
|
||||
<th>@Translate.Title</th>
|
||||
<th>@Translate.Status</th>
|
||||
<th>@Translate.Tags</th>
|
||||
</tr>
|
||||
@Each.Posts
|
||||
<tr>
|
||||
<td style="white-space:nowrap;">
|
||||
@Current.PublishedDate<br />
|
||||
@Translate.at @Current.PublishedTime
|
||||
</td>
|
||||
<td>
|
||||
@Current.Post.Title<br />
|
||||
<a href="/@Current.Post.Permalink">@Translate.View</a> |
|
||||
<a href="/post/@Current.Post.Id/edit">@Translate.Edit</a> |
|
||||
<a href="/post/@Current.Post.Id/delete">@Translate.Delete</a>
|
||||
</td>
|
||||
<td>@Current.Post.Status</td>
|
||||
<td>@Current.Tags</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-3 col-xs-offset-2">
|
||||
@If.HasNewer
|
||||
<p><a class="btn btn-default" href="@Model.NewerLink">« @Translate.NewerPosts</a></p>
|
||||
@EndIf
|
||||
</div>
|
||||
<div class="col-xs-3 col-xs-offset-1 text-right">
|
||||
@If.HasOlder
|
||||
<p><a class="btn btn-default" href="@Model.OlderLink">@Translate.OlderPosts »</a></p>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
||||
@EndSection
|
||||
@@ -1,41 +0,0 @@
|
||||
@Master['admin/admin-layout']
|
||||
|
||||
@Section['Content']
|
||||
<form action="/user/log-on" method="post">
|
||||
@AntiForgeryToken
|
||||
<input type="hidden" name="ReturnUrl" value="@Model.Form.ReturnUrl" />
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon" title="@Translate.EmailAddress"><i class="fa fa-envelope"></i></span>
|
||||
<input type="text" name="Email" id="Email" class="form-control" placeholder="@Translate.EmailAddress" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
|
||||
<br />
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon" title="@Translate.Password"><i class="fa fa-key"></i></span>
|
||||
<input type="password" name="Password" class="form-control" placeholder="@Translate.Password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-center">
|
||||
<p>
|
||||
<br />
|
||||
<button class="btn btn-primary"><i class="fa fa-sign-in"></i> @Translate.LogOn</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@EndSection
|
||||
|
||||
@Section['Scripts']
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
$(document).ready(function () { $("#Email").focus() })
|
||||
/* ]]> */
|
||||
</script>
|
||||
@EndSection
|
||||
@@ -1,4 +0,0 @@
|
||||
<h4>
|
||||
@Model.Commentor <small>@Model.CommentedOn</small>
|
||||
</h4>
|
||||
@Model.Comment.Text
|
||||
@@ -1,476 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap v3.3.4 (http://getbootstrap.com)
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
.btn-default,
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-info,
|
||||
.btn-warning,
|
||||
.btn-danger {
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.btn-default:active,
|
||||
.btn-primary:active,
|
||||
.btn-success:active,
|
||||
.btn-info:active,
|
||||
.btn-warning:active,
|
||||
.btn-danger:active,
|
||||
.btn-default.active,
|
||||
.btn-primary.active,
|
||||
.btn-success.active,
|
||||
.btn-info.active,
|
||||
.btn-warning.active,
|
||||
.btn-danger.active {
|
||||
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
}
|
||||
.btn-default .badge,
|
||||
.btn-primary .badge,
|
||||
.btn-success .badge,
|
||||
.btn-info .badge,
|
||||
.btn-warning .badge,
|
||||
.btn-danger .badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
.btn:active,
|
||||
.btn.active {
|
||||
background-image: none;
|
||||
}
|
||||
.btn-default {
|
||||
text-shadow: 0 1px 0 #fff;
|
||||
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
||||
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dbdbdb;
|
||||
border-color: #ccc;
|
||||
}
|
||||
.btn-default:hover,
|
||||
.btn-default:focus {
|
||||
background-color: #e0e0e0;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-default:active,
|
||||
.btn-default.active {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #dbdbdb;
|
||||
}
|
||||
.btn-default.disabled,
|
||||
.btn-default:disabled,
|
||||
.btn-default[disabled] {
|
||||
background-color: #e0e0e0;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #245580;
|
||||
}
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background-color: #265a88;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-primary:active,
|
||||
.btn-primary.active {
|
||||
background-color: #265a88;
|
||||
border-color: #245580;
|
||||
}
|
||||
.btn-primary.disabled,
|
||||
.btn-primary:disabled,
|
||||
.btn-primary[disabled] {
|
||||
background-color: #265a88;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-success {
|
||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
|
||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #3e8f3e;
|
||||
}
|
||||
.btn-success:hover,
|
||||
.btn-success:focus {
|
||||
background-color: #419641;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-success:active,
|
||||
.btn-success.active {
|
||||
background-color: #419641;
|
||||
border-color: #3e8f3e;
|
||||
}
|
||||
.btn-success.disabled,
|
||||
.btn-success:disabled,
|
||||
.btn-success[disabled] {
|
||||
background-color: #419641;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-info {
|
||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
|
||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #28a4c9;
|
||||
}
|
||||
.btn-info:hover,
|
||||
.btn-info:focus {
|
||||
background-color: #2aabd2;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-info:active,
|
||||
.btn-info.active {
|
||||
background-color: #2aabd2;
|
||||
border-color: #28a4c9;
|
||||
}
|
||||
.btn-info.disabled,
|
||||
.btn-info:disabled,
|
||||
.btn-info[disabled] {
|
||||
background-color: #2aabd2;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-warning {
|
||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
|
||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #e38d13;
|
||||
}
|
||||
.btn-warning:hover,
|
||||
.btn-warning:focus {
|
||||
background-color: #eb9316;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-warning:active,
|
||||
.btn-warning.active {
|
||||
background-color: #eb9316;
|
||||
border-color: #e38d13;
|
||||
}
|
||||
.btn-warning.disabled,
|
||||
.btn-warning:disabled,
|
||||
.btn-warning[disabled] {
|
||||
background-color: #eb9316;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-danger {
|
||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
|
||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #b92c28;
|
||||
}
|
||||
.btn-danger:hover,
|
||||
.btn-danger:focus {
|
||||
background-color: #c12e2a;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-danger:active,
|
||||
.btn-danger.active {
|
||||
background-color: #c12e2a;
|
||||
border-color: #b92c28;
|
||||
}
|
||||
.btn-danger.disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger[disabled] {
|
||||
background-color: #c12e2a;
|
||||
background-image: none;
|
||||
}
|
||||
.thumbnail,
|
||||
.img-thumbnail {
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.dropdown-menu > li > a:hover,
|
||||
.dropdown-menu > li > a:focus {
|
||||
background-color: #e8e8e8;
|
||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.dropdown-menu > .active > a,
|
||||
.dropdown-menu > .active > a:hover,
|
||||
.dropdown-menu > .active > a:focus {
|
||||
background-color: #2e6da4;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.navbar-default {
|
||||
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
||||
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.navbar-default .navbar-nav > .open > a,
|
||||
.navbar-default .navbar-nav > .active > a {
|
||||
background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
||||
background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
|
||||
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.navbar-brand,
|
||||
.navbar-nav > li > a {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
|
||||
}
|
||||
.navbar-inverse {
|
||||
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
||||
background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
|
||||
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.navbar-inverse .navbar-nav > .open > a,
|
||||
.navbar-inverse .navbar-nav > .active > a {
|
||||
background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
||||
background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
|
||||
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.navbar-inverse .navbar-brand,
|
||||
.navbar-inverse .navbar-nav > li > a {
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
|
||||
}
|
||||
.navbar-static-top,
|
||||
.navbar-fixed-top,
|
||||
.navbar-fixed-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a,
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
|
||||
color: #fff;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
}
|
||||
.alert {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
||||
}
|
||||
.alert-success {
|
||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
|
||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #b2dba1;
|
||||
}
|
||||
.alert-info {
|
||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
|
||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #9acfea;
|
||||
}
|
||||
.alert-warning {
|
||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
|
||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #f5e79e;
|
||||
}
|
||||
.alert-danger {
|
||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
||||
background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
|
||||
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dca7a7;
|
||||
}
|
||||
.progress {
|
||||
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
||||
background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
|
||||
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-success {
|
||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
|
||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-info {
|
||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
|
||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-warning {
|
||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
|
||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-danger {
|
||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
|
||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-striped {
|
||||
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
}
|
||||
.list-group {
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.list-group-item.active,
|
||||
.list-group-item.active:hover,
|
||||
.list-group-item.active:focus {
|
||||
text-shadow: 0 -1px 0 #286090;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #2b669a;
|
||||
}
|
||||
.list-group-item.active .badge,
|
||||
.list-group-item.active:hover .badge,
|
||||
.list-group-item.active:focus .badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
.panel {
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
}
|
||||
.panel-default > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-primary > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-success > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
|
||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-info > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
|
||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-warning > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
|
||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-danger > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
||||
background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
|
||||
background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.well {
|
||||
background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
||||
background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
|
||||
background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dcdcdc;
|
||||
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-theme.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
|
||||
<footer>
|
||||
<hr />
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-right">
|
||||
@Model.FooterLogoDark
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1,43 +0,0 @@
|
||||
@Each.Messages
|
||||
@Current.ToDisplay
|
||||
@EndEach
|
||||
@If.SubTitle.IsSome
|
||||
<h2>
|
||||
<span class="label label-info">@Model.SubTitle</span>
|
||||
</h2>
|
||||
@EndIf
|
||||
@Each.Posts
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<article>
|
||||
<h1>
|
||||
<a href="/@Current.Post.Permalink"
|
||||
title="@Translate.PermanentLinkTo "@Current.Post.Title"">@Current.Post.Title</a>
|
||||
</h1>
|
||||
<p>
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Current.PublishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Current.PublishedTime
|
||||
<i class="fa fa-comments-o" title="@Translate.Comments"></i> @Current.CommentCount
|
||||
</p>
|
||||
@Current.Post.Text
|
||||
</article>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
@EndEach
|
||||
<div class="row">
|
||||
<div class="col-xs-3 col-xs-offset-3">
|
||||
@If.HasNewer
|
||||
<p>
|
||||
<a class="btn btn-primary" href="@Model.NewerLink">@Translate.NewerPosts</a>
|
||||
</p>
|
||||
@EndIf
|
||||
</div>
|
||||
<div class="col-xs-3 text-right">
|
||||
@If.HasOlder
|
||||
<p>
|
||||
<a class="btn btn-primary" href="@Model.OlderLink">@Translate.OlderPosts</a>
|
||||
</p>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
@Master['themes/default/layout']
|
||||
|
||||
@Section['Content']
|
||||
@Partial['themes/default/index-content', Model]
|
||||
@EndSection
|
||||
|
||||
@Section['Footer']
|
||||
@Partial['themes/default/footer', Model]
|
||||
@EndSection
|
||||
@@ -1,48 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content="@Model.Generator" />
|
||||
<title>@Model.DisplayPageTitle</title>
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/default/bootstrap-theme.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" />
|
||||
<link rel="alternate" type="application/atom+xml" href="//@Model.WebLog.UrlBase/feed?format=atom" />
|
||||
<link rel="alternate" type="application/rss+xml" href="//@Model.WebLog.UrlBase/feed" />
|
||||
@Section['Head'];
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-default">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/">@Model.WebLog.Name</a>
|
||||
</div>
|
||||
<p class="navbar-text">@Model.WebLogSubtitle</p>
|
||||
<ul class="nav navbar-nav navbar-left">
|
||||
@Each.WebLog.PageList
|
||||
<li><a href="/@Current.Permalink">@Current.Title</a></li>
|
||||
@EndEach
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
@If.IsAuthenticated
|
||||
<li><a href="/admin">@Translate.Dashboard</a></li>
|
||||
<li><a href="/user/log-off">@Translate.LogOff</a></li>
|
||||
@EndIf
|
||||
@IfNot.IsAuthenticated
|
||||
<li><a href="/user/log-on">@Translate.LogOn</a></li>
|
||||
@EndIf
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
@Section['Content'];
|
||||
</div>
|
||||
@Section['Footer'];
|
||||
<script type="text/javascript" src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.3.min.js"></script>
|
||||
<script type="text/javascript" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||
@Section['Scripts'];
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +0,0 @@
|
||||
<article>
|
||||
<h1>@Model.Page.Title</h1>
|
||||
@Model.Page.Text
|
||||
</article>
|
||||
@@ -1,9 +0,0 @@
|
||||
@Master['themes/default/layout']
|
||||
|
||||
@Section['Content']
|
||||
@Partial['themes/default/page-content', Model]
|
||||
@EndSection
|
||||
|
||||
@Section['Footer']
|
||||
@Partial['themes/default/footer', Model]
|
||||
@EndSection
|
||||
@@ -1,67 +0,0 @@
|
||||
<article>
|
||||
<div class="row">
|
||||
<div class="col-xs-12"><h1>@Model.Post.Title</h1></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h4>
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Model.PublishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Model.PublishedTime
|
||||
<i class="fa fa-comments-o" title="@Translate.Comments"></i> @Model.CommentCount
|
||||
@Each.Post.Categories
|
||||
<span style="white-space:nowrap;">
|
||||
<i class="fa fa-folder-open-o" title="@Translate.Category"></i>
|
||||
<a href="/category/@Current.Slug" title="@Translate.CategorizedUnder @Current.Name">@Current.Name</a>
|
||||
|
||||
</span>
|
||||
@EndEach
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">@Model.Post.Text</div>
|
||||
</div>
|
||||
@If.HasTags
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
@Each.Tags
|
||||
<span style="white-space:nowrap;">
|
||||
<a href="/tag/@Current.Item2" title="@Translate.PostsTagged "@Current.Item1"">
|
||||
<i class="fa fa-tag"></i> @Current.Item1
|
||||
</a>
|
||||
</span>
|
||||
@EndEach
|
||||
</div>
|
||||
</div>
|
||||
@EndIf
|
||||
</article>
|
||||
<div class="row">
|
||||
<div class="col-xs-12"><hr /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
@Each.Comments
|
||||
@Partial['themes/default/comment', @Current]
|
||||
@EndEach
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12"><hr /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
@If.HasNewer
|
||||
<a href="/@Model.NewerPost.Value.Permalink" title="@Translate.NextPost - "@Model.NewerPost.Value.Title"">
|
||||
« @Model.NewerPost.Value.Title
|
||||
</a>
|
||||
@EndIf
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
@If.HasOlder
|
||||
<a href="/@Model.OlderPost.Value.Permalink"
|
||||
title="@Translate.PreviousPost - "@Model.OlderPost.Value.Title"">
|
||||
@Model.OlderPost.Value.Title »
|
||||
</a>
|
||||
@EndIf
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
@Master['themes/default/layout']
|
||||
|
||||
@Section['Content']
|
||||
@Partial['themes/default/single-content', Model]
|
||||
@EndSection
|
||||
|
||||
@Section['Footer']
|
||||
@Partial['themes/default/footer', Model]
|
||||
@EndSection
|
||||
Reference in New Issue
Block a user