2012-07-26 08:50:09 +00:00
|
|
|
{- Yesod webapp
|
2012-07-26 01:26:13 +00:00
|
|
|
-
|
|
|
|
- Copyright 2012 Joey Hess <joey@kitenet.net>
|
|
|
|
-
|
|
|
|
- Licensed under the GNU GPL version 3 or higher.
|
|
|
|
-}
|
|
|
|
|
2012-07-26 08:50:09 +00:00
|
|
|
{-# LANGUAGE OverloadedStrings, CPP, RankNTypes #-}
|
2012-07-26 01:26:13 +00:00
|
|
|
|
|
|
|
module Utility.WebApp where
|
|
|
|
|
|
|
|
import Common
|
2013-05-12 23:19:28 +00:00
|
|
|
import Utility.Tmp
|
2013-01-03 22:50:30 +00:00
|
|
|
import Utility.FileMode
|
Use cryptohash rather than SHA for hashing.
This is a massive win on OSX, which doesn't have a sha256sum normally.
Only use external hash commands when the file is > 1 mb,
since cryptohash is quite close to them in speed.
SHA is still used to calculate HMACs. I don't quite understand
cryptohash's API for those.
Used the following benchmark to arrive at the 1 mb number.
1 mb file:
benchmarking sha256/internal
mean: 13.86696 ms, lb 13.83010 ms, ub 13.93453 ms, ci 0.950
std dev: 249.3235 us, lb 162.0448 us, ub 458.1744 us, ci 0.950
found 5 outliers among 100 samples (5.0%)
4 (4.0%) high mild
1 (1.0%) high severe
variance introduced by outliers: 10.415%
variance is moderately inflated by outliers
benchmarking sha256/external
mean: 14.20670 ms, lb 14.17237 ms, ub 14.27004 ms, ci 0.950
std dev: 230.5448 us, lb 150.7310 us, ub 427.6068 us, ci 0.950
found 3 outliers among 100 samples (3.0%)
2 (2.0%) high mild
1 (1.0%) high severe
2 mb file:
benchmarking sha256/internal
mean: 26.44270 ms, lb 26.23701 ms, ub 26.63414 ms, ci 0.950
std dev: 1.012303 ms, lb 925.8921 us, ub 1.122267 ms, ci 0.950
variance introduced by outliers: 35.540%
variance is moderately inflated by outliers
benchmarking sha256/external
mean: 26.84521 ms, lb 26.77644 ms, ub 26.91433 ms, ci 0.950
std dev: 347.7867 us, lb 210.6283 us, ub 571.3351 us, ci 0.950
found 6 outliers among 100 samples (6.0%)
import Crypto.Hash
import Data.ByteString.Lazy as L
import Criterion.Main
import Common
testfile :: FilePath
testfile = "/run/shm/data" -- on ram disk
main = defaultMain
[ bgroup "sha256"
[ bench "internal" $ whnfIO internal
, bench "external" $ whnfIO external
]
]
sha256 :: L.ByteString -> Digest SHA256
sha256 = hashlazy
internal :: IO String
internal = show . sha256 <$> L.readFile testfile
external :: IO String
external = do
s <- readProcess "sha256sum" [testfile]
return $ fst $ separate (== ' ') s
2013-09-22 23:45:08 +00:00
|
|
|
import Utility.Hash
|
2012-07-26 01:26:13 +00:00
|
|
|
|
2012-08-30 17:05:39 +00:00
|
|
|
import qualified Yesod
|
2012-07-26 08:50:09 +00:00
|
|
|
import qualified Network.Wai as Wai
|
2012-07-26 01:26:13 +00:00
|
|
|
import Network.Wai.Handler.Warp
|
|
|
|
import Network.Wai.Logger
|
|
|
|
import Control.Monad.IO.Class
|
|
|
|
import Network.HTTP.Types
|
|
|
|
import System.Log.Logger
|
2012-07-26 08:50:09 +00:00
|
|
|
import qualified Data.CaseInsensitive as CI
|
2012-07-26 01:26:13 +00:00
|
|
|
import Network.Socket
|
|
|
|
import Control.Exception
|
2013-10-08 15:14:25 +00:00
|
|
|
import "crypto-api" Crypto.Random
|
2012-07-26 16:41:20 +00:00
|
|
|
import qualified Web.ClientSession as CS
|
2012-07-26 08:50:09 +00:00
|
|
|
import qualified Data.ByteString.Lazy as L
|
2013-04-09 19:18:05 +00:00
|
|
|
import qualified Data.ByteString.Lazy.UTF8 as L8
|
2012-08-09 17:33:04 +00:00
|
|
|
import qualified Data.ByteString as B
|
2012-07-26 08:50:09 +00:00
|
|
|
import qualified Data.Text as T
|
|
|
|
import qualified Data.Text.Encoding as TE
|
|
|
|
import Blaze.ByteString.Builder.Char.Utf8 (fromText)
|
|
|
|
import Blaze.ByteString.Builder (Builder)
|
|
|
|
import Data.Monoid
|
|
|
|
import Control.Arrow ((***))
|
2012-07-27 19:33:24 +00:00
|
|
|
import Control.Concurrent
|
2013-05-03 02:38:45 +00:00
|
|
|
#ifdef __ANDROID__
|
|
|
|
import Data.Endian
|
|
|
|
#endif
|
2012-07-26 01:26:13 +00:00
|
|
|
|
2013-04-08 19:04:35 +00:00
|
|
|
localhost :: HostName
|
2012-07-26 01:26:13 +00:00
|
|
|
localhost = "localhost"
|
|
|
|
|
2013-04-18 16:52:55 +00:00
|
|
|
{- Builds a command to use to start or open a web browser showing an url. -}
|
|
|
|
browserProc :: String -> CreateProcess
|
2012-09-29 18:49:15 +00:00
|
|
|
#ifdef darwin_HOST_OS
|
2013-04-18 16:52:55 +00:00
|
|
|
browserProc url = proc "open" [url]
|
2012-07-26 01:26:13 +00:00
|
|
|
#else
|
2013-04-18 16:52:55 +00:00
|
|
|
#ifdef __ANDROID__
|
2013-06-01 01:28:37 +00:00
|
|
|
-- Warning: The `am` command does not work very reliably on Android.
|
2013-04-18 16:52:55 +00:00
|
|
|
browserProc url = proc "am"
|
|
|
|
["start", "-a", "android.intent.action.VIEW", "-d", url]
|
|
|
|
#else
|
2013-12-06 22:18:05 +00:00
|
|
|
#ifdef mingw32_HOST_OS
|
2013-12-07 02:07:16 +00:00
|
|
|
browserProc url = proc "cmd" ["/c start " ++ url]
|
2013-12-06 22:18:05 +00:00
|
|
|
#else
|
2013-04-18 16:52:55 +00:00
|
|
|
browserProc url = proc "xdg-open" [url]
|
|
|
|
#endif
|
2012-07-26 01:26:13 +00:00
|
|
|
#endif
|
2013-12-06 22:18:05 +00:00
|
|
|
#endif
|
2012-07-26 01:26:13 +00:00
|
|
|
|
2013-04-08 19:04:35 +00:00
|
|
|
{- Binds to a socket on localhost, or possibly a different specified
|
|
|
|
- hostname or address, and runs a webapp on it.
|
2012-07-26 01:26:13 +00:00
|
|
|
-
|
2013-01-10 03:17:52 +00:00
|
|
|
- An IO action can also be run, to do something with the address,
|
2012-07-26 01:26:13 +00:00
|
|
|
- such as start a web browser to view the webapp.
|
2012-07-27 19:33:24 +00:00
|
|
|
-}
|
2013-04-08 19:04:35 +00:00
|
|
|
runWebApp :: Maybe HostName -> Wai.Application -> (SockAddr -> IO ()) -> IO ()
|
2013-12-07 02:18:58 +00:00
|
|
|
runWebApp h app observer = withSocketsDo $ do
|
2013-12-07 02:57:54 +00:00
|
|
|
sock <- getSocket h
|
2013-03-09 18:57:48 +00:00
|
|
|
void $ forkIO $ runSettingsSocket webAppSettings sock app
|
2013-05-03 02:38:45 +00:00
|
|
|
sockaddr <- fixSockAddr <$> getSocketName sock
|
|
|
|
observer sockaddr
|
|
|
|
|
|
|
|
fixSockAddr :: SockAddr -> SockAddr
|
|
|
|
#ifdef __ANDROID__
|
|
|
|
{- On Android, the port is currently incorrectly returned in network
|
|
|
|
- byte order, which is wrong on little endian systems. -}
|
|
|
|
fixSockAddr (SockAddrInet (PortNum port) addr) = SockAddrInet (PortNum $ swapEndian port) addr
|
|
|
|
#endif
|
|
|
|
fixSockAddr addr = addr
|
2012-07-26 01:26:13 +00:00
|
|
|
|
2013-03-10 19:43:10 +00:00
|
|
|
webAppSettings :: Settings
|
2013-03-09 18:57:48 +00:00
|
|
|
webAppSettings = defaultSettings
|
|
|
|
-- disable buggy sloworis attack prevention code
|
|
|
|
{ settingsTimeout = 30 * 60
|
|
|
|
}
|
|
|
|
|
2013-04-08 19:04:35 +00:00
|
|
|
{- Binds to a local socket, or if specified, to a socket on the specified
|
2013-05-02 20:48:14 +00:00
|
|
|
- hostname or address. Selects any free port, unless the hostname ends with
|
2013-04-09 19:18:05 +00:00
|
|
|
- ":port"
|
2012-09-18 21:19:41 +00:00
|
|
|
-
|
|
|
|
- Prefers to bind to the ipv4 address rather than the ipv6 address
|
|
|
|
- of localhost, if it's available.
|
2013-04-08 19:04:35 +00:00
|
|
|
-}
|
|
|
|
getSocket :: Maybe HostName -> IO Socket
|
|
|
|
getSocket h = do
|
2013-12-06 03:03:54 +00:00
|
|
|
#if defined(__ANDROID__) || defined (mingw32_HOST_OS)
|
2013-05-02 20:48:14 +00:00
|
|
|
-- getAddrInfo currently segfaults on Android.
|
|
|
|
-- The HostName is ignored by this code.
|
|
|
|
when (isJust h) $
|
2013-12-06 03:03:54 +00:00
|
|
|
error "getSocket with HostName not supported on this OS"
|
2013-05-02 20:48:14 +00:00
|
|
|
addr <- inet_addr "127.0.0.1"
|
|
|
|
sock <- socket AF_INET Stream defaultProtocol
|
|
|
|
preparesocket sock
|
|
|
|
bindSocket sock (SockAddrInet aNY_PORT addr)
|
|
|
|
use sock
|
|
|
|
where
|
|
|
|
#else
|
2013-04-09 19:18:05 +00:00
|
|
|
addrs <- getAddrInfo (Just hints) (Just hostname) port
|
2012-09-18 21:19:41 +00:00
|
|
|
case (partition (\a -> addrFamily a == AF_INET) addrs) of
|
|
|
|
(v4addr:_, _) -> go v4addr
|
|
|
|
(_, v6addr:_) -> go v6addr
|
|
|
|
_ -> error "unable to bind to a local socket"
|
2012-12-13 04:24:19 +00:00
|
|
|
where
|
2013-04-09 19:18:05 +00:00
|
|
|
(hostname, port) = maybe (localhost, Nothing) splitHostPort h
|
2013-05-02 17:01:42 +00:00
|
|
|
hints = defaultHints { addrSocketType = Stream }
|
2012-12-13 04:24:19 +00:00
|
|
|
{- Repeated attempts because bind sometimes fails for an
|
|
|
|
- unknown reason on OSX. -}
|
|
|
|
go addr = go' 100 addr
|
|
|
|
go' :: Int -> AddrInfo -> IO Socket
|
|
|
|
go' 0 _ = error "unable to bind to local socket"
|
|
|
|
go' n addr = do
|
2013-05-02 20:48:14 +00:00
|
|
|
r <- tryIO $ bracketOnError (open addr) sClose (useaddr addr)
|
2012-12-13 04:24:19 +00:00
|
|
|
either (const $ go' (pred n) addr) return r
|
|
|
|
open addr = socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
|
2013-05-02 20:48:14 +00:00
|
|
|
useaddr addr sock = do
|
|
|
|
preparesocket sock
|
2012-12-13 04:24:19 +00:00
|
|
|
bindSocket sock (addrAddress addr)
|
2013-05-02 20:48:14 +00:00
|
|
|
use sock
|
|
|
|
#endif
|
|
|
|
preparesocket sock = setSocketOption sock ReuseAddr 1
|
|
|
|
use sock = do
|
2012-12-13 04:24:19 +00:00
|
|
|
listen sock maxListenQueue
|
|
|
|
return sock
|
2012-07-26 01:26:13 +00:00
|
|
|
|
2013-04-09 19:18:05 +00:00
|
|
|
{- Splits address:port. For IPv6, use [address]:port. The port is optional. -}
|
|
|
|
splitHostPort :: String -> (HostName, Maybe ServiceName)
|
|
|
|
splitHostPort s
|
|
|
|
| "[" `isPrefixOf` s = let (h, p) = break (== ']') (drop 1 s)
|
|
|
|
in if "]:" `isPrefixOf` p
|
|
|
|
then (h, Just $ drop 2 p)
|
|
|
|
else (h, Nothing)
|
|
|
|
| otherwise = let (h, p) = separate (== ':') s
|
|
|
|
in if null p
|
|
|
|
then (h, Nothing)
|
|
|
|
else (h, Just p)
|
|
|
|
|
2012-07-26 01:26:13 +00:00
|
|
|
{- Checks if debugging is actually enabled. -}
|
|
|
|
debugEnabled :: IO Bool
|
|
|
|
debugEnabled = do
|
|
|
|
l <- getRootLogger
|
|
|
|
return $ getLevel l <= Just DEBUG
|
|
|
|
|
|
|
|
{- WAI middleware that logs using System.Log.Logger at debug level.
|
|
|
|
-
|
|
|
|
- Recommend only inserting this middleware when debugging is actually
|
|
|
|
- enabled, as it's not optimised at all.
|
|
|
|
-}
|
2012-07-26 08:50:09 +00:00
|
|
|
httpDebugLogger :: Wai.Middleware
|
2012-07-26 01:26:13 +00:00
|
|
|
httpDebugLogger waiApp req = do
|
|
|
|
logRequest req
|
|
|
|
waiApp req
|
|
|
|
|
2012-07-26 08:50:09 +00:00
|
|
|
logRequest :: MonadIO m => Wai.Request -> m ()
|
2012-07-26 01:26:13 +00:00
|
|
|
logRequest req = do
|
|
|
|
liftIO $ debugM "WebApp" $ unwords
|
2012-07-26 08:50:09 +00:00
|
|
|
[ showSockAddr $ Wai.remoteHost req
|
|
|
|
, frombs $ Wai.requestMethod req
|
|
|
|
, frombs $ Wai.rawPathInfo req
|
|
|
|
--, show $ Wai.httpVersion req
|
2012-07-26 01:26:13 +00:00
|
|
|
--, frombs $ lookupRequestField "referer" req
|
|
|
|
, frombs $ lookupRequestField "user-agent" req
|
|
|
|
]
|
2012-12-13 04:24:19 +00:00
|
|
|
where
|
2013-04-09 19:18:05 +00:00
|
|
|
frombs v = L8.toString $ L.fromChunks [v]
|
2012-07-26 01:26:13 +00:00
|
|
|
|
2012-08-09 17:33:04 +00:00
|
|
|
lookupRequestField :: CI.CI B.ByteString -> Wai.Request -> B.ByteString
|
2012-07-26 08:50:09 +00:00
|
|
|
lookupRequestField k req = fromMaybe "" . lookup k $ Wai.requestHeaders req
|
2012-07-26 07:38:20 +00:00
|
|
|
|
2012-07-26 16:41:20 +00:00
|
|
|
{- Rather than storing a session key on disk, use a random key
|
|
|
|
- that will only be valid for this run of the webapp. -}
|
2013-06-03 20:33:05 +00:00
|
|
|
#if MIN_VERSION_yesod(1,2,0)
|
2013-06-02 19:57:22 +00:00
|
|
|
webAppSessionBackend :: Yesod.Yesod y => y -> IO (Maybe Yesod.SessionBackend)
|
2013-06-03 20:33:05 +00:00
|
|
|
#else
|
|
|
|
webAppSessionBackend :: Yesod.Yesod y => y -> IO (Maybe (Yesod.SessionBackend y))
|
|
|
|
#endif
|
2012-07-26 16:41:20 +00:00
|
|
|
webAppSessionBackend _ = do
|
|
|
|
g <- newGenIO :: IO SystemRandom
|
|
|
|
case genBytes 96 g of
|
|
|
|
Left e -> error $ "failed to generate random key: " ++ show e
|
|
|
|
Right (s, _) -> case CS.initKey s of
|
|
|
|
Left e -> error $ "failed to initialize key: " ++ show e
|
2013-03-10 20:02:16 +00:00
|
|
|
Right key -> use key
|
|
|
|
where
|
|
|
|
timeout = 120 * 60 -- 120 minutes
|
|
|
|
use key =
|
2013-06-02 19:57:22 +00:00
|
|
|
#if MIN_VERSION_yesod(1,2,0)
|
|
|
|
Just . Yesod.clientSessionBackend key . fst
|
|
|
|
<$> Yesod.clientSessionDateCacher timeout
|
|
|
|
#else
|
2013-03-10 20:02:16 +00:00
|
|
|
#if MIN_VERSION_yesod(1,1,7)
|
|
|
|
Just . Yesod.clientSessionBackend2 key . fst
|
|
|
|
<$> Yesod.clientSessionDateCacher timeout
|
|
|
|
#else
|
|
|
|
return $ Just $
|
|
|
|
Yesod.clientSessionBackend key timeout
|
|
|
|
#endif
|
2013-06-02 19:57:22 +00:00
|
|
|
#endif
|
2012-07-26 16:41:20 +00:00
|
|
|
|
|
|
|
{- Generates a random sha512 string, suitable to be used for an
|
2012-07-26 07:38:20 +00:00
|
|
|
- authentication secret. -}
|
|
|
|
genRandomToken :: IO String
|
|
|
|
genRandomToken = do
|
|
|
|
g <- newGenIO :: IO SystemRandom
|
|
|
|
return $
|
|
|
|
case genBytes 512 g of
|
|
|
|
Left e -> error $ "failed to generate secret token: " ++ show e
|
Use cryptohash rather than SHA for hashing.
This is a massive win on OSX, which doesn't have a sha256sum normally.
Only use external hash commands when the file is > 1 mb,
since cryptohash is quite close to them in speed.
SHA is still used to calculate HMACs. I don't quite understand
cryptohash's API for those.
Used the following benchmark to arrive at the 1 mb number.
1 mb file:
benchmarking sha256/internal
mean: 13.86696 ms, lb 13.83010 ms, ub 13.93453 ms, ci 0.950
std dev: 249.3235 us, lb 162.0448 us, ub 458.1744 us, ci 0.950
found 5 outliers among 100 samples (5.0%)
4 (4.0%) high mild
1 (1.0%) high severe
variance introduced by outliers: 10.415%
variance is moderately inflated by outliers
benchmarking sha256/external
mean: 14.20670 ms, lb 14.17237 ms, ub 14.27004 ms, ci 0.950
std dev: 230.5448 us, lb 150.7310 us, ub 427.6068 us, ci 0.950
found 3 outliers among 100 samples (3.0%)
2 (2.0%) high mild
1 (1.0%) high severe
2 mb file:
benchmarking sha256/internal
mean: 26.44270 ms, lb 26.23701 ms, ub 26.63414 ms, ci 0.950
std dev: 1.012303 ms, lb 925.8921 us, ub 1.122267 ms, ci 0.950
variance introduced by outliers: 35.540%
variance is moderately inflated by outliers
benchmarking sha256/external
mean: 26.84521 ms, lb 26.77644 ms, ub 26.91433 ms, ci 0.950
std dev: 347.7867 us, lb 210.6283 us, ub 571.3351 us, ci 0.950
found 6 outliers among 100 samples (6.0%)
import Crypto.Hash
import Data.ByteString.Lazy as L
import Criterion.Main
import Common
testfile :: FilePath
testfile = "/run/shm/data" -- on ram disk
main = defaultMain
[ bgroup "sha256"
[ bench "internal" $ whnfIO internal
, bench "external" $ whnfIO external
]
]
sha256 :: L.ByteString -> Digest SHA256
sha256 = hashlazy
internal :: IO String
internal = show . sha256 <$> L.readFile testfile
external :: IO String
external = do
s <- readProcess "sha256sum" [testfile]
return $ fst $ separate (== ' ') s
2013-09-22 23:45:08 +00:00
|
|
|
Right (s, _) -> show $ sha512 $ L.fromChunks [s]
|
2012-07-26 08:50:09 +00:00
|
|
|
|
|
|
|
{- A Yesod isAuthorized method, which checks the auth cgi parameter
|
2012-07-29 16:12:14 +00:00
|
|
|
- against a token extracted from the Yesod application.
|
|
|
|
-
|
|
|
|
- Note that the usual Yesod error page is bypassed on error, to avoid
|
|
|
|
- possibly leaking the auth token in urls on that page!
|
|
|
|
-}
|
2013-06-03 20:33:05 +00:00
|
|
|
#if MIN_VERSION_yesod(1,2,0)
|
2013-06-02 19:57:22 +00:00
|
|
|
checkAuthToken :: (Monad m, Yesod.MonadHandler m) => (Yesod.HandlerSite m -> T.Text) -> m Yesod.AuthResult
|
2013-06-03 20:33:05 +00:00
|
|
|
#else
|
|
|
|
checkAuthToken :: forall t sub. (t -> T.Text) -> Yesod.GHandler sub t Yesod.AuthResult
|
|
|
|
#endif
|
2012-07-26 08:50:09 +00:00
|
|
|
checkAuthToken extractToken = do
|
2012-08-30 17:05:39 +00:00
|
|
|
webapp <- Yesod.getYesod
|
|
|
|
req <- Yesod.getRequest
|
|
|
|
let params = Yesod.reqGetParams req
|
2012-07-26 09:13:27 +00:00
|
|
|
if lookup "auth" params == Just (extractToken webapp)
|
2012-08-30 17:05:39 +00:00
|
|
|
then return Yesod.Authorized
|
|
|
|
else Yesod.sendResponseStatus unauthorized401 ()
|
2012-07-26 08:50:09 +00:00
|
|
|
|
|
|
|
{- A Yesod joinPath method, which adds an auth cgi parameter to every
|
|
|
|
- url matching a predicate, containing a token extracted from the
|
|
|
|
- Yesod application.
|
|
|
|
-
|
|
|
|
- A typical predicate would exclude files under /static.
|
|
|
|
-}
|
|
|
|
insertAuthToken :: forall y. (y -> T.Text)
|
|
|
|
-> ([T.Text] -> Bool)
|
|
|
|
-> y
|
|
|
|
-> T.Text
|
|
|
|
-> [T.Text]
|
|
|
|
-> [(T.Text, T.Text)]
|
|
|
|
-> Builder
|
|
|
|
insertAuthToken extractToken predicate webapp root pathbits params =
|
|
|
|
fromText root `mappend` encodePath pathbits' encodedparams
|
2012-12-13 04:24:19 +00:00
|
|
|
where
|
|
|
|
pathbits' = if null pathbits then [T.empty] else pathbits
|
|
|
|
encodedparams = map (TE.encodeUtf8 *** go) params'
|
|
|
|
go "" = Nothing
|
|
|
|
go x = Just $ TE.encodeUtf8 x
|
|
|
|
authparam = (T.pack "auth", extractToken webapp)
|
|
|
|
params'
|
|
|
|
| predicate pathbits = authparam:params
|
|
|
|
| otherwise = params
|
2013-01-03 22:50:30 +00:00
|
|
|
|
|
|
|
{- Creates a html shim file that's used to redirect into the webapp,
|
|
|
|
- to avoid exposing the secret token when launching the web browser. -}
|
|
|
|
writeHtmlShim :: String -> String -> FilePath -> IO ()
|
|
|
|
writeHtmlShim title url file = viaTmp writeFileProtected file $ genHtmlShim title url
|
|
|
|
|
|
|
|
{- TODO: generate this static file using Yesod. -}
|
|
|
|
genHtmlShim :: String -> String -> String
|
|
|
|
genHtmlShim title url = unlines
|
|
|
|
[ "<html>"
|
|
|
|
, "<head>"
|
|
|
|
, "<title>"++ title ++ "</title>"
|
|
|
|
, "<meta http-equiv=\"refresh\" content=\"1; URL="++url++"\">"
|
|
|
|
, "<body>"
|
|
|
|
, "<p>"
|
|
|
|
, "<a href=\"" ++ url ++ "\">" ++ title ++ "</a>"
|
|
|
|
, "</p>"
|
|
|
|
, "</body>"
|
|
|
|
, "</html>"
|
|
|
|
]
|