hackage-server-0.4: Distribution/Server/Features/Documentation.hs
{-# LANGUAGE RankNTypes, NamedFieldPuns, RecordWildCards, PatternGuards #-}
module Distribution.Server.Features.Documentation (
DocumentationFeature(..),
DocumentationResource(..),
initDocumentationFeature
) where
import Distribution.Server.Framework
import Distribution.Server.Features.Documentation.State
import Distribution.Server.Features.Upload
import Distribution.Server.Features.Core
import Distribution.Server.Features.TarIndexCache
import Distribution.Server.Framework.BackupRestore
import qualified Distribution.Server.Framework.ResponseContentTypes as Resource
import Distribution.Server.Framework.BlobStorage (BlobId)
import qualified Distribution.Server.Framework.BlobStorage as BlobStorage
import qualified Distribution.Server.Util.ServeTarball as ServerTarball
import Data.TarIndex (TarIndex)
import qualified Codec.Archive.Tar as Tar
import qualified Codec.Archive.Tar.Check as Tar
import Distribution.Text
import Distribution.Package
import Distribution.Version (Version(..))
import qualified Data.ByteString.Lazy as BSL
import qualified Data.Map as Map
import Data.Function (fix)
import Data.Aeson (toJSON)
-- TODO:
-- 1. Write an HTML view for organizing uploads
-- 2. Have cabal generate a standard doc tarball, and serve that here
data DocumentationFeature = DocumentationFeature {
documentationFeatureInterface :: HackageFeature,
queryHasDocumentation :: MonadIO m => PackageIdentifier -> m Bool,
documentationResource :: DocumentationResource
}
instance IsHackageFeature DocumentationFeature where
getFeatureInterface = documentationFeatureInterface
data DocumentationResource = DocumentationResource {
packageDocsContent :: Resource,
packageDocsWhole :: Resource,
packageDocsStats :: Resource,
packageDocsContentUri :: PackageId -> String,
packageDocsWholeUri :: String -> PackageId -> String
}
initDocumentationFeature :: String
-> ServerEnv
-> CoreResource
-> IO [PackageIdentifier]
-> UploadFeature
-> TarIndexCacheFeature
-> IO DocumentationFeature
initDocumentationFeature name
env@ServerEnv{serverStateDir, serverVerbosity = verbosity}
core
getPackages
upload
tarIndexCache = do
loginfo verbosity "Initialising documentation feature, start"
documentationState <- documentationStateComponent name serverStateDir
let feature = documentationFeature name env core getPackages upload tarIndexCache documentationState
loginfo verbosity "Initialising documentation feature, end"
return feature
documentationStateComponent :: String -> FilePath -> IO (StateComponent AcidState Documentation)
documentationStateComponent name stateDir = do
st <- openLocalStateFrom (stateDir </> "db" </> name) initialDocumentation
return StateComponent {
stateDesc = "Package documentation"
, stateHandle = st
, getState = query st GetDocumentation
, putState = update st . ReplaceDocumentation
, backupState = dumpBackup
, restoreState = updateDocumentation (Documentation Map.empty)
, resetState = documentationStateComponent name
}
where
dumpBackup doc =
let exportFunc (pkgid, blob) = BackupBlob ([display pkgid, "documentation.tar"]) blob
in map exportFunc . Map.toList $ documentation doc
updateDocumentation :: Documentation -> RestoreBackup Documentation
updateDocumentation docs = RestoreBackup {
restoreEntry = \entry ->
case entry of
BackupBlob [str, "documentation.tar"] blobId | Just pkgId <- simpleParse str -> do
docs' <- importDocumentation pkgId blobId docs
return (updateDocumentation docs')
_ ->
return (updateDocumentation docs)
, restoreFinalize = return docs
}
importDocumentation :: PackageId -> BlobId -> Documentation -> Restore Documentation
importDocumentation pkgId blobId (Documentation docs) =
return (Documentation (Map.insert pkgId blobId docs))
documentationFeature :: String
-> ServerEnv
-> CoreResource
-> IO [PackageIdentifier]
-> UploadFeature
-> TarIndexCacheFeature
-> StateComponent AcidState Documentation
-> DocumentationFeature
documentationFeature name
ServerEnv{serverBlobStore = store}
CoreResource{
packageInPath
, guardValidPackageId
, corePackagePage
, corePackagesPage
, lookupPackageId
}
getPackages
UploadFeature{..}
TarIndexCacheFeature{cachedTarIndex}
documentationState
= DocumentationFeature{..}
where
documentationFeatureInterface = (emptyHackageFeature name) {
featureDesc = "Maintain and display documentation"
, featureResources =
map ($ documentationResource) [
packageDocsContent
, packageDocsWhole
, packageDocsStats
]
, featureState = [abstractAcidStateComponent documentationState]
}
queryHasDocumentation :: MonadIO m => PackageIdentifier -> m Bool
queryHasDocumentation pkgid = queryState documentationState (HasDocumentation pkgid)
documentationResource = fix $ \r -> DocumentationResource {
packageDocsContent = (extendResourcePath "/docs/.." corePackagePage) {
resourceDesc = [ (GET, "Browse documentation") ]
, resourceGet = [ ("", serveDocumentation) ]
}
, packageDocsWhole = (extendResourcePath "/docs.:format" corePackagePage) {
resourceDesc = [ (GET, "Download documentation")
, (PUT, "Upload documentation")
]
, resourceGet = [ ("tar", serveDocumentationTar) ]
, resourcePut = [ ("tar", uploadDocumentation) ]
}
, packageDocsStats = (extendResourcePath "/docs.:format" corePackagesPage) {
resourceDesc = [ (GET, "Get information about which packages have documentation") ]
, resourceGet = [ ("json", serveDocumentationStats) ]
}
, packageDocsContentUri = \pkgid ->
renderResource (packageDocsContent r) [display pkgid]
, packageDocsWholeUri = \format pkgid ->
renderResource (packageDocsWhole r) [display pkgid, format]
}
serveDocumentationStats :: DynamicPath -> ServerPartE Response
serveDocumentationStats _dpath = do
pkgs <- mapParaM queryHasDocumentation =<< liftIO getPackages
return . toResponse . toJSON . map aux $ pkgs
where
aux :: (PackageIdentifier, Bool) -> (String, Bool)
aux (pkgId, hasDocs) = (display pkgId, hasDocs)
serveDocumentationTar :: DynamicPath -> ServerPartE Response
serveDocumentationTar dpath =
withDocumentation (packageDocsWhole documentationResource)
dpath $ \_ blob _ -> do
file <- liftIO $ BlobStorage.fetch store blob
return $ toResponse $ Resource.DocTarball file blob
-- return: not-found error or tarball
serveDocumentation :: DynamicPath -> ServerPartE Response
serveDocumentation dpath = do
withDocumentation (packageDocsContent documentationResource)
dpath $ \pkgid blob index -> do
let tarball = BlobStorage.filepath store blob
etag = blobETag blob
-- if given a directory, the default page is index.html
-- the root directory within the tarball is e.g. foo-1.0-docs/
ServerTarball.serveTarball ["index.html"] (display pkgid ++ "-docs")
tarball index etag
-- return: not-found error (parsing) or see other uri
uploadDocumentation :: DynamicPath -> ServerPartE Response
uploadDocumentation dpath = do
pkgid <- packageInPath dpath
guardValidPackageId pkgid
guardAuthorisedAsMaintainerOrTrustee (packageName pkgid)
-- The order of operations:
-- * Insert new documentation into blob store
-- * Generate the new index
-- * Drop the index for the old tar-file
-- * Link the new documentation to the package
fileContents <- expectUncompressedTarball
mres <- liftIO $ BlobStorage.addWith store fileContents
(\content -> return (checkDocTarball pkgid content))
case mres of
Left err -> errBadRequest "Invalid documentation tarball" [MText err]
Right ((), blobid) -> do
updateState documentationState $ InsertDocumentation pkgid blobid
noContent (toResponse ())
{-
To upload documentation using curl:
curl -u admin:admin \
-X PUT \
-H "Content-Type: application/x-tar" \
--data-binary @transformers-0.3.0.0-docs.tar \
http://localhost:8080/package/transformers-0.3.0.0/docs
or
curl -u admin:admin \
-X PUT \
-H "Content-Type: application/x-tar" \
-H "Content-Encoding: gzip" \
--data-binary @transformers-0.3.0.0-docs.tar.gz \
http://localhost:8080/package/transformers-0.3.0.0/docs
The tarfile is expected to have the structure
transformers-0.3.0.0-docs/index.html
..
-}
withDocumentation :: Resource -> DynamicPath
-> (PackageId -> BlobId -> TarIndex -> ServerPartE Response)
-> ServerPartE Response
withDocumentation self dpath func = do
pkgid <- packageInPath dpath
-- lookupPackageId gives us the latest version if no version is specified:
pkginfo <- lookupPackageId pkgid
case pkgVersion pkgid of
-- if no version is given we want to redirect to the latest version
Version [] _ -> tempRedirect (renderResource' self dpath') (toResponse "")
where
latest = packageId pkginfo
dpath' = [ if var == "package"
then (var, display latest)
else e
| e@(var, _) <- dpath ]
_ -> do
mdocs <- queryState documentationState $ LookupDocumentation pkgid
case mdocs of
Nothing -> errNotFound "Not Found"
[MText $ "There is no documentation for " ++ display pkgid]
Just blob -> do
index <- liftIO $ cachedTarIndex blob
func pkgid blob index
-- Check the tar file is well formed and all files are within foo-1.0-docs/
checkDocTarball :: PackageId -> BSL.ByteString -> Either String ()
checkDocTarball pkgid =
checkEntries
. fmapErr (either id show) . Tar.checkTarbomb (display pkgid ++ "-docs")
. fmapErr (either id show) . Tar.checkSecurity
. fmapErr (either id show) . Tar.checkPortability
. fmapErr show . Tar.read
where
fmapErr f = Tar.foldEntries Tar.Next Tar.Done (Tar.Fail . f)
checkEntries = Tar.foldEntries (\_ remainder -> remainder) (Right ()) Left
{------------------------------------------------------------------------------
Auxiliary
------------------------------------------------------------------------------}
mapParaM :: Monad m => (a -> m b) -> [a] -> m [(a, b)]
mapParaM f = mapM (\x -> (,) x `liftM` f x)