on master help sign in
Bring obelisk-asset into this repo, with improvements
commite612f6211af71b850e0a8533ac144a4985cbcec3 authorRyan Trinkle <ryan.trinkle@gmail.com> dateSun 4 Feb 2018, 21:43 (9 years ago) parentaffd41e
17 files changed+1044−15
default.nix+17−6 lib/asset/README.md+29 lib/asset/assets.nix+251 lib/asset/github.json−7 lib/asset/manifest/obelisk-asset-manifest.cabal+43 lib/asset/manifest/src-bin/generate.hs+16 lib/asset/manifest/src/Obelisk/Asset/Gather.hs+68 lib/asset/manifest/src/Obelisk/Asset/Promoted.hs+111 lib/asset/manifest/src/Obelisk/Asset/Symlink.hs+22 lib/asset/manifest/src/Obelisk/Asset/TH.hs+14 lib/asset/serve-snap/obelisk-asset-serve.cabal+32 lib/asset/serve-snap/src/Obelisk/Asset/Accept.hs+258 lib/asset/serve-snap/src/Obelisk/Asset/Serve/Snap.hs+123 lib/backend/obelisk-backend.cabal+1−1 lib/backend/src/Obelisk/Backend.hs+1−1 lib/snap-extras/obelisk-snap-extras.cabal+21 lib/snap-extras/src/Obelisk/Snap/Extras.hs+37
default.nix+17−6
@@ -28,7 +28,7 @@ let #TODO: Upstream
2828
2929 # The haskell environment used to build Obelisk itself, e.g. the 'ob' command
3030 ghcObelisk = reflex-platform.ghc.override {
31− overrides = composeExtensions addLibs (self: super: {
31+ overrides = composeExtensions defaultHaskellOverrides (self: super: {
3232 mkDerivation = args: super.mkDerivation (args // {
3333 enableLibraryProfiling = profiling;
3434 });
@@ -53,14 +53,21 @@ let #TODO: Upstream
5353 });
5454 };
5555
56+ fixUpstreamPkgs = self: super: {
57+ heist = doJailbreak super.heist; #TODO: Move up to reflex-platform; create tests for r-p supported packages
58+ };
59+
5660 addLibs = self: super: {
57− obelisk-asset-serve = self.callCabal2nix "obelisk-asset-serve" (hackGet ./lib/asset + "/serve") {};
5861 obelisk-asset-manifest = self.callCabal2nix "obelisk-asset-manifest" (hackGet ./lib/asset + "/manifest") {};
62+ obelisk-asset-serve-snap = self.callCabal2nix "obelisk-asset-serve-snap" (hackGet ./lib/asset + "/serve-snap") {};
5963 obelisk-backend = self.callCabal2nix "obelisk-backend" ./lib/backend {};
60− obelisk-snap = self.callCabal2nix "obelisk-snap" ./lib/snap {};
61− obelisk-selftest = self.callCabal2nix "obelisk-selftest" ./lib/selftest {};
6264 obelisk-command = self.callCabal2nix "obelisk-command" ./lib/command {};
65+ obelisk-selftest = self.callCabal2nix "obelisk-selftest" ./lib/selftest {};
66+ obelisk-snap = self.callCabal2nix "obelisk-snap" ./lib/snap {};
67+ obelisk-snap-extras = self.callCabal2nix "obelisk-snap-extras" ./lib/snap-extras {};
6368 };
69+
70+ defaultHaskellOverrides = composeExtensions fixUpstreamPkgs addLibs;
6471in
6572with pkgs.lib;
6673rec {
@@ -113,10 +120,9 @@ rec {
113120 };
114121 combinedPackages = predefinedPackages // packages;
115122 projectOverrides = self: super: {
116− heist = doJailbreak super.heist; #TODO: Move up to reflex-platform; create tests for r-p supported packages
117123 ${staticName} = dontHaddock (self.callCabal2nix "static" assets.haskellManifest {});
118124 };
119− overrides = composeExtensions addLibs projectOverrides;
125+ overrides = composeExtensions defaultHaskellOverrides projectOverrides;
120126 in {
121127 inherit overrides;
122128 packages = combinedPackages;
@@ -145,4 +151,9 @@ rec {
145151 };
146152 };
147153 in mkProject (projectDefinition args));
154+ haskellPackageSets = {
155+ ghc = reflex-platform.ghc.override {
156+ overrides = defaultHaskellOverrides;
157+ };
158+ };
148159}
lib/asset/README.mdnew file+29
@@ -0,0 +1,29 @@
1+## obelisk-asset
2+
3+The 'assets.nix' file contains nix expressions that are a mutually
4+recursive set of attributes used to create static asset directories
5+with hashable file encodings. This file should be incorporated within
6+your project's 'default.nix' file. (See ./example/default.nix , line 2)
7+```
8+{assets ? import ./../assets.nix { inherit nixpkgs; }
9+}:
10+```
11+
12+The 'mkAssets' and 'mkAssetsWith' functions within 'assets.nix' only
13+need to be passed an encoding('zopfliEncodings', 'gzipEncodings',
14+'noEncodings') and a static directory of your choice as arguments.
15+
16+In this example, 'mkAssets' is used on a directory that has a .png
17+file inside of it. (See ./example/default.nix, line 11)
18+```
19+myAssets = assets.mkAssets ./static;
20+```
21+
22+Once you have successfully incorporated 'assets.nix' into your
23+project's nix file(s), use nix-build to generate a symlink of
24+your 'mkAssets' expression. The following command from
25+example/run-example (line 3) will generate an immutable symlink
26+that holds hashed static assets.
27+```bash
28+nix-build -o static.assets -A myAssets
29+```
lib/asset/assets.nixnew file+251
@@ -0,0 +1,251 @@
1+# Asset generation pipeline using Nix, which generates a directory structure to be served via obelisk-asset-serve-*
2+{ nixpkgs }:
3+
4+with nixpkgs.lib;
5+
6+rec {
7+
8+# Default encoding generation function for this platform; usually zopfliEncodings, but gzipEncodings on darwin due to zopfli not building on darwin.
9+#
10+# defaultEncodings :: String -> Derivation
11+defaultEncodings =
12+ # zopfli isn't supported on macOS as of nixpkgs eafd703a63
13+ if nixpkgs.stdenv.isDarwin then gzipEncodings else zopfliEncodings;
14+
15+# Encoding generation function which uses zopfli to encode the asset with very high compression efficiency, at the cost of CPU time compressing.
16+# Generates gzip, compress/zlib, and deflate outputs all using zopfli with 5 iterations.
17+#
18+# zopfliEncodings :: String -> Derivation
19+zopfliEncodings = file:
20+ nixpkgs.stdenv.mkDerivation {
21+ name = "encodings";
22+
23+ input = file;
24+
25+ builder = builtins.toFile "builder.sh" ''
26+ source "$stdenv/setup"
27+
28+ mkdir -p "$out"
29+
30+ ln -s "$input" "$out/identity"
31+
32+ zopfli -c --i5 --gzip "$input" >"$out/gzip"
33+ zopfli -c --i5 --zlib "$input" >"$out/compress"
34+ zopfli -c --i5 --deflate "$input" >"$out/deflate"
35+ '';
36+
37+ buildInputs = [
38+ nixpkgs.zopfli
39+ ];
40+ };
41+
42+# Encoding generation function which uses gzip to encode the asset with decent compression efficiency and a small CPU cost. Only generates a gzip output.
43+#
44+# gzipEncodings :: String -> Derivation
45+gzipEncodings = file:
46+ nixpkgs.stdenv.mkDerivation {
47+ name = "encodings";
48+
49+ input = file;
50+
51+ builder = builtins.toFile "builder.sh" ''
52+ source "$stdenv/setup"
53+
54+ mkdir -p "$out"
55+
56+ ln -s "$input" "$out/identity"
57+
58+ gzip -c7 "$input" > "$out/gzip"
59+ '';
60+
61+ buildInputs = [
62+ nixpkgs.gzip
63+ ];
64+ };
65+
66+# Encoding generation function which doesn't do any compression.
67+#
68+# noEncodings :: String -> Derivation
69+noEncodings = file:
70+ nixpkgs.stdenv.mkDerivation {
71+ name = "encodings";
72+
73+ input = file;
74+
75+ builder = builtins.toFile "builder.sh" ''
76+ source "$stdenv/setup"
77+
78+ mkdir -p "$out"
79+
80+ ln -s "$input" "$out/identity"
81+ '';
82+
83+ buildInputs = [
84+ ];
85+ };
86+
87+# Convert an attrset into a list of name/value pairs.
88+#
89+# setToList :: AttrSet a -> [{ name :: String, value :: a }]
90+setToList = mapAttrsToList (name: value: { inherit name value; });
91+
92+# Apply some function (chunkDerivations, for example) to the values in a list of name/value pairs then reattach the names.
93+#
94+# mapSnds :: ([b] -> [b']) -> [{ fst :: a, snd :: b }] -> [{ fst :: a, snd :: b' }]
95+mapSnds = f: l: zipLists (map (x: x.fst) l) (f (map (x: x.snd) l));
96+
97+# Chunk derivations in groups of up to a given size to avoid https://github.com/NixOS/nix/issues/875
98+#
99+# It works by creating a dependent chain of derivations that must be built to finish evaluation of the overall nix expression, without evaluating too many.
100+# Based on the current operation of nix, n should not be greater than 1012, and should probably be substantially less.
101+#
102+# chunkDerivations :: Int -> [a] -> [a]
103+chunkDerivations = n: l:
104+ let a = take n l;
105+ b = drop n l;
106+ go = x: if x == [] then builtins.toFile "emptyList.nix" "[]" else chunkDerivations2 n (take n x) (go (drop n x));
107+
108+ # makeChunkDerivation :: Int -> [a] -> ExprFile [a] -> ExprFile [a]
109+ makeChunkDerivation = chunk': next:
110+ let chunk = map (path: "(import ${builtins.unsafeDiscardOutputDependency path.drvPath}).${path.outputName}") chunk';
111+ in builtins.seq (builtins.toPath next) (nixpkgs.stdenv.mkDerivation {
112+ name = "chunkDerivations";
113+ inherit chunk next;
114+ preferLocalBuild = true;
115+ builder = builtins.toFile "chunkDerivations.sh" ''
116+ source "$stdenv/setup"
117+
118+ echo $chunk
119+
120+ echo "[ $chunk ] ++ (import $next)" >"$out"
121+ '';
122+ });
123+ in a ++ import (go b);
124+
125+# Recursively map some function over a @AttrSet DirEntry@, unioning the results of the application at each level.
126+#
127+# unionMapFilesWithName :: ({name :: String, value :: DirEntry} -> [{name :: String, value :: a}]) -> AttrSet DirEntry -> AttrSet DirEntry
128+unionMapFilesWithName = f: d:
129+ let go = {name, value}:
130+ if value.type == "directory" then [{
131+ inherit name;
132+ value = {
133+ inherit (value) type;
134+ contents = unionMapFilesWithName f value.contents;
135+ };
136+ }] else setToList (f {
137+ inherit name value;
138+ });
139+ in builtins.listToAttrs (builtins.concatLists (map go (setToList d)));
140+
141+# Read a directory structure recursively into an @AttrSet DirEntry@.
142+#
143+# readDirRecursive :: String -> AttrSet DirEntry
144+readDirRecursive = dir:
145+ let d = filterAttrs (n: d: !(hasPrefix "." n)) (builtins.readDir dir);
146+ go = name:
147+ let path = dir + "/${name}";
148+ type = d.${name};
149+ in if type == "directory" then {
150+ inherit name;
151+ value = {
152+ inherit type;
153+ contents = readDirRecursive path;
154+ };
155+ } else {
156+ inherit name;
157+ value = {
158+ inherit type path;
159+ };
160+ };
161+ in builtins.listToAttrs (map go (builtins.attrNames d));
162+
163+# Render a string as a double quoted string with any internal double quotes or backslashes escaped using backslash, appropriate for a nix expression.
164+#
165+# doubleQuoteString :: String -> String
166+doubleQuoteString = s: "\"" + builtins.replaceStrings ["\\" "\""] ["\\\\" "\\\""] s + "\"";
167+
168+# Given an @AttrSet DirEntry@ generate a derivation with symlink entries for each @DirEntry@ given.
169+#
170+# dirToPath :: AttrSet DirEntry -> Derivation
171+dirToPath = contents:
172+ let pairsToBashArray = xs: builtins.concatStringsSep " " (map (np: "[" + doubleQuoteString np.fst + "]=" + doubleQuoteString (toString np.snd)) xs);
173+ files = pairsToBashArray (mapSnds (chunkDerivations 128) (mapAttrsToList (fst: entry: { inherit fst; snd = toPath entry; }) contents));
174+ filesIsLarge = builtins.stringLength files > 65536;
175+ in
176+ nixpkgs.stdenv.mkDerivation {
177+ name = "toPath";
178+ inherit files;
179+ passAsFile = optional filesIsLarge "files";
180+ preferLocalBuild = true;
181+ builder = builtins.toFile "builder.sh" ''
182+ set -e
183+ source "$stdenv/setup"
184+
185+ eval "declare -A files=(${if filesIsLarge then ''$(cat "$filesPath")'' else ''$files''})"
186+
187+ mkdir "$out"
188+
189+ for filename in "''${!files[@]}" ; do
190+ ln -s "''${files[$filename]}" "$out/$filename"
191+ done
192+ '';
193+ };
194+
195+# Given a @DirEntry@ either use 'dirToPath' to make a derivation with its contents or return the path directly.
196+toPath = x: if x.type == "directory" then dirToPath x.contents else x.path;
197+
198+# Given a file path, hash its contents as nix does and return the hash string.
199+hashFile = path:
200+ builtins.readFile (nixpkgs.runCommand "hashFile" {
201+ buildInputs = [
202+ nixpkgs.nix
203+ ];
204+ preferLocalBuild = true;
205+ inherit path;
206+ } ''
207+ nix-hash --flat --base32 --type sha256 "$path" | tr -d '\n' >"$out"
208+ '');
209+
210+# Build a DirEntry of the shape { type :: String, contents :: {DirEntry} } with type set to directory. Used to create a data model to pass to toPath.
211+#
212+# dir :: String -> DirEntry
213+dir = contents: {
214+ type = "directory";
215+ inherit contents;
216+};
217+
218+# Build a DirEntry of the shape { type :: String, path :: String } with type set to symlink. Used to create a data model to pass to toPath.
219+#
220+# symlink :: String -> DirEntry
221+symlink = path: {
222+ type = "symlink";
223+ inherit path;
224+};
225+
226+# Given a encoding generation function and a file entry resulting from readDirRecursive in the form { name :: String, value: { path :: String } },
227+# build a DirEntry for dirToPath with the various encodings of the asset for dirToPath to build into a final directory tree.
228+mkAsset = encodings: {name, value}:
229+ let nameWithHash = "${hashFile value.path}-${name}";
230+ in {
231+ ${nameWithHash} = dir {
232+ type = symlink (builtins.toFile "type" "immutable");
233+ encodings = symlink (encodings value.path);
234+ };
235+ ${name} = dir {
236+ type = symlink (builtins.toFile "type" "redirect");
237+ target = symlink (builtins.toFile "target" "${nameWithHash}");
238+ };
239+ };
240+
241+# Given an encoding generation function to use and a directory containing assets, recursively walk the directory and encode each asset.
242+#
243+# mkAssetsWith :: (String -> Derivation) -> String -> Derivation
244+mkAssetsWith = encodings: d: dirToPath (unionMapFilesWithName (mkAsset encodings) (readDirRecursive d));
245+
246+# Given an input directory containing assets, recursively walk the directory and encode each asset with the default encodings.
247+#
248+# mkAssets :: String -> Derivation
249+mkAssets = mkAssetsWith defaultEncodings;
250+
251+}
lib/asset/github.jsondeleted−7
@@ -1,7 +0,0 @@
1−{
2− "owner": "obsidiansystems",
3− "repo": "obelisk-asset",
4− "private": true,
5− "rev": "d65dece1176aa0137a83d3cee9f693698f555e24",
6− "sha256": "1x37h9vhsr9n6p5myyh3cm8j6rmwnkns6q6nz4c4dvyq61c5xw2q"
7−}
lib/asset/manifest/obelisk-asset-manifest.cabalnew file+43
@@ -0,0 +1,43 @@
1+Name: obelisk-asset-manifest
2+Version: 0.1
3+Synopsis: Project Synopsis Here
4+Description: Project Description Here
5+License: BSD3
6+Author: Obsidian Systems LLC
7+Maintainer: maintainer@obsidian.systems
8+Stability: Experimental
9+Category: Web
10+Build-type: Simple
11+Cabal-version: >= 1.8
12+
13+library
14+ hs-source-dirs: src
15+
16+ build-depends:
17+ base
18+ , bytestring
19+ , containers
20+ , directory
21+ , SHA
22+ , filepath
23+ , template-haskell
24+ , text
25+ , transformers
26+ , unix
27+ , vector
28+ exposed-modules:
29+ Obelisk.Asset.Gather
30+ Obelisk.Asset.Promoted
31+ Obelisk.Asset.Symlink
32+ Obelisk.Asset.TH
33+
34+ other-extensions: TemplateHaskell
35+ ghc-options: -Wall -Werror -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields -O2 -fprof-auto-calls
36+
37+executable obelisk-asset-manifest-generate
38+ hs-source-dirs: src-bin
39+ main-is: generate.hs
40+ build-depends:
41+ base
42+ , obelisk-asset-manifest
43+ , text
lib/asset/manifest/src-bin/generate.hsnew file+16
@@ -0,0 +1,16 @@
1+import qualified Data.Text as T
2+import Obelisk.Asset.Gather
3+import Obelisk.Asset.Promoted
4+import Obelisk.Asset.Symlink
5+import System.Environment
6+
7+main :: IO ()
8+main = do
9+ --TODO: Usage
10+ [root, haskellTarget, packageName, moduleName, fileTarget] <- getArgs
11+ paths <- gatherHashedPaths root
12+ writeStaticProject paths haskellTarget $ StaticConfig
13+ { _staticConfig_packageName = T.pack packageName
14+ , _staticConfig_moduleName = T.pack moduleName
15+ }
16+ copyAndSymlink paths root fileTarget
lib/asset/manifest/src/Obelisk/Asset/Gather.hsnew file+68
@@ -0,0 +1,68 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+-- | Functions for collecting up files to be processed by the asset pipeline
3+module Obelisk.Asset.Gather
4+ ( gatherHashedPaths
5+ , toHashedPath
6+ ) where
7+
8+import Control.Monad (forM)
9+import Data.Bits
10+import qualified Data.ByteString.Lazy as LBS
11+import qualified Data.ByteString.Lazy.Builder as LBS
12+import Data.Char
13+import Data.Digest.Pure.SHA
14+import Data.Map (Map)
15+import qualified Data.Map as Map
16+import Data.Monoid
17+import qualified Data.Text as T
18+import Data.Text.Encoding
19+import qualified Data.Vector.Unboxed as UV
20+import Data.Word
21+import System.FilePath.Posix
22+import System.Directory
23+
24+-- | Given a path, recursively explore it, creating hashed paths for all files found
25+gatherHashedPaths
26+ :: FilePath -- ^ The root of the directory to process
27+ -> IO (Map FilePath FilePath) -- ^ A mapping of original paths to hashed paths (all relative to the root)
28+gatherHashedPaths root = go ""
29+ where
30+ go :: FilePath -> IO (Map FilePath FilePath)
31+ go subdir = do
32+ subs <- listDirectory $ root </> subdir
33+ fmap mconcat $ forM subs $ \sub -> do
34+ let relativePath = subdir </> sub
35+ isFile <- doesFileExist $ root </> relativePath
36+ if isFile
37+ then do hashedRelativePath <- toHashedPath root relativePath
38+ return $ Map.singleton relativePath hashedRelativePath
39+ else go relativePath
40+
41+-- | Given a root path and path relative to it, construct a hashed version of the relative path
42+toHashedPath
43+ :: FilePath -- ^ Root path
44+ -> FilePath -- ^ Relative path within root
45+ -> IO FilePath
46+toHashedPath root relativePath = do
47+ let path = root </> relativePath
48+ contents <- LBS.readFile path
49+ let hashPrefix = T.unpack $ decodeUtf8 $ LBS.toStrict $ toNixBase32 $ bytestringDigest $ sha256 contents
50+ (dir, filename) = splitFileName relativePath
51+ return $ normalise $ dir </> (hashPrefix <> "-" <> filename)
52+
53+-- | Convert a ByteString to base 32 in the way that Nix does
54+toNixBase32 :: LBS.ByteString -> LBS.ByteString
55+toNixBase32 x = LBS.toLazyByteString $ mconcat $ map (LBS.word8 . (symbols UV.!) . fromIntegral) vals
56+ where vals = byteStringToQuintets x
57+ symbols = UV.fromList $ map (fromIntegral . ord) $ filter (`notElem` ("eotu" :: String)) $ ['0'..'9'] <> ['a'..'z']
58+ -- See https://github.com/NixOS/nix/blob/6f1743b1a5116ca57a60b481ee4083c891b7a334/src/libutil/hash.cc#L109
59+ byteStringToQuintets :: LBS.ByteString -> [Word8]
60+ byteStringToQuintets hash = map f [len-1, len-2 .. 0]
61+ where hashSize = fromIntegral $ LBS.length hash
62+ len = (hashSize * 8 - 1) `div` 5 + 1
63+ f n = let b = n * 5
64+ (i, j) = b `divMod` 8
65+ j' = fromIntegral j
66+ --TODO: This is probably pretty slow; replace with something that doesn't use LBS.index
67+ c = ((hash `LBS.index` i) `shift` (-j')) .|. (if i >= hashSize - 1 then 0 else (hash `LBS.index` (i + 1)) `shift` (8 - j'))
68+ in c .&. 0x1f
lib/asset/manifest/src/Obelisk/Asset/Promoted.hsnew file+111
@@ -0,0 +1,111 @@
1+{-# LANGUAGE KindSignatures #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE TemplateHaskellQuotes #-}
4+module Obelisk.Asset.Promoted
5+ ( writeStaticProject
6+ , declareStatic
7+ , StaticConfig (..)
8+ ) where
9+
10+import Obelisk.Asset.Gather
11+
12+import Data.Foldable
13+import Language.Haskell.TH (runQ, pprint)
14+import Language.Haskell.TH.Syntax hiding (lift)
15+import GHC.TypeLits
16+import Data.Sequence (Seq)
17+import qualified Data.Sequence as Seq
18+import Control.Monad.Trans.Writer
19+import System.FilePath
20+import System.Directory
21+import Data.Monoid
22+import Data.Text (Text)
23+import qualified Data.Text as T
24+import qualified Data.Text.IO as T
25+import Data.Map (Map)
26+import qualified Data.Map as Map
27+
28+data StaticConfig = StaticConfig
29+ { _staticConfig_packageName :: Text --TODO: Better type
30+ , _staticConfig_moduleName :: Text --TODO: Better type
31+ }
32+
33+writeStaticProject :: Map FilePath FilePath -> FilePath -> StaticConfig -> IO ()
34+writeStaticProject paths target cfg = do
35+ createDirectoryIfMissing True $ target </> "src"
36+ T.writeFile (target </> T.unpack (_staticConfig_packageName cfg) <.> "cabal") $ staticCabalFile cfg
37+ let modName = _staticConfig_moduleName cfg
38+ modContents <- staticModuleFile modName paths
39+ T.writeFile (target </> "src" </> T.unpack modName <.> "hs") modContents
40+
41+staticCabalFile :: StaticConfig -> Text
42+staticCabalFile cfg = T.unlines
43+ [ "name: " <> _staticConfig_packageName cfg
44+ , "version: 0"
45+ , "cabal-version: >= 1.2"
46+ , "build-type: Simple"
47+ , ""
48+ , "library"
49+ , " hs-source-dirs: src"
50+ , " build-depends:"
51+ , " base,"
52+ , " ghc-prim,"
53+ , " text"
54+ , " exposed-modules: " <> _staticConfig_moduleName cfg
55+ ]
56+
57+staticModuleFile :: Text -> Map FilePath FilePath -> IO Text
58+staticModuleFile moduleName paths = do
59+ decs <- runQ $ fmap toList $ execWriterT $ staticClassWithInstances paths
60+ return $ T.unlines
61+ [ "{-# LANGUAGE AllowAmbiguousTypes #-}"
62+ , "{-# LANGUAGE DataKinds #-}"
63+ , "{-# LANGUAGE FlexibleInstances #-}"
64+ , "{-# LANGUAGE KindSignatures #-}"
65+ , "{-# LANGUAGE OverloadedStrings #-}"
66+ , "module " <> moduleName <> " where"
67+ , ""
68+ , "import qualified GHC.Types"
69+ , "import Data.Text ()"
70+ , "import qualified Data.Text.Internal"
71+ , ""
72+ , T.pack $ pprint decs
73+ ]
74+
75+data StaticContext = StaticContext
76+ { _staticContext_className :: Name
77+ , _staticContext_methodName :: Name
78+ }
79+
80+declareStatic :: FilePath -> Q [Dec]
81+declareStatic root = do
82+ paths <- runIO $ gatherHashedPaths root
83+ -- TODO: If https://ghc.haskell.org/trac/ghc/ticket/14623 is implemented, use
84+ -- qAddDependentFile to watch the directories as well as the files
85+ forM_ (Map.keys paths) $ \original -> do
86+ qAddDependentFile $ root </> original
87+ fmap toList $ execWriterT $ staticClassWithInstances paths
88+
89+staticClassWithInstances :: Map FilePath FilePath -> WriterT (Seq Dec) Q ()
90+staticClassWithInstances paths = do
91+ ctx <- staticClass
92+ forM_ (Map.toList paths) $ \(original, hashed) -> do
93+ staticInstance ctx original hashed
94+
95+staticClass :: WriterT (Seq Dec) Q StaticContext
96+staticClass = do
97+ let n x = Name (OccName x) NameS
98+ className = n "StaticFile"
99+ methodName = n "static"
100+ cls = ClassD [] className [KindedTV (n "s") (ConT ''Symbol)] [] [SigD methodName (ConT ''Text)]
101+ tell $ Seq.singleton cls
102+ return $ StaticContext
103+ { _staticContext_className = className
104+ , _staticContext_methodName = methodName
105+ }
106+
107+staticInstance :: StaticContext -> FilePath -> FilePath -> WriterT (Seq Dec) Q ()
108+staticInstance ctx relativePath hashedPath = do
109+ let headType = ConT (_staticContext_className ctx) `AppT` LitT (StrTyLit relativePath)
110+ methodDec = ValD (VarP $ _staticContext_methodName ctx) (NormalB (LitE (StringL hashedPath))) []
111+ tell $ Seq.singleton $ InstanceD Nothing [] headType [methodDec]
lib/asset/manifest/src/Obelisk/Asset/Symlink.hsnew file+22
@@ -0,0 +1,22 @@
1+module Obelisk.Asset.Symlink
2+ ( copyAndSymlink
3+ ) where
4+
5+import Control.Monad
6+import Data.Map (Map)
7+import qualified Data.Map as Map
8+import System.Directory
9+import System.FilePath.Posix
10+import System.Posix.Files
11+
12+-- | Copy the given hashed files from one location to hashed paths in another
13+-- location, and symlink the unhashed paths to the hashed ones
14+copyAndSymlink
15+ :: Map FilePath FilePath
16+ -> FilePath
17+ -> FilePath
18+ -> IO ()
19+copyAndSymlink paths source destination = forM_ (Map.toList paths) $ \(original, hashed) -> do
20+ createDirectoryIfMissing True $ destination </> takeDirectory hashed
21+ copyFile (source </> original) (destination </> hashed)
22+ createSymbolicLink (takeFileName hashed) $ destination </> original
lib/asset/manifest/src/Obelisk/Asset/TH.hsnew file+14
@@ -0,0 +1,14 @@
1+module Obelisk.Asset.TH
2+ ( assetPath
3+ ) where
4+
5+import Obelisk.Asset.Gather
6+
7+import Language.Haskell.TH.Syntax
8+import System.FilePath.Posix
9+
10+-- | Produces a string literal with the hashed path of the file
11+assetPath :: FilePath -> FilePath -> Q Exp
12+assetPath root relativePath = do
13+ qAddDependentFile $ root </> relativePath
14+ LitE . StringL <$> runIO (toHashedPath root relativePath)
lib/asset/serve-snap/obelisk-asset-serve.cabalnew file+32
@@ -0,0 +1,32 @@
1+name: obelisk-asset-serve-snap
2+version: 0.1
3+synopsis: Serve preprocessed assets using Snap
4+license: BSD3
5+author: Obsidian Systems LLC
6+maintainer: maintainer@obsidian.systems
7+category: Web
8+build-type: Simple
9+cabal-version: >=1.2
10+
11+library
12+ hs-source-dirs: src
13+
14+ build-depends:
15+ base
16+ , attoparsec
17+ , bytestring
18+ , containers
19+ , directory
20+ , filepath
21+ , snap
22+ , snap-core
23+ , obelisk-snap-extras
24+ , text
25+ , transformers
26+ , unix
27+ exposed-modules:
28+ Obelisk.Asset.Accept
29+ Obelisk.Asset.Serve.Snap
30+
31+ other-extensions: TemplateHaskell
32+ ghc-options: -Wall -Werror -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields -O2 -fprof-auto-calls
lib/asset/serve-snap/src/Obelisk/Asset/Accept.hsnew file+258
@@ -0,0 +1,258 @@
1+{-# LANGUAGE CPP #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE BangPatterns #-}
4+{-# LANGUAGE ScopedTypeVariables #-}
5+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
6+
7+-- | Module containing parsers and utilities for managing @Accept-Encoding@ headers and the overall process of encoding selection.
8+--
9+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
10+module Obelisk.Asset.Accept
11+ ( AcceptableEncodings(..)
12+ , Encoding(..)
13+ , QValue(..)
14+ , QValueResolution
15+ , acceptEncodingBody
16+ , chooseEncoding
17+ , missingAcceptableEncodings
18+ ) where
19+
20+#ifdef __GLASGOW_HASKELL__
21+#if __GLASGOW_HASKELL__ < 710
22+import Prelude hiding (takeWhile, sequence)
23+import Data.Traversable (sequence)
24+#else
25+import Prelude hiding (takeWhile)
26+#endif
27+#else
28+import Prelude hiding (takeWhile)
29+#endif
30+
31+import Control.Applicative ((<|>), optional)
32+import Control.Arrow (second)
33+import Control.Monad (replicateM, void)
34+import Data.Attoparsec.ByteString as AttoBS
35+import Data.Attoparsec.ByteString.Char8 as AC8
36+import Data.ByteString (ByteString)
37+import qualified Data.ByteString as BS
38+import Data.Either (partitionEithers)
39+import Data.Fixed (E3, Fixed(..), resolution)
40+import Data.List (sort)
41+import Data.Map (Map)
42+import qualified Data.Map as Map
43+import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
44+import Data.Monoid ((<>))
45+import Data.Ord (Down(..))
46+import Data.Proxy (Proxy(..))
47+import Data.String (IsString)
48+import Data.Word (Word8)
49+
50+
51+-- | Type of a particular named encoding technique, such as @gzip@.
52+--
53+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.5
54+newtype Encoding = Encoding { unEncoding :: ByteString } deriving (Show, Read, Eq, Ord, IsString)
55+
56+-- | Maximum precision of a Q value, in particular 3 decimal places of precision as given by the standard.
57+type QValueResolution = E3
58+
59+-- | Type of an HTTP @qvalue@ or quality value indicating how preferred some encoding is relative to some other one.
60+--
61+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9
62+newtype QValue = QValue { unQValue :: Fixed QValueResolution } deriving (Show, Read, Eq, Ord)
63+
64+-- | Structure used for picking a mutually acceptable encoding, holding a default 'QValue' along with 'QValue's for a number of 'Encoding's and typically
65+-- represented in HTTP as a string like gzip; q=1.0, identity; q=0.5, *; q=0.0@.
66+--
67+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
68+data AcceptableEncodings = AcceptableEncodings
69+ { _acceptableEncodings_defaultQValue :: QValue
70+ -- ^The 'QValue' associated with the default encoding value @*@, indicating any unmentioned encoding.
71+ , _acceptableEncodings_byEncoding :: Map Encoding QValue
72+ -- ^'QValue's for each 'Encoding'.
73+ } deriving (Show, Read, Eq, Ord)
74+
75+-- | When no Accept-Encoding header is present, prefer identity, then gzip or compress, then anything else available.
76+missingAcceptableEncodings :: AcceptableEncodings
77+missingAcceptableEncodings = AcceptableEncodings
78+ { _acceptableEncodings_defaultQValue = QValue 0.001
79+ , _acceptableEncodings_byEncoding = Map.fromList
80+ [ (Encoding "identity", QValue 1)
81+ , (Encoding "gzip", QValue 0.5)
82+ , (Encoding "compress", QValue 0.5)
83+ ]
84+ }
85+
86+-- | Takes a list of 'Encoding's and an 'AcceptableEncodings' representing preferences and returns @Just 'Encoding'@ to use of the given list based on those
87+-- preferences. An 'Encoding' is preferred if it has a higher 'QValue' or in the case of ties if it comes first in the given list of encodings. An encoding
88+-- with a @QValue@ of 0 will never be chosen. If no encoding could be chosen, either because no encodings were given or because 0 @QValue@s suppressed them,
89+-- then @Nothing@ is returned.
90+--
91+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
92+chooseEncoding :: [Encoding] -> AcceptableEncodings -> Maybe Encoding
93+chooseEncoding es ae = fmap snd $ listToMaybe $ sort $ catMaybes $ zipWith f es [(1::Int)..]
94+ where f e n = case encodingQValue e ae of
95+ QValue 0 -> Nothing
96+ q -> Just ((Down q, n), e) -- Choose by quality first (in descending order), then by the server's preference order
97+
98+-- | Helper function for 'chooseEncoding' that returns the default AcceptableEncoding if requested encoding is unavailable
99+encodingQValue :: Encoding -> AcceptableEncodings -> QValue
100+encodingQValue e ae = Map.findWithDefault (_acceptableEncodings_defaultQValue ae) e $ _acceptableEncodings_byEncoding ae
101+
102+-- | Attoparsec 'Parser' for parsing an 'AcceptableEncodings' from the value of an @Accept-Encoding@ header, as specified by HTTP/1.1 / RFC2616.
103+--
104+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
105+acceptEncodingBody :: Parser AcceptableEncodings
106+acceptEncodingBody = do
107+ l <- hashRule (Just 1) Nothing $ do -- partially applied hashRule awaiting to be passed Parser a
108+ c <- (Nothing <$ literal "*") <|> (Just <$> contentCoding) -- Parser Nothing <|> Parser Encoding
109+ mq <- optional $ do
110+ literal ";"
111+ literal "q"
112+ literal "="
113+ qvalue
114+ let q = fromMaybe (QValue 1) mq
115+ return (c, q)
116+ let (stars, specificEncodings) = partitionEithers $ flip map l $ \(c, q) -> case c of
117+ Nothing -> Left q
118+ Just n -> Right (n, q)
119+ starQValue <- case stars of
120+ [] -> return Nothing
121+ [q] -> return $ Just q
122+ _ -> fail "acceptEncodingBody: multiple * values provided"
123+ byEncodingProvided <- sequence $ Map.fromListWithKey (\k _ _ -> fail $ "acceptEncodingBody: encoding " <> show k <> " repeated multiple times") $ map (second return) specificEncodings
124+ let defaultIdentityQValue = fromMaybe (QValue 1) starQValue -- identity has a default qvalue of 1 unless * is given a different qvalue explicitly
125+ defaultQValue = fromMaybe (QValue 0) starQValue
126+ byEncoding = Map.filter (/= defaultQValue) -- Canonicalize: qvalues equal to the default are redundant
127+ . Map.alter (Just . fromMaybe defaultIdentityQValue) "identity" -- Add implicit "identity" encoding, unless it has been explicitly added
128+ $ byEncodingProvided
129+ return $ AcceptableEncodings defaultQValue byEncoding
130+
131+-- | Parser for a 'QValue'.
132+--
133+-- See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9
134+qvalue :: Parser QValue
135+qvalue = do
136+ skipMany lws
137+ q0 <|> q1
138+ where q0 = do
139+ char '0'
140+ decimals <- option [] $ do
141+ char '.'
142+ starRule (Just 0) (Just numAllowedDigits) digit
143+ return $ QValue $ MkFixed $ fromIntegral $ (read ('0' : decimals) :: Int) * 10 ^ (numAllowedDigits - length decimals)
144+ q1 = do
145+ char '1'
146+ option () $ do
147+ char '.'
148+ starRule (Just 0) (Just numAllowedDigits) $ char '0'
149+ return ()
150+ return $ QValue 1
151+ numAllowedDigits :: Int
152+ numAllowedDigits = fromIntegral $ resolution (Proxy :: Proxy QValueResolution)
153+
154+-- | Helper function used in 'acceptEncodingBody' to evaluate Encoding as ByteString
155+contentCoding :: Parser Encoding
156+contentCoding = Encoding <$> token
157+
158+-- | Helper function used in 'contentCoding' that skips lightweight spaces and consumes input while 'isTokenChar' returns True
159+token :: Parser ByteString
160+token = skipMany lws >> AttoBS.takeWhile1 isTokenChar
161+
162+-- | Helper function used in 'acceptEncodingBody', discards evaluation and returns a 'Parser ()' to continue parsing
163+literal :: ByteString -> Parser ()
164+literal s = void $ skipMany lws >> string s
165+
166+-- | Helper function used in 'token' to validate if ByteStringChar is printable but not a control or seperator char
167+isTokenChar :: Word8 -> Bool
168+isTokenChar c = isChar c && not (isCtl c || isSeparator c)
169+
170+-- | Helper function used in 'isTokenChar' to validate if ByteStringChar is a control or printable char
171+isChar :: Word8 -> Bool
172+isChar c = c <= 127
173+
174+-- | Helper function used in 'isTokenChar' to validate if ByteStringChar is a control char
175+isCtl :: Word8 -> Bool
176+isCtl c = c <= 31 || c == 127
177+
178+-- | Helper function used in 'isTokenChar' to validate if ByteStringChar is a seperator char
179+isSeparator :: Word8 -> Bool
180+isSeparator c = BS.elem c $ spVal `BS.cons` htVal `BS.cons` "()<>@,;:\\\"/[]?={}"
181+
182+-- | A version of 'liftM2' that is strict in the result of its first
183+-- action.
184+liftM2' :: (Monad m) => (a -> b -> c) -> m a -> m b -> m c
185+liftM2' f a b = do
186+ !x <- a
187+ y <- b
188+ return (f x y)
189+{-# INLINE liftM2' #-}
190+
191+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.1
192+starRule :: Maybe Int -> Maybe Int -> Parser a -> Parser [a]
193+starRule minNum maxNum element = do
194+ let numMandatory = fromMaybe 0 minNum
195+ mandatoryVals <- replicateM numMandatory element
196+ optionalVals <- case maxNum of
197+ Nothing -> many' element
198+ Just n -> do
199+ let countUpTo 0 _ = return []
200+ countUpTo m a = liftM2' (:) a (countUpTo (pred m) a) <|> return []
201+ countUpTo (n - numMandatory) element
202+ return $ mandatoryVals ++ optionalVals
203+
204+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.1
205+-- The spec is a bit ambiguous on whether extra commas and whitespace are permitted before and after the elements; this implementation permits them
206+hashRule :: forall a. Maybe Int -> Maybe Int -> Parser a -> Parser [a]
207+hashRule minNum maxNum element = do
208+ let numMandatory = fromMaybe 0 minNum
209+ sep :: Parser ()
210+ sep = do
211+ skipMany1 $ do
212+ skipMany lws
213+ char ','
214+ return ()
215+ processMandatory :: Bool -> Int -> Parser [a]
216+ processMandatory isInitial 0 = processOptional isInitial $ fmap (subtract numMandatory) maxNum
217+ processMandatory isInitial n = do
218+ if isInitial then void $ optional sep else sep
219+ liftM2' (:) (skipMany lws >> element) $ processMandatory False $ pred n
220+ processOptional :: Bool -> Maybe Int -> Parser [a]
221+ processOptional _ (Just 0) = return []
222+ processOptional isInitial n = (<|> return []) $ do
223+ if isInitial then void $ optional sep else sep
224+ liftM2' (:) (skipMany lws >> element) $ processOptional False $ fmap pred n
225+ result <- processMandatory True numMandatory
226+ _ <- optional sep
227+ skipMany lws
228+ return result
229+
230+-- | Linear whitespace
231+-- See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
232+lws :: Parser ()
233+lws = do
234+ option () $ cr >> lf
235+ starRule (Just 1) Nothing $ sp <|> ht
236+ return ()
237+
238+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
239+cr :: Parser ()
240+cr = void $ word8 13
241+
242+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
243+lf :: Parser ()
244+lf = void $ word8 10
245+
246+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
247+sp :: Parser ()
248+sp = void $ word8 spVal
249+
250+spVal :: Word8
251+spVal = 32
252+
253+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
254+ht :: Parser ()
255+ht = void $ word8 htVal
256+
257+htVal :: Word8
258+htVal = 9
lib/asset/serve-snap/src/Obelisk/Asset/Serve/Snap.hsnew file+123
@@ -0,0 +1,123 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+-- | Serve preprocessed assets using Snap.
3+module Obelisk.Asset.Serve.Snap
4+ ( serveAssets
5+ , serveAssetsInPlace
6+ , getAssetPath
7+ ) where
8+
9+import Obelisk.Asset.Accept (Encoding (..), acceptEncodingBody, chooseEncoding, missingAcceptableEncodings)
10+import Obelisk.Snap.Extras
11+
12+import Snap
13+ (MonadSnap, getHeader, getsRequest, modifyResponse, pass, redirect, sendFile, setContentLength, setContentType, setHeader, setResponseCode)
14+import Snap.Util.FileServe (defaultMimeTypes, fileType, getSafePath, serveFile)
15+
16+import Control.Applicative ((<|>))
17+import Control.Exception (handleJust, try, throwIO)
18+import Control.Monad (forM, liftM)
19+import Control.Monad.IO.Class (liftIO)
20+import Data.Attoparsec.ByteString (parseOnly, endOfInput)
21+import Data.ByteString (ByteString)
22+import qualified Data.ByteString as BS
23+import Data.List (isSuffixOf, sort)
24+import Data.Monoid ((<>))
25+import qualified Data.Text as T
26+import Data.Text.Encoding (decodeUtf8, encodeUtf8)
27+import System.Directory (getDirectoryContents)
28+import System.FilePath ((</>), splitFileName, takeDirectory)
29+import System.IO.Error (isDoesNotExistError)
30+import System.Posix (getFileStatus, fileSize)
31+
32+-- | Serve static assets from an asset directory generated via @assets.nix@ or, failing that, from a regular directory.
33+--
34+-- For assets generated from @assets.nix@, redirects will be sent to the browser
35+-- from the logical asset name (e.g. @test.png@) to the unique filename for the
36+-- current version of the asset
37+-- (e.g. @0rx5yvkkgkig2pcqf4ngi1l7vh89qqajdzc6aiayaibyhvj0d853-test.png@). Hashed
38+-- asset names will be sent with permanent caching headers.
39+serveAssets :: MonadSnap m => FilePath -> FilePath -> m ()
40+serveAssets = serveAssets' True
41+
42+-- | Serve static assets from an asset directory generated via @assets.nix@ or, failing that, from a regular directory.
43+--
44+-- For assets generated from @assets.nix@, redirects will be not sent to the
45+-- browser for the logical asset name (e.g. @test.png@) but instead the current
46+-- version of the asset with to point the browser at the unique filename for the
47+-- current version of the asset.
48+serveAssetsInPlace :: MonadSnap m => FilePath -> FilePath -> m ()
49+serveAssetsInPlace = serveAssets' False
50+
51+-- | Serve static assets from an asset directory generated via @assets.nix@ or, failing that, from a regular directory.
52+--
53+-- For assets generated from @assets.nix@, the @Bool@ argument @doRedirect@ controls whether redirects will be sent to the browser if a request is made for
54+-- an unhashed asset name, e.g. @test.png@. For @True@, a redirect will be sent, yielding more round trips to the server but better caching behavior if the
55+-- asset doesn't change often. Conversely for @False@, the asset will be served "in place" but made uncacheable.
56+serveAssets' :: MonadSnap m => Bool -> FilePath -> FilePath -> m ()
57+serveAssets' doRedirect base fallback = do
58+ pRaw <- getSafePath
59+ go $ if "/" `isSuffixOf` pRaw || pRaw == "" then pRaw <> "index.html" else pRaw
60+ where
61+ go p = do
62+ assetType <- liftIO $ try $ BS.readFile $ base </> p </> "type"
63+ case assetType of
64+ Right "immutable" -> do
65+ conditionalOnModification <- getsRequest $ getHeader "If-Modified-Since"
66+ case conditionalOnModification of
67+ Nothing -> do
68+ encodedFiles <- liftM (filter (`notElem` [".", ".."])) $ liftIO $ getDirectoryContents $ base </> p </> "encodings"
69+ availableEncodings <- liftM (map snd . sort) $ forM encodedFiles $ \f -> do
70+ stat <- liftIO $ getFileStatus $ base </> p </> "encodings" </> f
71+ return (fileSize stat, Encoding $ encodeUtf8 $ T.pack f)
72+ acceptEncodingRaw <- getsRequest $ getHeader "Accept-Encoding"
73+ ae <- case acceptEncodingRaw of
74+ Nothing -> return missingAcceptableEncodings
75+ Just aer -> case parseOnly (acceptEncodingBody <* endOfInput) aer of
76+ Right ae -> return ae
77+ Left err -> error err
78+ Just (Encoding e) <- return $ chooseEncoding availableEncodings ae
79+ modifyResponse $ setHeader "Content-Encoding" e . setHeader "Vary" "Accept-Encoding"
80+ if doRedirect then cachePermanently else doNotCache --TODO: Use Etags when not redirecting
81+ let finalFilename = base </> p </> "encodings" </> T.unpack (decodeUtf8 e)
82+ stat <- liftIO $ getFileStatus finalFilename
83+ modifyResponse $ setHeader "Last-Modified" "Thu, 1 Jan 1970 00:00:00 GMT"
84+ modifyResponse $ setResponseCode 200 . setContentLength (fromIntegral $ fileSize stat) . setContentType (fileType defaultMimeTypes p)
85+ sendFile finalFilename
86+ Just _ -> cachePermanently >> modifyResponse (setResponseCode 304)
87+ Right "redirect" -> do
88+ mtarget <- liftIO $ getAssetTarget $ base </> p
89+ case mtarget of
90+ Just target -> if doRedirect
91+ then do
92+ doNotCache
93+ redirect target
94+ else go $ takeDirectory p </> T.unpack (decodeUtf8 target)
95+ Nothing -> serveFile $ fallback </> p
96+ Right unknown -> error $ T.unpack ("serveAssets': Unknown asset " <> decodeUtf8 unknown)
97+ Left err | isDoesNotExistError err -> (doNotCache >> serveFileIfExists (fallback </> p)) <|> do
98+ let (dirname, filename) = splitFileName p
99+ unhashedFilename = drop 1 $ dropWhile (/= '-') filename
100+ if null unhashedFilename then pass else do
101+ doNotCache
102+ serveFileIfExists $ fallback </> dirname </> unhashedFilename
103+ | otherwise -> liftIO $ throwIO err
104+
105+-- | If the given file exists in a hashed location, return that location. The
106+-- resulting FilePath will be relative to @base@, just like @assetPath@ is.
107+getAssetPath
108+ :: FilePath -- ^ @base@: Path to asset directory
109+ -> FilePath -- ^ @assetPath@: Path to non-hashed asset within the asset directory
110+ -> IO (Maybe FilePath) -- ^ Path to hashed asset within the asset directory, if it exists
111+getAssetPath base p = do
112+ target <- getAssetTarget $ base </> p
113+ return $ fmap ((takeDirectory p </>) . T.unpack . decodeUtf8) target
114+
115+-- | Given a file path into an asset directory prepared by @assets.nix@, read
116+-- the target path from the metadata if it exists. Returns @Nothing@ when the
117+-- asset path doesn't exist.
118+getAssetTarget :: FilePath -> IO (Maybe ByteString)
119+getAssetTarget p =
120+ handleJust
121+ (\ e -> if isDoesNotExistError e then Just () else Nothing)
122+ (\ () -> return Nothing)
123+ (liftM Just $ BS.readFile $ p </> "target")
lib/backend/obelisk-backend.cabal+1−1
@@ -9,6 +9,6 @@ library
99 snap,
1010 snap-server,
1111 data-default,
12− obelisk-asset-serve,
12+ obelisk-asset-serve-snap,
1313 bytestring
1414 exposed-modules: Obelisk.Backend
lib/backend/src/Obelisk/Backend.hs+1−1
@@ -8,7 +8,7 @@ module Obelisk.Backend
88
99import qualified Data.ByteString.Char8 as BSC8
1010import Data.Default (Default (..))
11−import Obelisk.Asset.Serve (serveAssets)
11+import Obelisk.Asset.Serve.Snap (serveAssets)
1212import System.IO (hSetBuffering, stderr, BufferMode (..))
1313import Snap (httpServe, defaultConfig, commandLineConfig, route)
1414import Snap.Internal.Http.Server.Config (Config (accessLog, errorLog), ConfigLog (ConfigIoLog))
lib/snap-extras/obelisk-snap-extras.cabalnew file+21
@@ -0,0 +1,21 @@
1+name: obelisk-snap-extras
2+version: 0.1
3+synopsys: Extra functionality for Snap that should be considered for upstreaming
4+license: BSD3
5+build-type: Simple
6+cabal-version: >=1.2
7+
8+library
9+ hs-source-dirs: src
10+ build-depends:
11+ attoparsec
12+ , base
13+ , bytestring
14+ , snap-core
15+ , unix
16+ , directory
17+ , filepath
18+ , text
19+ exposed-modules:
20+ Obelisk.Snap.Extras
21+ ghc-options: -Wall
lib/snap-extras/src/Obelisk/Snap/Extras.hsnew file+37
@@ -0,0 +1,37 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+module Obelisk.Snap.Extras
3+ ( cachePermanently
4+ , doNotCache
5+ , serveFileIfExists
6+ , serveFileIfExistsAs
7+ ) where
8+
9+import Control.Monad.IO.Class
10+import Data.ByteString (ByteString)
11+import Snap.Core
12+import Snap.Util.FileServe
13+import System.Directory
14+
15+-- | Set response header for "permanent" caching
16+cachePermanently :: MonadSnap m => m ()
17+cachePermanently = do
18+ modifyResponse $ setHeader "Cache-Control" "public, max-age=315360000"
19+ modifyResponse $ setHeader "Expires" "Tue, 01 Feb 2050 00:00:00 GMT" --TODO: This should be set to "approximately one year from the time the response is sent"
20+
21+-- | Set response header to not cache
22+doNotCache :: MonadSnap m => m ()
23+doNotCache = do
24+ modifyResponse $ setHeader "Cache-Control" "no-cache, no-store, must-revalidate"
25+ modifyResponse $ setHeader "Expires" "0"
26+
27+-- | Serves the specified file if it exists; otherwise, 'pass'es
28+serveFileIfExists :: MonadSnap m => FilePath -> m ()
29+serveFileIfExists f = do
30+ exists <- liftIO $ doesFileExist f
31+ if exists then serveFile f else pass
32+
33+-- | Like 'serveFileIfExists', but with a given MIME type
34+serveFileIfExistsAs :: MonadSnap m => ByteString -> FilePath -> m ()
35+serveFileIfExistsAs mimeType f = do
36+ exists <- liftIO $ doesFileExist f
37+ if exists then serveFileAs mimeType f else pass