2026-07-17 21:43:03 +09:00
|
|
|
module Nuthatch.App
|
|
|
|
|
( Env (..)
|
|
|
|
|
, newEnv
|
|
|
|
|
, application
|
2026-07-18 13:17:40 +09:00
|
|
|
, utf8Boundary
|
|
|
|
|
, authenticationHandler
|
|
|
|
|
, loginResponse
|
2026-07-18 22:49:19 +09:00
|
|
|
, dispatchAndFinalize
|
2026-07-19 01:12:29 +09:00
|
|
|
, collectKeysBoundedWith
|
2026-07-18 23:52:45 +09:00
|
|
|
, PutFailure (..)
|
|
|
|
|
, runPut
|
|
|
|
|
, runDelete
|
2026-07-17 21:43:03 +09:00
|
|
|
, runRecovery
|
|
|
|
|
) where
|
|
|
|
|
|
|
|
|
|
import Control.Concurrent (threadDelay)
|
2026-07-18 22:49:19 +09:00
|
|
|
import Control.Concurrent.Async (mapConcurrently_)
|
|
|
|
|
import Control.Exception
|
|
|
|
|
( SomeException
|
|
|
|
|
, throwIO
|
|
|
|
|
, try
|
|
|
|
|
)
|
2026-07-19 01:12:29 +09:00
|
|
|
import Control.Monad (forM, forever, when)
|
2026-07-18 13:17:40 +09:00
|
|
|
import Control.Monad.IO.Class (liftIO)
|
2026-07-18 22:49:19 +09:00
|
|
|
import Control.Retry
|
|
|
|
|
( retrying )
|
2026-07-17 21:43:03 +09:00
|
|
|
import qualified Data.Aeson as A
|
2026-07-19 01:12:29 +09:00
|
|
|
import Data.Bifunctor (first)
|
2026-07-17 21:43:03 +09:00
|
|
|
import qualified Data.ByteString as BS
|
|
|
|
|
import qualified Data.ByteString.Lazy as BL
|
2026-07-18 13:17:40 +09:00
|
|
|
import Data.Either (isRight)
|
2026-07-19 01:12:29 +09:00
|
|
|
import Data.Maybe (fromMaybe)
|
2026-07-17 21:43:03 +09:00
|
|
|
import Data.Text (Text)
|
|
|
|
|
import qualified Data.Text as T
|
|
|
|
|
import qualified Data.Text.Encoding as TE
|
2026-07-19 01:12:29 +09:00
|
|
|
import qualified Data.Text.IO as TIO
|
|
|
|
|
import Data.Word (Word64)
|
2026-07-18 15:22:57 +09:00
|
|
|
import Database.PostgreSQL.Simple (Only (..))
|
2026-07-19 01:12:29 +09:00
|
|
|
import GHC.Clock (getMonotonicTimeNSec)
|
2026-07-17 21:43:03 +09:00
|
|
|
import Network.HTTP.Types
|
2026-07-18 13:17:40 +09:00
|
|
|
( hContentType
|
2026-07-18 18:08:41 +09:00
|
|
|
, hCookie
|
2026-07-17 21:43:03 +09:00
|
|
|
, hLocation
|
2026-07-19 01:12:29 +09:00
|
|
|
, methodGet
|
|
|
|
|
, status200
|
2026-07-17 21:43:03 +09:00
|
|
|
, status400
|
2026-07-19 01:12:29 +09:00
|
|
|
, status404
|
|
|
|
|
, status405
|
2026-07-18 13:17:40 +09:00
|
|
|
, urlDecode
|
2026-07-17 21:43:03 +09:00
|
|
|
)
|
2026-07-19 01:12:29 +09:00
|
|
|
import qualified Network.HTTP.Types.URI as URI
|
|
|
|
|
import Network.Wai (Request (..), responseFile, responseLBS)
|
2026-07-18 13:17:40 +09:00
|
|
|
import Nuthatch.Api
|
2026-07-17 21:43:03 +09:00
|
|
|
import Nuthatch.Audit (audit)
|
|
|
|
|
import Nuthatch.Config (Config (..))
|
|
|
|
|
import Nuthatch.Db (Db, ensurePersonalPool, withTransaction)
|
|
|
|
|
import qualified Nuthatch.Db as Db
|
2026-07-18 18:08:41 +09:00
|
|
|
import Nuthatch.Oidc
|
2026-07-17 21:43:03 +09:00
|
|
|
import Nuthatch.Paths
|
|
|
|
|
import Nuthatch.Protocol
|
2026-07-18 20:48:26 +09:00
|
|
|
import Nuthatch.Redis
|
|
|
|
|
( Redis
|
|
|
|
|
, cacheGet
|
|
|
|
|
, cacheInvalidatePrefixes
|
|
|
|
|
, cacheSet
|
|
|
|
|
, throttleCheck
|
|
|
|
|
, throttleTake
|
|
|
|
|
)
|
2026-07-18 22:49:19 +09:00
|
|
|
import Nuthatch.Recovery
|
|
|
|
|
( recoveryRetryPolicy
|
|
|
|
|
)
|
2026-07-17 21:43:03 +09:00
|
|
|
import Nuthatch.S3
|
|
|
|
|
( ListPage (..)
|
|
|
|
|
, ObjectInfo (..)
|
2026-07-18 20:48:26 +09:00
|
|
|
, PutObjectError (..)
|
2026-07-17 21:43:03 +09:00
|
|
|
, S3
|
2026-07-18 20:48:26 +09:00
|
|
|
, S3Error (..)
|
2026-07-17 21:43:03 +09:00
|
|
|
, deleteObject
|
2026-07-18 20:48:26 +09:00
|
|
|
, getObjectSource
|
2026-07-17 21:43:03 +09:00
|
|
|
, headObject
|
|
|
|
|
, listObjects
|
2026-07-18 23:52:45 +09:00
|
|
|
, objectStoreAvailable
|
2026-07-18 20:48:26 +09:00
|
|
|
, observeObjectFully
|
|
|
|
|
, putObjectStream
|
2026-07-18 15:22:57 +09:00
|
|
|
, renderS3Error
|
2026-07-17 21:43:03 +09:00
|
|
|
)
|
|
|
|
|
import Nuthatch.Session
|
2026-07-18 20:48:26 +09:00
|
|
|
import Nuthatch.Streaming
|
|
|
|
|
( BandwidthError (..)
|
|
|
|
|
, UploadStreamError (..)
|
|
|
|
|
)
|
2026-07-17 21:43:03 +09:00
|
|
|
import Nuthatch.Types
|
2026-07-19 01:12:29 +09:00
|
|
|
import Nuthatch.Ui (browserScript, productPage)
|
2026-07-18 13:17:40 +09:00
|
|
|
import Servant
|
|
|
|
|
import Servant.Server.Experimental.Auth
|
|
|
|
|
( AuthHandler
|
|
|
|
|
, mkAuthHandler
|
|
|
|
|
)
|
2026-07-18 20:48:26 +09:00
|
|
|
import Servant.Types.SourceT (SourceT)
|
2026-07-19 01:12:29 +09:00
|
|
|
import System.Directory (doesFileExist)
|
2026-07-18 22:49:19 +09:00
|
|
|
import System.Timeout (timeout)
|
2026-07-18 18:08:41 +09:00
|
|
|
import Web.Cookie
|
|
|
|
|
( defaultSetCookie
|
|
|
|
|
, parseCookies
|
|
|
|
|
, renderSetCookieBS
|
|
|
|
|
, sameSiteLax
|
|
|
|
|
, setCookieHttpOnly
|
|
|
|
|
, setCookieMaxAge
|
|
|
|
|
, setCookieName
|
|
|
|
|
, setCookiePath
|
|
|
|
|
, setCookieSameSite
|
|
|
|
|
, setCookieSecure
|
|
|
|
|
, setCookieValue
|
|
|
|
|
)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
data Env = Env
|
|
|
|
|
{ envConfig :: Config
|
|
|
|
|
, envDb :: Db
|
|
|
|
|
, envRedis :: Redis
|
|
|
|
|
, envS3 :: S3
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newEnv :: Config -> Db -> Redis -> S3 -> Env
|
|
|
|
|
newEnv = Env
|
|
|
|
|
|
|
|
|
|
application :: Env -> Application
|
2026-07-18 13:17:40 +09:00
|
|
|
application env =
|
|
|
|
|
utf8Boundary $
|
|
|
|
|
serveWithContext
|
|
|
|
|
api
|
|
|
|
|
(authenticationHandler (envConfig env) :. EmptyContext)
|
|
|
|
|
(server env)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
utf8Boundary :: Application -> Application
|
|
|
|
|
utf8Boundary downstream request sendResponse
|
|
|
|
|
| requestUtf8Valid request = downstream request sendResponse
|
|
|
|
|
| otherwise =
|
|
|
|
|
sendResponse $
|
|
|
|
|
responseLBS status400
|
|
|
|
|
[(hContentType, "application/json")]
|
|
|
|
|
(A.encode InvalidUtf8)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
requestUtf8Valid :: Request -> Bool
|
|
|
|
|
requestUtf8Valid request =
|
|
|
|
|
validUtf8 (urlDecode False (rawPathInfo request))
|
|
|
|
|
&& all validQueryItem (queryString request)
|
|
|
|
|
where
|
|
|
|
|
validQueryItem (name, value) =
|
|
|
|
|
validUtf8 name && maybe True validUtf8 value
|
|
|
|
|
validUtf8 = isRight . TE.decodeUtf8'
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
server :: Env -> Server Api
|
|
|
|
|
server env =
|
|
|
|
|
pure (HealthResponse True)
|
|
|
|
|
:<|> handleReady env
|
|
|
|
|
:<|> pure (BrowserScript browserScript)
|
2026-07-19 01:12:29 +09:00
|
|
|
:<|> handleAppRequest
|
|
|
|
|
:<|> staticAssetsServer
|
2026-07-18 13:17:40 +09:00
|
|
|
:<|> handleLoginRequest env
|
2026-07-18 18:08:41 +09:00
|
|
|
:<|> handleOidcCallback env
|
2026-07-18 13:17:40 +09:00
|
|
|
:<|> handleLogoutRequest env
|
|
|
|
|
:<|> handleBrowseRequest env
|
|
|
|
|
:<|> handleDownloadRequest env
|
|
|
|
|
:<|> handleUploadRequest env
|
|
|
|
|
:<|> handleDeleteRequest env
|
|
|
|
|
:<|> handleDeletePrefixRequest env
|
2026-07-19 01:12:29 +09:00
|
|
|
:<|>
|
|
|
|
|
( handleSessionRequest
|
|
|
|
|
:<|> handleBrowseRequest env
|
|
|
|
|
:<|> handleDownloadRequest env
|
|
|
|
|
:<|> handleUploadRequest env
|
|
|
|
|
:<|> handleDeleteRequest env
|
|
|
|
|
:<|> handleDeletePlanRequest env
|
|
|
|
|
:<|> handleDeleteBatchRequest env
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
handleAppRequest
|
|
|
|
|
:: SessionContext
|
|
|
|
|
-> Handler (Headers '[Header "Content-Security-Policy" Text] HtmlPage)
|
|
|
|
|
handleAppRequest _ = do
|
|
|
|
|
page <- liftIO (TIO.readFile "web/dist/index.html")
|
|
|
|
|
pure (addHeader applicationContentSecurityPolicy (HtmlPage page))
|
|
|
|
|
|
|
|
|
|
handleSessionRequest :: SessionContext -> Handler SessionResponse
|
|
|
|
|
handleSessionRequest session =
|
|
|
|
|
case sessionCsrf session of
|
|
|
|
|
Nothing -> throwOperationError err403 AuthenticationRequired
|
|
|
|
|
Just csrf ->
|
|
|
|
|
let user = sessionUser session
|
|
|
|
|
in pure SessionResponse
|
|
|
|
|
{ sessionResponseUserId = suUserId user
|
|
|
|
|
, sessionResponseDisplay = suDisplay user
|
|
|
|
|
, sessionResponseCsrf = csrf
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applicationContentSecurityPolicy :: Text
|
|
|
|
|
applicationContentSecurityPolicy =
|
|
|
|
|
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; \
|
|
|
|
|
\base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
|
|
|
|
|
|
|
|
|
staticAssetsServer :: SessionContext -> Server Raw
|
|
|
|
|
staticAssetsServer _ =
|
|
|
|
|
Tagged $ \request sendResponse ->
|
|
|
|
|
case (requestMethod request, pathInfo request) of
|
|
|
|
|
(method, [asset])
|
|
|
|
|
| method == methodGet
|
|
|
|
|
, validAssetName asset -> do
|
|
|
|
|
let path = "web/dist/assets/" <> T.unpack asset
|
|
|
|
|
exists <- doesFileExist path
|
|
|
|
|
if exists
|
|
|
|
|
then sendResponse $
|
|
|
|
|
responseFile
|
|
|
|
|
status200
|
|
|
|
|
[ (hContentType, assetContentType asset)
|
|
|
|
|
, ("X-Content-Type-Options", "nosniff")
|
|
|
|
|
, ("Cache-Control", "public, max-age=31536000, immutable")
|
|
|
|
|
]
|
|
|
|
|
path
|
|
|
|
|
Nothing
|
|
|
|
|
else sendResponse (responseLBS status404 [] "")
|
|
|
|
|
(method, _) | method /= methodGet ->
|
|
|
|
|
sendResponse (responseLBS status405 [] "")
|
|
|
|
|
_ ->
|
|
|
|
|
sendResponse (responseLBS status404 [] "")
|
|
|
|
|
where
|
|
|
|
|
validAssetName asset =
|
|
|
|
|
not (T.null asset)
|
|
|
|
|
&& T.all
|
|
|
|
|
(\character ->
|
|
|
|
|
character >= 'a' && character <= 'z'
|
|
|
|
|
|| character >= 'A' && character <= 'Z'
|
|
|
|
|
|| character >= '0' && character <= '9'
|
|
|
|
|
|| character `elem` (".-_" :: String))
|
|
|
|
|
asset
|
|
|
|
|
assetContentType asset
|
|
|
|
|
| ".js" `T.isSuffixOf` asset = "text/javascript; charset=utf-8"
|
|
|
|
|
| ".css" `T.isSuffixOf` asset = "text/css; charset=utf-8"
|
|
|
|
|
| otherwise = "application/octet-stream"
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
authenticationHandler :: Config -> AuthHandler Request SessionContext
|
|
|
|
|
authenticationHandler cfg = mkAuthHandler $ \request -> do
|
2026-07-18 18:08:41 +09:00
|
|
|
let cookies = requestCookies request
|
2026-07-18 13:17:40 +09:00
|
|
|
sessionName = TE.encodeUtf8 (cfgCookieName cfg)
|
|
|
|
|
csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg)
|
|
|
|
|
token <- maybe (throwError loginRequired) pure (lookup sessionName cookies)
|
|
|
|
|
sessionToken <- parseCookieValue validateSessionToken token
|
|
|
|
|
verified <- liftIO (verifySession cfg sessionToken)
|
|
|
|
|
user <- either (const (throwError loginRequired)) pure verified
|
|
|
|
|
csrf <- traverse
|
|
|
|
|
(parseCookieValue validateCsrfToken)
|
|
|
|
|
(lookup csrfName cookies)
|
|
|
|
|
pure SessionContext
|
|
|
|
|
{ sessionUser = user
|
|
|
|
|
, sessionCsrf = csrf
|
|
|
|
|
}
|
|
|
|
|
where
|
|
|
|
|
loginRequired = err303
|
|
|
|
|
{ errHeaders = [(hLocation, "/login")]
|
|
|
|
|
, errBody = A.encode AuthenticationRequired
|
|
|
|
|
}
|
|
|
|
|
parseCookieValue validate bytes =
|
|
|
|
|
case TE.decodeUtf8' bytes of
|
|
|
|
|
Left _ -> throwOperationError err400 MalformedAuthentication
|
|
|
|
|
Right value ->
|
|
|
|
|
either
|
|
|
|
|
(const (throwOperationError err400 MalformedAuthentication))
|
|
|
|
|
pure
|
|
|
|
|
(validate value)
|
|
|
|
|
|
|
|
|
|
handleReady :: Env -> Handler ReadyResponse
|
|
|
|
|
handleReady env = do
|
|
|
|
|
pg <- liftIO $
|
|
|
|
|
try (Db.query_ (envDb env) "SELECT 1") :: Handler (Either SomeException [Only Int])
|
|
|
|
|
case pg of
|
|
|
|
|
Right _ -> pure (ReadyResponse True)
|
|
|
|
|
Left _ -> throwOperationError err503 ReadyDependencyUnavailable
|
|
|
|
|
|
|
|
|
|
handleLoginRequest
|
2026-07-17 21:43:03 +09:00
|
|
|
:: Env
|
2026-07-18 18:08:41 +09:00
|
|
|
-> Handler LoginStart
|
|
|
|
|
handleLoginRequest env = do
|
|
|
|
|
let cfg = envConfig env
|
|
|
|
|
authorizationResult <- liftIO (beginAuthorization cfg)
|
|
|
|
|
authorization <- either
|
|
|
|
|
(const (throwOperationError err502 OidcAuthorizationFailed))
|
|
|
|
|
pure
|
|
|
|
|
authorizationResult
|
|
|
|
|
pure $
|
|
|
|
|
addHeader
|
|
|
|
|
(cookieValue
|
|
|
|
|
(cfgOidcTransactionCookieName cfg)
|
|
|
|
|
(authorizationTransaction authorization)
|
|
|
|
|
"/oidc/callback"
|
|
|
|
|
True) $
|
|
|
|
|
addHeader (authorizationLocation authorization) NoContent
|
|
|
|
|
|
|
|
|
|
handleOidcCallback
|
|
|
|
|
:: Env
|
|
|
|
|
-> Text
|
|
|
|
|
-> Text
|
|
|
|
|
-> Text
|
2026-07-18 13:17:40 +09:00
|
|
|
-> Handler LoginSuccess
|
2026-07-18 18:08:41 +09:00
|
|
|
handleOidcCallback env code state cookieHeader = do
|
|
|
|
|
let cfg = envConfig env
|
|
|
|
|
cookies = parseCookies (TE.encodeUtf8 cookieHeader)
|
|
|
|
|
transactionName = TE.encodeUtf8 (cfgOidcTransactionCookieName cfg)
|
|
|
|
|
transaction <- maybe
|
|
|
|
|
(throwOperationError err400 OidcCallbackRejected)
|
|
|
|
|
(parseCookieValueText OidcCallbackRejected)
|
|
|
|
|
(lookup transactionName cookies)
|
|
|
|
|
identityResult <- liftIO (redeemAuthorizationCode cfg transaction state code)
|
|
|
|
|
identity <- case identityResult of
|
|
|
|
|
Left reason -> do
|
|
|
|
|
liftIO $ audit "login" "rejected" Nothing [("reason", A.String reason)]
|
|
|
|
|
throwOperationError err400 OidcCallbackRejected
|
|
|
|
|
Right accepted -> pure accepted
|
|
|
|
|
let uid = encodeUserId (identityIssuer identity) (identitySubject identity)
|
2026-07-18 13:17:40 +09:00
|
|
|
liftIO (ensurePersonalPool (envDb env) cfg uid)
|
2026-07-18 18:08:41 +09:00
|
|
|
tokenResult <- liftIO $
|
|
|
|
|
mintSession
|
|
|
|
|
cfg
|
|
|
|
|
(identityIssuer identity)
|
|
|
|
|
(identitySubject identity)
|
|
|
|
|
(identityDisplay identity)
|
2026-07-18 13:17:40 +09:00
|
|
|
token <- either
|
2026-07-18 18:08:41 +09:00
|
|
|
(const (throwOperationError err500 (SessionCreationFailed "session creation failed")))
|
2026-07-18 13:17:40 +09:00
|
|
|
pure
|
|
|
|
|
tokenResult
|
|
|
|
|
csrf <- liftIO newCsrfToken
|
|
|
|
|
liftIO $ audit "login" "ok" (Just (unUserId uid)) []
|
2026-07-18 18:08:41 +09:00
|
|
|
pure (loginResponse cfg token csrf)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 18:08:41 +09:00
|
|
|
loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess
|
|
|
|
|
loginResponse cfg token csrf =
|
|
|
|
|
addHeader (cookieValue (cfgCookieName cfg) (unSessionToken token) "/" True) $
|
|
|
|
|
addHeader (cookieValue (cfgCsrfCookieName cfg) (unCsrfToken csrf) "/" False) $
|
|
|
|
|
addHeader
|
|
|
|
|
(expiredCookieValue
|
|
|
|
|
(cfgOidcTransactionCookieName cfg)
|
|
|
|
|
"/oidc/callback") $
|
2026-07-19 01:12:29 +09:00
|
|
|
addHeader "/app" NoContent
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
handleLogoutRequest
|
2026-07-17 21:43:03 +09:00
|
|
|
:: Env
|
2026-07-18 13:17:40 +09:00
|
|
|
-> SessionContext
|
|
|
|
|
-> CsrfToken
|
|
|
|
|
-> Handler LogoutSuccess
|
|
|
|
|
handleLogoutRequest env session providedCsrf = do
|
|
|
|
|
requireCsrf LogoutCsrfRejected session providedCsrf
|
|
|
|
|
let cfg = envConfig env
|
|
|
|
|
pure $
|
2026-07-18 18:08:41 +09:00
|
|
|
addHeader (expiredCookieValue (cfgCookieName cfg) "/") $
|
|
|
|
|
addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $
|
|
|
|
|
addHeader
|
|
|
|
|
(expiredCookieValue
|
|
|
|
|
(cfgOidcTransactionCookieName cfg)
|
|
|
|
|
"/oidc/callback")
|
|
|
|
|
NoContent
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
handleBrowseRequest
|
2026-07-17 21:43:03 +09:00
|
|
|
:: Env
|
2026-07-18 13:17:40 +09:00
|
|
|
-> SessionContext
|
|
|
|
|
-> Maybe Root
|
|
|
|
|
-> Maybe BrowsePrefix
|
|
|
|
|
-> Maybe SearchQuery
|
|
|
|
|
-> Maybe PaginationToken
|
|
|
|
|
-> Handler BrowseView
|
2026-07-19 01:12:29 +09:00
|
|
|
handleBrowseRequest env session selectedRoot selectedPrefix search continuation = do
|
2026-07-18 13:17:40 +09:00
|
|
|
let user = sessionUser session
|
|
|
|
|
root = fromMaybe Personal selectedRoot
|
|
|
|
|
prefix = maybe "" browsePrefixText selectedPrefix
|
|
|
|
|
liftIO (ensurePersonalPool (envDb env) (envConfig env) (suUserId user))
|
|
|
|
|
result <- liftIO $ case search of
|
2026-07-19 01:12:29 +09:00
|
|
|
Nothing -> do
|
|
|
|
|
listed <- listEntries env user root prefix
|
|
|
|
|
(unPaginationToken <$> continuation)
|
|
|
|
|
pure (fmap (\(entries, next) -> (entries, next, False)) listed)
|
|
|
|
|
Just query -> do
|
|
|
|
|
found <- searchEntries env user root (unSearchQuery query)
|
|
|
|
|
pure (fmap (\(entries, partial) -> (entries, Nothing, partial)) found)
|
|
|
|
|
(entries, nextContinuation, partial) <- either
|
2026-07-18 13:17:40 +09:00
|
|
|
(throwOperationError status502Error . BrowseObjectStoreFailed)
|
|
|
|
|
pure
|
|
|
|
|
result
|
|
|
|
|
let response = BrowseResponse
|
|
|
|
|
{ browseEntries = entries
|
2026-07-19 01:12:29 +09:00
|
|
|
, browseContinuation = PaginationToken <$> nextContinuation
|
|
|
|
|
, browsePartial = partial
|
2026-07-18 13:17:40 +09:00
|
|
|
}
|
|
|
|
|
pure BrowseView
|
2026-07-19 01:12:29 +09:00
|
|
|
{ browseViewHtml = HtmlPage (productPage entries)
|
2026-07-18 13:17:40 +09:00
|
|
|
, browseViewJson = response
|
|
|
|
|
}
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
handleDownloadRequest
|
2026-07-17 21:43:03 +09:00
|
|
|
:: Env
|
2026-07-18 13:17:40 +09:00
|
|
|
-> SessionContext
|
|
|
|
|
-> Root
|
|
|
|
|
-> RelativePath
|
2026-07-19 01:12:29 +09:00
|
|
|
-> Maybe Text
|
2026-07-18 13:17:40 +09:00
|
|
|
-> Handler DownloadSuccess
|
2026-07-19 01:12:29 +09:00
|
|
|
handleDownloadRequest env session root path requestedRange = do
|
|
|
|
|
when (requestedRange /= Nothing) $
|
|
|
|
|
throwOperationError err416 DownloadRangeUnsupported
|
2026-07-18 13:17:40 +09:00
|
|
|
let user = sessionUser session
|
|
|
|
|
key = buildObjectKey root (suUserId user) path
|
|
|
|
|
metadata <- liftIO (headObject (envS3 env) key)
|
|
|
|
|
info <- case metadata of
|
|
|
|
|
Left message -> throwOperationError status502Error
|
2026-07-18 15:22:57 +09:00
|
|
|
(DownloadObjectStoreFailed (renderS3Error message))
|
2026-07-18 13:17:40 +09:00
|
|
|
Right Nothing -> throwOperationError err404 DownloadNotFound
|
|
|
|
|
Right (Just value) -> pure value
|
2026-07-18 20:48:26 +09:00
|
|
|
requireDownloadBandwidth env user (oiSize info)
|
|
|
|
|
let inspect = inspectBandwidth env user
|
|
|
|
|
body = getObjectSource (envS3 env) key (transferPieceSize env) inspect
|
2026-07-18 13:17:40 +09:00
|
|
|
liftIO $ audit "download" "ok" (Just (unUserId (suUserId user)))
|
|
|
|
|
[ ("root", A.toJSON (rootLabel root))
|
|
|
|
|
, ("bytes", A.toJSON (oiSize info))
|
|
|
|
|
]
|
2026-07-19 01:12:29 +09:00
|
|
|
let filename = basename path
|
2026-07-18 13:17:40 +09:00
|
|
|
pure $
|
2026-07-19 01:12:29 +09:00
|
|
|
addHeader (contentDisposition filename) $
|
2026-07-18 20:48:26 +09:00
|
|
|
addHeader "nosniff" $
|
2026-07-19 01:12:29 +09:00
|
|
|
addHeader "sandbox" $
|
|
|
|
|
addHeader (oiSize info) body
|
2026-07-18 13:17:40 +09:00
|
|
|
|
|
|
|
|
handleUploadRequest
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionContext
|
|
|
|
|
-> Root
|
|
|
|
|
-> RelativePath
|
|
|
|
|
-> OperationId
|
|
|
|
|
-> DeclaredSize
|
|
|
|
|
-> ContentSha256
|
|
|
|
|
-> CsrfToken
|
|
|
|
|
-> SourceIO BS.ByteString
|
|
|
|
|
-> Handler UploadResponse
|
|
|
|
|
handleUploadRequest env session root path operation declaredSize contentIdentity providedCsrf bodySource = do
|
|
|
|
|
requireCsrf UploadCsrfRejected session providedCsrf
|
|
|
|
|
let user = sessionUser session
|
|
|
|
|
size = unDeclaredSize declaredSize
|
|
|
|
|
configuredMaximum = cfgMaxObjectBytes (envConfig env)
|
|
|
|
|
when (size > configuredMaximum) $
|
|
|
|
|
throwOperationError err413 UploadTooLarge
|
2026-07-18 20:48:26 +09:00
|
|
|
requireUploadBandwidth env user size
|
|
|
|
|
outcome <- liftIO $
|
|
|
|
|
runPut env user root path operation size contentIdentity bodySource
|
2026-07-18 13:17:40 +09:00
|
|
|
result <- either
|
2026-07-18 20:48:26 +09:00
|
|
|
throwPutFailure
|
2026-07-18 13:17:40 +09:00
|
|
|
pure
|
|
|
|
|
outcome
|
|
|
|
|
liftIO $ audit "put" (mutationAuditResult result) (Just (unUserId (suUserId user)))
|
|
|
|
|
[ ("root", A.toJSON (rootLabel root))
|
|
|
|
|
, ("bytes", A.toJSON size)
|
|
|
|
|
, ("operation_id", A.toJSON (unOperationId operation))
|
|
|
|
|
]
|
|
|
|
|
pure UploadResponse
|
|
|
|
|
{ uploadResponseOperationId = operation
|
|
|
|
|
, uploadResponseResult = result
|
2026-07-18 21:25:50 +09:00
|
|
|
, uploadResponseIdempotencyWindowSeconds =
|
|
|
|
|
cfgIdempotencyWindowSec (envConfig env)
|
2026-07-18 13:17:40 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:48:26 +09:00
|
|
|
requireDownloadBandwidth :: Env -> SessionUser -> Integer -> Handler ()
|
|
|
|
|
requireDownloadBandwidth env user size = do
|
|
|
|
|
available <- liftIO $
|
|
|
|
|
throttleCheck
|
|
|
|
|
(envRedis env)
|
|
|
|
|
(envConfig env)
|
|
|
|
|
(suUserId user)
|
|
|
|
|
(min size (toInteger (transferPieceSize env)))
|
|
|
|
|
case available of
|
|
|
|
|
Left _ -> throwOperationError err503 DownloadThrottleUnavailable
|
|
|
|
|
Right False -> throwOperationError err429 DownloadBandwidthExceeded
|
|
|
|
|
Right True -> pure ()
|
|
|
|
|
|
|
|
|
|
requireUploadBandwidth :: Env -> SessionUser -> Integer -> Handler ()
|
|
|
|
|
requireUploadBandwidth env user size = do
|
|
|
|
|
available <- liftIO $
|
|
|
|
|
throttleCheck
|
|
|
|
|
(envRedis env)
|
|
|
|
|
(envConfig env)
|
|
|
|
|
(suUserId user)
|
|
|
|
|
(min size (toInteger (transferPieceSize env)))
|
|
|
|
|
case available of
|
|
|
|
|
Left _ -> throwOperationError err503 UploadThrottleUnavailable
|
|
|
|
|
Right False -> throwOperationError err429 UploadBandwidthExceeded
|
|
|
|
|
Right True -> pure ()
|
|
|
|
|
|
|
|
|
|
inspectBandwidth :: Env -> SessionUser -> BS.ByteString -> IO ()
|
|
|
|
|
inspectBandwidth env user chunk = do
|
|
|
|
|
result <- throttleTake
|
|
|
|
|
(envRedis env)
|
|
|
|
|
(envConfig env)
|
|
|
|
|
(suUserId user)
|
|
|
|
|
(toInteger (BS.length chunk))
|
|
|
|
|
case result of
|
|
|
|
|
Left _ -> throwIO BandwidthUnavailable
|
|
|
|
|
Right False -> throwIO BandwidthExceeded
|
|
|
|
|
Right True -> pure ()
|
|
|
|
|
|
|
|
|
|
transferPieceSize :: Env -> Int
|
|
|
|
|
transferPieceSize env =
|
|
|
|
|
fromInteger (max 1 (min 65536 (cfgThrottleBurstBytes (envConfig env))))
|
|
|
|
|
|
2026-07-18 13:17:40 +09:00
|
|
|
handleDeleteRequest
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionContext
|
|
|
|
|
-> DeleteRequest
|
|
|
|
|
-> Handler DeleteResponse
|
|
|
|
|
handleDeleteRequest env session request = do
|
|
|
|
|
requireCsrf DeleteCsrfRejected session (deleteCsrf request)
|
|
|
|
|
let user = sessionUser session
|
|
|
|
|
operation = deleteOperationId request
|
|
|
|
|
root = deleteRoot request
|
|
|
|
|
outcome <- liftIO $
|
|
|
|
|
runDelete env user root (deletePath request) operation
|
|
|
|
|
result <- either
|
|
|
|
|
(throwOperationError err409 . DeleteMutationRejected)
|
|
|
|
|
pure
|
|
|
|
|
outcome
|
|
|
|
|
liftIO $ audit "delete" (mutationAuditResult result)
|
|
|
|
|
(Just (unUserId (suUserId user)))
|
|
|
|
|
[ ("root", A.toJSON (rootLabel root))
|
|
|
|
|
, ("operation_id", A.toJSON (unOperationId operation))
|
|
|
|
|
]
|
|
|
|
|
pure DeleteResponse
|
|
|
|
|
{ deleteResponseOperationId = operation
|
|
|
|
|
, deleteResponseResult = result
|
2026-07-18 21:25:50 +09:00
|
|
|
, deleteResponseIdempotencyWindowSeconds =
|
|
|
|
|
cfgIdempotencyWindowSec (envConfig env)
|
2026-07-18 13:17:40 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleDeletePrefixRequest
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionContext
|
|
|
|
|
-> DeletePrefixRequest
|
|
|
|
|
-> Handler DeletePrefixResponse
|
2026-07-19 01:12:29 +09:00
|
|
|
handleDeletePrefixRequest _env session request = do
|
2026-07-18 13:17:40 +09:00
|
|
|
requireCsrf DeletePrefixCsrfRejected session (deletePrefixCsrf request)
|
2026-07-19 01:12:29 +09:00
|
|
|
throwOperationError err409
|
|
|
|
|
(DeletePrefixMutationRejected
|
|
|
|
|
"use delete-plan and delete-batch with client operation ids")
|
|
|
|
|
|
|
|
|
|
handleDeletePlanRequest
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionContext
|
|
|
|
|
-> Root
|
|
|
|
|
-> RelativePath
|
|
|
|
|
-> Maybe PaginationToken
|
|
|
|
|
-> Handler DeletePlanResponse
|
|
|
|
|
handleDeletePlanRequest env session root path continuation = do
|
|
|
|
|
let user = sessionUser session
|
|
|
|
|
rootPrefix = case root of
|
|
|
|
|
Personal -> "users/" <> unUserId (suUserId user) <> "/"
|
|
|
|
|
Shared -> "shared/"
|
|
|
|
|
objectPrefix = rootPrefix <> unRelativePath path <> "/"
|
|
|
|
|
listed <- liftIO $
|
|
|
|
|
listObjects
|
|
|
|
|
(envS3 env)
|
|
|
|
|
objectPrefix
|
|
|
|
|
Nothing
|
|
|
|
|
(unPaginationToken <$> continuation)
|
|
|
|
|
(cfgDeletePageSize (envConfig env))
|
|
|
|
|
page <- either
|
|
|
|
|
(throwOperationError status502Error
|
|
|
|
|
. DeletePrefixMutationRejected
|
|
|
|
|
. renderS3Error)
|
|
|
|
|
pure
|
|
|
|
|
listed
|
|
|
|
|
paths <- either
|
2026-07-18 13:17:40 +09:00
|
|
|
(throwOperationError err409 . DeletePrefixMutationRejected)
|
|
|
|
|
pure
|
2026-07-19 01:12:29 +09:00
|
|
|
(traverse
|
|
|
|
|
(validateRelativePath . T.drop (T.length rootPrefix) . fst)
|
|
|
|
|
(lpObjects page))
|
|
|
|
|
pure DeletePlanResponse
|
|
|
|
|
{ deletePlanPaths = paths
|
|
|
|
|
, deletePlanContinuation = PaginationToken <$> lpContinuationToken page
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleDeleteBatchRequest
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionContext
|
|
|
|
|
-> DeleteBatchRequest
|
|
|
|
|
-> Handler DeleteBatchResponse
|
|
|
|
|
handleDeleteBatchRequest env session request = do
|
|
|
|
|
requireCsrf DeletePrefixCsrfRejected session (deleteBatchCsrf request)
|
|
|
|
|
let items = deleteBatchItems request
|
|
|
|
|
when (null items || length items > cfgDeletePageSize (envConfig env)) $
|
|
|
|
|
throwOperationError err409
|
|
|
|
|
(DeletePrefixMutationRejected "delete batch size is invalid")
|
|
|
|
|
results <- liftIO $ forM items $ \item -> do
|
|
|
|
|
outcome <- runDelete
|
|
|
|
|
env
|
|
|
|
|
(sessionUser session)
|
|
|
|
|
(deleteBatchRoot request)
|
|
|
|
|
(deleteBatchPath item)
|
|
|
|
|
(deleteBatchOperationId item)
|
|
|
|
|
pure $ case outcome of
|
|
|
|
|
Left message -> DeleteItemResponse
|
|
|
|
|
{ deleteItemPath = deleteBatchPath item
|
|
|
|
|
, deleteItemOperationId = deleteBatchOperationId item
|
|
|
|
|
, deleteItemResult = Nothing
|
|
|
|
|
, deleteItemError = Just message
|
|
|
|
|
}
|
|
|
|
|
Right result -> DeleteItemResponse
|
|
|
|
|
{ deleteItemPath = deleteBatchPath item
|
|
|
|
|
, deleteItemOperationId = deleteBatchOperationId item
|
|
|
|
|
, deleteItemResult = Just result
|
|
|
|
|
, deleteItemError = Nothing
|
|
|
|
|
}
|
|
|
|
|
pure (DeleteBatchResponse results)
|
2026-07-18 13:17:40 +09:00
|
|
|
|
|
|
|
|
requireCsrf :: A.ToJSON error => error -> SessionContext -> CsrfToken -> Handler ()
|
|
|
|
|
requireCsrf csrfError session provided =
|
|
|
|
|
case sessionCsrf session of
|
|
|
|
|
Just expected | constantTimeEq expected provided -> pure ()
|
|
|
|
|
_ -> throwOperationError err403 csrfError
|
|
|
|
|
|
|
|
|
|
throwOperationError :: A.ToJSON error => ServerError -> error -> Handler a
|
|
|
|
|
throwOperationError serverError operationError =
|
|
|
|
|
throwError serverError
|
|
|
|
|
{ errBody = A.encode operationError
|
|
|
|
|
, errHeaders = ("Content-Type", "application/json") : errHeaders serverError
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
status502Error :: ServerError
|
|
|
|
|
status502Error = ServerError
|
|
|
|
|
{ errHTTPCode = 502
|
|
|
|
|
, errReasonPhrase = "Bad Gateway"
|
|
|
|
|
, errBody = ""
|
|
|
|
|
, errHeaders = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
browsePrefixText :: BrowsePrefix -> Text
|
|
|
|
|
browsePrefixText (BrowsePrefix Nothing) = ""
|
|
|
|
|
browsePrefixText (BrowsePrefix (Just path)) = unRelativePath path
|
|
|
|
|
|
2026-07-18 18:08:41 +09:00
|
|
|
cookieValue :: Text -> Text -> BS.ByteString -> Bool -> Text
|
|
|
|
|
cookieValue name value path httpOnly =
|
|
|
|
|
TE.decodeUtf8 $
|
|
|
|
|
renderSetCookieBS $
|
|
|
|
|
defaultSetCookie
|
|
|
|
|
{ setCookieName = TE.encodeUtf8 name
|
|
|
|
|
, setCookieValue = TE.encodeUtf8 value
|
|
|
|
|
, setCookiePath = Just path
|
|
|
|
|
, setCookieHttpOnly = httpOnly
|
|
|
|
|
, setCookieSecure = True
|
|
|
|
|
, setCookieSameSite = Just sameSiteLax
|
|
|
|
|
}
|
2026-07-18 13:17:40 +09:00
|
|
|
|
2026-07-18 18:08:41 +09:00
|
|
|
expiredCookieValue :: Text -> BS.ByteString -> Text
|
|
|
|
|
expiredCookieValue name path =
|
|
|
|
|
TE.decodeUtf8 $
|
|
|
|
|
renderSetCookieBS $
|
|
|
|
|
defaultSetCookie
|
|
|
|
|
{ setCookieName = TE.encodeUtf8 name
|
|
|
|
|
, setCookieValue = ""
|
|
|
|
|
, setCookiePath = Just path
|
|
|
|
|
, setCookieMaxAge = Just 0
|
|
|
|
|
, setCookieHttpOnly = True
|
|
|
|
|
, setCookieSecure = True
|
|
|
|
|
, setCookieSameSite = Just sameSiteLax
|
|
|
|
|
}
|
2026-07-18 13:17:40 +09:00
|
|
|
|
|
|
|
|
mutationAuditResult :: MutationResult -> Text
|
|
|
|
|
mutationAuditResult MutationSucceeded = "ok"
|
|
|
|
|
mutationAuditResult MutationPending = "pending"
|
|
|
|
|
mutationAuditResult MutationFailed = "failed"
|
|
|
|
|
mutationAuditResult MutationRejected = "rejected"
|
|
|
|
|
mutationAuditResult MutationConflict = "conflict"
|
|
|
|
|
|
2026-07-19 01:12:29 +09:00
|
|
|
contentDisposition :: Text -> Text
|
|
|
|
|
contentDisposition filename =
|
|
|
|
|
"attachment; filename=\"" <> fallback <> "\"; filename*=UTF-8''" <> encoded
|
|
|
|
|
where
|
|
|
|
|
fallback = T.map safeAscii filename
|
|
|
|
|
encoded = TE.decodeUtf8 (URI.urlEncode True (TE.encodeUtf8 filename))
|
|
|
|
|
safeAscii character
|
|
|
|
|
| character >= ' ' && character <= '~'
|
|
|
|
|
&& character `notElem` ("\"\\;" :: String) = character
|
|
|
|
|
| otherwise = '_'
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
runPut
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionUser
|
|
|
|
|
-> Root
|
|
|
|
|
-> RelativePath
|
|
|
|
|
-> OperationId
|
|
|
|
|
-> Integer
|
|
|
|
|
-> ContentSha256
|
2026-07-18 20:48:26 +09:00
|
|
|
-> SourceT IO BS.ByteString
|
|
|
|
|
-> IO (Either PutFailure MutationResult)
|
2026-07-17 21:43:03 +09:00
|
|
|
runPut env user root rel op size sha body = do
|
|
|
|
|
ensurePersonalPool (envDb env) (envConfig env) (suUserId user)
|
|
|
|
|
let uid = suUserId user
|
|
|
|
|
key = buildObjectKey root uid rel
|
|
|
|
|
pool = case root of
|
|
|
|
|
Personal -> personalPool uid
|
|
|
|
|
Shared -> sharedPool
|
2026-07-18 23:52:45 +09:00
|
|
|
admitted <- withTransaction (envDb env) $ \conn -> do
|
|
|
|
|
existing <- mutationEpochExists
|
|
|
|
|
conn uid Put op (cfgIdempotencyWindowSec (envConfig env))
|
|
|
|
|
available <-
|
|
|
|
|
if existing then pure True else objectStoreAvailable (envS3 env)
|
|
|
|
|
if available
|
|
|
|
|
then Just <$> admitMutation conn uid Put op pool key rel size (Just sha)
|
|
|
|
|
(cfgMaxObjectBytes (envConfig env))
|
|
|
|
|
(cfgIdempotencyWindowSec (envConfig env))
|
|
|
|
|
else pure Nothing
|
|
|
|
|
case admitted of
|
|
|
|
|
Nothing ->
|
|
|
|
|
pure (Left (PutDispatchFailed (PutObjectStoreError S3TransportError)))
|
|
|
|
|
Just outcome ->
|
|
|
|
|
case outcome of
|
|
|
|
|
AdmitConflict -> pure (Right MutationConflict)
|
2026-07-19 01:12:29 +09:00
|
|
|
AdmitRejected _ _ -> pure (Right MutationRejected)
|
2026-07-18 23:52:45 +09:00
|
|
|
AdmitReplay MutationPending _ -> pure (Right MutationPending)
|
|
|
|
|
AdmitReplay result _ -> pure (Right result)
|
|
|
|
|
AdmitPrepared rec ->
|
|
|
|
|
dispatchAndFinalize
|
|
|
|
|
env
|
|
|
|
|
user
|
|
|
|
|
root
|
|
|
|
|
rec
|
|
|
|
|
(PutDispatchFailed (PutObjectStoreError S3TransportError))
|
|
|
|
|
(putObjectStream
|
|
|
|
|
(envS3 env)
|
|
|
|
|
key
|
|
|
|
|
size
|
|
|
|
|
(Just sha)
|
|
|
|
|
(transferPieceSize env)
|
|
|
|
|
(inspectBandwidth env user)
|
|
|
|
|
body
|
|
|
|
|
>>= pure . either (Left . PutDispatchFailed) Right)
|
2026-07-18 20:48:26 +09:00
|
|
|
|
2026-07-19 01:12:29 +09:00
|
|
|
newtype PutFailure
|
|
|
|
|
= PutDispatchFailed PutObjectError
|
2026-07-18 20:48:26 +09:00
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
throwPutFailure :: PutFailure -> Handler a
|
|
|
|
|
throwPutFailure failure =
|
|
|
|
|
case failure of
|
|
|
|
|
PutDispatchFailed (PutObjectStreamError streamError) ->
|
|
|
|
|
case streamError of
|
|
|
|
|
UploadStreamInvalid message ->
|
|
|
|
|
throwOperationError err400 (UploadInvalidBodyStream (T.pack message))
|
|
|
|
|
UploadStreamSizeMismatch ->
|
|
|
|
|
throwOperationError err400 UploadSizeMismatch
|
|
|
|
|
UploadStreamIdentityMismatch ->
|
|
|
|
|
throwOperationError err400 UploadContentIdentityMismatch
|
|
|
|
|
PutDispatchFailed (PutObjectBandwidthError bandwidthError) ->
|
|
|
|
|
case bandwidthError of
|
|
|
|
|
BandwidthUnavailable ->
|
|
|
|
|
throwOperationError err503 UploadThrottleUnavailable
|
|
|
|
|
BandwidthExceeded ->
|
|
|
|
|
throwOperationError err429 UploadBandwidthExceeded
|
|
|
|
|
PutDispatchFailed (PutObjectStoreError err) ->
|
|
|
|
|
throwOperationError err409
|
|
|
|
|
(UploadMutationRejected (renderS3Error err))
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
runDelete
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionUser
|
|
|
|
|
-> Root
|
|
|
|
|
-> RelativePath
|
|
|
|
|
-> OperationId
|
|
|
|
|
-> IO (Either Text MutationResult)
|
|
|
|
|
runDelete env user root rel op = do
|
|
|
|
|
ensurePersonalPool (envDb env) (envConfig env) (suUserId user)
|
|
|
|
|
let uid = suUserId user
|
|
|
|
|
key = buildObjectKey root uid rel
|
|
|
|
|
pool = case root of
|
|
|
|
|
Personal -> personalPool uid
|
|
|
|
|
Shared -> sharedPool
|
2026-07-18 23:52:45 +09:00
|
|
|
admitted <- withTransaction (envDb env) $ \conn -> do
|
|
|
|
|
existing <- mutationEpochExists
|
|
|
|
|
conn uid Delete op (cfgIdempotencyWindowSec (envConfig env))
|
|
|
|
|
available <-
|
|
|
|
|
if existing then pure True else objectStoreAvailable (envS3 env)
|
|
|
|
|
if available
|
|
|
|
|
then Just <$> admitMutation conn uid Delete op pool key rel 0 Nothing
|
|
|
|
|
(cfgMaxObjectBytes (envConfig env))
|
|
|
|
|
(cfgIdempotencyWindowSec (envConfig env))
|
|
|
|
|
else pure Nothing
|
|
|
|
|
case admitted of
|
|
|
|
|
Nothing -> pure (Left "object store unavailable")
|
|
|
|
|
Just outcome ->
|
|
|
|
|
case outcome of
|
|
|
|
|
AdmitConflict -> pure (Right MutationConflict)
|
2026-07-19 01:12:29 +09:00
|
|
|
AdmitRejected _ _ -> pure (Right MutationRejected)
|
2026-07-18 23:52:45 +09:00
|
|
|
AdmitReplay MutationPending _ -> pure (Right MutationPending)
|
|
|
|
|
AdmitReplay result _ -> pure (Right result)
|
|
|
|
|
AdmitPrepared rec ->
|
|
|
|
|
dispatchAndFinalize
|
|
|
|
|
env
|
|
|
|
|
user
|
|
|
|
|
root
|
|
|
|
|
rec
|
|
|
|
|
"object request timed out"
|
|
|
|
|
(deleteObject (envS3 env) key
|
|
|
|
|
>>= pure . either (Left . renderS3Error) Right)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
dispatchAndFinalize
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionUser
|
|
|
|
|
-> Root
|
|
|
|
|
-> MutationRecord
|
2026-07-18 20:48:26 +09:00
|
|
|
-> failure
|
|
|
|
|
-> IO (Either failure ())
|
|
|
|
|
-> IO (Either failure MutationResult)
|
2026-07-19 01:12:29 +09:00
|
|
|
dispatchAndFinalize env user root rec timeoutFailure dispatch = do
|
2026-07-18 22:49:19 +09:00
|
|
|
let cfg = envConfig env
|
|
|
|
|
requestTimeoutSec = cfgObjectRequestTimeoutSec cfg
|
2026-07-17 21:43:03 +09:00
|
|
|
prepared <- withTransaction (envDb env) $ \conn -> do
|
|
|
|
|
moved <- markApplying conn rec
|
|
|
|
|
if not moved
|
|
|
|
|
then pure False
|
2026-07-18 22:49:19 +09:00
|
|
|
else markExternalPending
|
|
|
|
|
conn
|
|
|
|
|
rec
|
|
|
|
|
(requestTimeoutSec * 2)
|
|
|
|
|
(cfgRecoveryGraceSec cfg)
|
2026-07-17 21:43:03 +09:00
|
|
|
if not prepared
|
|
|
|
|
then pure (Right MutationPending)
|
|
|
|
|
else do
|
2026-07-18 22:49:19 +09:00
|
|
|
dispatchOutcome <- timeout
|
|
|
|
|
(fromInteger (requestTimeoutSec * 1000000))
|
|
|
|
|
dispatch
|
|
|
|
|
case dispatchOutcome of
|
|
|
|
|
Nothing -> pure (Left timeoutFailure)
|
|
|
|
|
Just (Left dispatchFailure) -> pure (Left dispatchFailure)
|
|
|
|
|
Just (Right ()) -> do
|
|
|
|
|
settled <- withTransaction (envDb env) $ \conn ->
|
|
|
|
|
markExternalSettled conn rec
|
|
|
|
|
if not settled
|
|
|
|
|
then pure (Right MutationPending)
|
|
|
|
|
else do
|
|
|
|
|
observed <- observeObject (envS3 env) (mrObjectKey rec)
|
|
|
|
|
finalized <- withTransaction (envDb env) $ \conn ->
|
|
|
|
|
finalizeFromObservation conn rec observed
|
|
|
|
|
case finalized of
|
|
|
|
|
Just MutationSucceeded -> do
|
|
|
|
|
cacheInvalidatePrefixes (envRedis env) (suUserId user) root
|
|
|
|
|
(listCacheAncestorPrefixes (mrRelativePath rec))
|
|
|
|
|
pure (Right MutationSucceeded)
|
2026-07-19 01:12:29 +09:00
|
|
|
Just MutationFailed -> pure (Right MutationFailed)
|
2026-07-18 22:49:19 +09:00
|
|
|
Just other -> pure (Right other)
|
|
|
|
|
Nothing -> pure (Right MutationPending)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
observeObject :: S3 -> ObjectKey -> IO ObjectObservation
|
|
|
|
|
observeObject s3 key = do
|
2026-07-18 20:48:26 +09:00
|
|
|
result <- observeObjectFully s3 key
|
2026-07-18 15:22:57 +09:00
|
|
|
case result of
|
2026-07-18 20:48:26 +09:00
|
|
|
Right info ->
|
2026-07-18 15:22:57 +09:00
|
|
|
pure (ObjectPresent (oiSize info) (oiSha256 info))
|
2026-07-18 20:48:26 +09:00
|
|
|
Left S3NotFound -> pure ObjectAbsent
|
2026-07-18 15:22:57 +09:00
|
|
|
Left err -> pure (ObjectUnavailable (renderS3Error err))
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-19 01:12:29 +09:00
|
|
|
listEntries
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionUser
|
|
|
|
|
-> Root
|
|
|
|
|
-> Text
|
|
|
|
|
-> Maybe Text
|
|
|
|
|
-> IO (Either Text ([ListEntry], Maybe Text))
|
|
|
|
|
listEntries env user root prefix continuation = do
|
2026-07-17 21:43:03 +09:00
|
|
|
let cacheKey =
|
|
|
|
|
"nuthatch:list:"
|
|
|
|
|
<> unUserId (suUserId user)
|
|
|
|
|
<> ":"
|
|
|
|
|
<> rootLabel root
|
|
|
|
|
<> ":"
|
|
|
|
|
<> prefix
|
|
|
|
|
<> ":"
|
2026-07-19 01:12:29 +09:00
|
|
|
<> T.pack (show (cfgBrowsePageSize (envConfig env)))
|
|
|
|
|
<> ":"
|
2026-07-17 21:43:03 +09:00
|
|
|
s3Prefix =
|
|
|
|
|
case root of
|
|
|
|
|
Personal -> "users/" <> unUserId (suUserId user) <> "/" <> prefixSlash
|
|
|
|
|
Shared -> "shared/" <> prefixSlash
|
|
|
|
|
prefixSlash
|
|
|
|
|
| T.null prefix = ""
|
|
|
|
|
| T.isSuffixOf "/" prefix = prefix
|
|
|
|
|
| otherwise = prefix <> "/"
|
2026-07-19 01:12:29 +09:00
|
|
|
cached <- case continuation of
|
|
|
|
|
Nothing -> cacheGet (envRedis env) cacheKey
|
|
|
|
|
Just _ -> pure Nothing
|
2026-07-17 21:43:03 +09:00
|
|
|
case cached >>= A.decodeStrict of
|
2026-07-19 01:12:29 +09:00
|
|
|
Just result -> pure (Right result)
|
2026-07-17 21:43:03 +09:00
|
|
|
Nothing -> do
|
2026-07-19 01:12:29 +09:00
|
|
|
page <- listObjects
|
|
|
|
|
(envS3 env)
|
|
|
|
|
s3Prefix
|
|
|
|
|
(Just "/")
|
|
|
|
|
continuation
|
|
|
|
|
(cfgBrowsePageSize (envConfig env))
|
2026-07-17 21:43:03 +09:00
|
|
|
case page of
|
2026-07-18 15:22:57 +09:00
|
|
|
Left err -> pure (Left (renderS3Error err))
|
2026-07-17 21:43:03 +09:00
|
|
|
Right lp -> do
|
|
|
|
|
let rootPrefix =
|
|
|
|
|
case root of
|
|
|
|
|
Personal -> "users/" <> unUserId (suUserId user) <> "/"
|
|
|
|
|
Shared -> "shared/"
|
|
|
|
|
folders =
|
|
|
|
|
[ ListEntry
|
|
|
|
|
{ leName = lastSeg (T.dropEnd 1 (T.drop (T.length rootPrefix) p))
|
|
|
|
|
, leIsPrefix = True
|
|
|
|
|
, leSize = Nothing
|
|
|
|
|
, leRelativePath = T.dropEnd 1 (T.drop (T.length rootPrefix) p)
|
|
|
|
|
}
|
|
|
|
|
| p <- lpPrefixes lp
|
|
|
|
|
, T.isPrefixOf rootPrefix p
|
|
|
|
|
]
|
|
|
|
|
files =
|
|
|
|
|
[ ListEntry
|
|
|
|
|
{ leName = lastSeg rel
|
|
|
|
|
, leIsPrefix = False
|
|
|
|
|
, leSize = Just sz
|
|
|
|
|
, leRelativePath = rel
|
|
|
|
|
}
|
|
|
|
|
| (k, sz) <- lpObjects lp
|
|
|
|
|
, T.isPrefixOf rootPrefix k
|
|
|
|
|
, let rel = T.drop (T.length rootPrefix) k
|
|
|
|
|
, not (T.null rel)
|
|
|
|
|
, not (T.isSuffixOf "/" rel)
|
|
|
|
|
]
|
|
|
|
|
entries = folders <> files
|
2026-07-19 01:12:29 +09:00
|
|
|
result = (entries, lpContinuationToken lp)
|
|
|
|
|
when (continuation == Nothing) $
|
|
|
|
|
cacheSet (envRedis env) cacheKey (cfgListCacheTtlSec (envConfig env))
|
|
|
|
|
(BL.toStrict (A.encode result))
|
|
|
|
|
pure (Right result)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-19 01:12:29 +09:00
|
|
|
searchEntries
|
|
|
|
|
:: Env
|
|
|
|
|
-> SessionUser
|
|
|
|
|
-> Root
|
|
|
|
|
-> Text
|
|
|
|
|
-> IO (Either Text ([ListEntry], Bool))
|
2026-07-17 21:43:03 +09:00
|
|
|
searchEntries env user root q = do
|
|
|
|
|
let s3Prefix =
|
|
|
|
|
case root of
|
|
|
|
|
Personal -> "users/" <> unUserId (suUserId user) <> "/"
|
|
|
|
|
Shared -> "shared/"
|
2026-07-19 01:12:29 +09:00
|
|
|
needle = T.toCaseFold q
|
|
|
|
|
started <- getMonotonicTimeNSec
|
|
|
|
|
let deadline =
|
|
|
|
|
started + fromInteger (cfgSearchTimeoutMs (envConfig env) * 1000000)
|
|
|
|
|
keysResult <- collectKeysBounded
|
|
|
|
|
(envS3 env)
|
|
|
|
|
s3Prefix
|
|
|
|
|
(cfgSearchMaxKeys (envConfig env))
|
|
|
|
|
deadline
|
2026-07-17 21:43:03 +09:00
|
|
|
pure $ case keysResult of
|
|
|
|
|
Left err -> Left err
|
2026-07-19 01:12:29 +09:00
|
|
|
Right (keys, partial) ->
|
2026-07-17 21:43:03 +09:00
|
|
|
Right
|
2026-07-19 01:12:29 +09:00
|
|
|
( [ ListEntry
|
2026-07-17 21:43:03 +09:00
|
|
|
{ leName = lastSeg rel
|
|
|
|
|
, leIsPrefix = False
|
|
|
|
|
, leSize = Just sz
|
|
|
|
|
, leRelativePath = rel
|
|
|
|
|
}
|
|
|
|
|
| (k, sz) <- keys
|
|
|
|
|
, T.isPrefixOf s3Prefix k
|
|
|
|
|
, let rel = T.drop (T.length s3Prefix) k
|
|
|
|
|
, not (T.null rel)
|
2026-07-19 01:12:29 +09:00
|
|
|
, needle `T.isInfixOf` T.toCaseFold rel
|
2026-07-17 21:43:03 +09:00
|
|
|
]
|
2026-07-19 01:12:29 +09:00
|
|
|
, partial
|
|
|
|
|
)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-19 01:12:29 +09:00
|
|
|
collectKeysBounded
|
|
|
|
|
:: S3
|
|
|
|
|
-> Text
|
|
|
|
|
-> Int
|
|
|
|
|
-> Word64
|
|
|
|
|
-> IO (Either Text ([(Text, Integer)], Bool))
|
|
|
|
|
collectKeysBounded s3 prefix =
|
|
|
|
|
collectKeysBoundedWith
|
|
|
|
|
getMonotonicTimeNSec
|
|
|
|
|
(\continuation limit ->
|
|
|
|
|
first renderS3Error
|
|
|
|
|
<$> listObjects s3 prefix Nothing continuation limit)
|
|
|
|
|
|
|
|
|
|
collectKeysBoundedWith
|
|
|
|
|
:: IO Word64
|
|
|
|
|
-> (Maybe Text -> Int -> IO (Either Text ListPage))
|
|
|
|
|
-> Int
|
|
|
|
|
-> Word64
|
|
|
|
|
-> IO (Either Text ([(Text, Integer)], Bool))
|
|
|
|
|
collectKeysBoundedWith currentTime listPage maxKeys deadline =
|
|
|
|
|
go Nothing [] 0
|
2026-07-17 21:43:03 +09:00
|
|
|
where
|
|
|
|
|
go mTok acc n
|
2026-07-19 01:12:29 +09:00
|
|
|
| n >= maxKeys = pure (Right (take maxKeys acc, True))
|
2026-07-17 21:43:03 +09:00
|
|
|
| otherwise = do
|
2026-07-19 01:12:29 +09:00
|
|
|
now <- currentTime
|
|
|
|
|
if now >= deadline
|
|
|
|
|
then pure (Right (take maxKeys acc, True))
|
|
|
|
|
else do
|
|
|
|
|
let remainingMicros =
|
|
|
|
|
fromIntegral ((deadline - now + 999) `div` 1000)
|
|
|
|
|
timedPage <- timeout remainingMicros $
|
|
|
|
|
listPage mTok (min 1000 (maxKeys - n))
|
|
|
|
|
case timedPage of
|
|
|
|
|
Nothing -> pure (Right (take maxKeys acc, True))
|
|
|
|
|
Just (Left err) -> pure (Left err)
|
|
|
|
|
Just (Right lp) -> do
|
|
|
|
|
let acc' = acc <> lpObjects lp
|
|
|
|
|
n' = length acc'
|
|
|
|
|
if lpIsTruncated lp && n' < maxKeys
|
|
|
|
|
then go (lpContinuationToken lp) acc' n'
|
|
|
|
|
else pure
|
|
|
|
|
(Right (take maxKeys acc', lpIsTruncated lp))
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
runRecovery :: Env -> IO ()
|
|
|
|
|
runRecovery env = forever $ do
|
2026-07-18 22:49:19 +09:00
|
|
|
recoverOnce env
|
2026-07-17 21:43:03 +09:00
|
|
|
threadDelay (5 * 1000000)
|
|
|
|
|
|
|
|
|
|
recoverOnce :: Env -> IO ()
|
|
|
|
|
recoverOnce env = do
|
|
|
|
|
let grace = cfgRecoveryGraceSec (envConfig env)
|
|
|
|
|
batch <- withTransaction (envDb env) $ \conn -> claimActiveBatch conn 20 grace
|
2026-07-18 22:49:19 +09:00
|
|
|
mapConcurrently_ (recoverOne env) batch
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
recoverOne :: Env -> MutationRecord -> IO ()
|
|
|
|
|
recoverOne env rec =
|
|
|
|
|
case (mrState rec, mrExternal rec) of
|
|
|
|
|
(Prepared, _) -> do
|
2026-07-18 22:49:19 +09:00
|
|
|
settled <- withTransaction (envDb env) $ \conn -> do
|
2026-07-17 21:43:03 +09:00
|
|
|
moved <- markApplying conn rec
|
2026-07-18 22:49:19 +09:00
|
|
|
if moved then markSettledFromIdle conn rec else pure False
|
|
|
|
|
when settled (finalizeRecovered env rec)
|
2026-07-17 21:43:03 +09:00
|
|
|
(Applying, ExtIdle) -> do
|
2026-07-18 22:49:19 +09:00
|
|
|
settled <- withTransaction (envDb env) $ \conn ->
|
|
|
|
|
markSettledFromIdle conn rec
|
|
|
|
|
when settled (finalizeRecovered env rec)
|
2026-07-17 21:43:03 +09:00
|
|
|
(Applying, ExtPending) -> do
|
2026-07-18 22:49:19 +09:00
|
|
|
settled <- withTransaction (envDb env) $ \conn ->
|
|
|
|
|
markExternalSettled conn rec
|
|
|
|
|
when settled (finalizeRecovered env rec)
|
|
|
|
|
(Applying, ExtSettled) -> finalizeRecovered env rec
|
2026-07-17 21:43:03 +09:00
|
|
|
_ -> pure ()
|
|
|
|
|
|
2026-07-18 22:49:19 +09:00
|
|
|
finalizeRecovered :: Env -> MutationRecord -> IO ()
|
|
|
|
|
finalizeRecovered env rec = do
|
|
|
|
|
observed <- observeObjectWithRetry env rec
|
|
|
|
|
result <- withTransaction (envDb env) $ \conn ->
|
|
|
|
|
finalizeFromObservation conn rec observed
|
|
|
|
|
when (result == Just MutationSucceeded) $
|
|
|
|
|
cacheInvalidatePrefixes
|
|
|
|
|
(envRedis env)
|
|
|
|
|
(mrUserId rec)
|
|
|
|
|
(recordRoot rec)
|
|
|
|
|
(listCacheAncestorPrefixes (mrRelativePath rec))
|
|
|
|
|
|
|
|
|
|
observeObjectWithRetry :: Env -> MutationRecord -> IO ObjectObservation
|
|
|
|
|
observeObjectWithRetry env rec = do
|
|
|
|
|
observed <- retrying
|
|
|
|
|
recoveryRetryPolicy
|
|
|
|
|
retryUnavailable
|
|
|
|
|
(\_ -> observeObject (envS3 env) (mrObjectKey rec))
|
|
|
|
|
case observed of
|
|
|
|
|
ObjectUnavailable reason -> do
|
|
|
|
|
audit "recovery_observation" "exhausted" (Just (unUserId (mrUserId rec)))
|
|
|
|
|
[ ("operation_id", A.toJSON (unOperationId (mrOperationId rec)))
|
|
|
|
|
, ("reason", A.toJSON reason)
|
|
|
|
|
]
|
|
|
|
|
pure observed
|
|
|
|
|
_ -> pure observed
|
|
|
|
|
where
|
|
|
|
|
retryUnavailable _ observation =
|
|
|
|
|
case observation of
|
|
|
|
|
ObjectUnavailable reason -> do
|
|
|
|
|
audit "recovery_observation" "retry" (Just (unUserId (mrUserId rec)))
|
|
|
|
|
[ ("operation_id", A.toJSON (unOperationId (mrOperationId rec)))
|
|
|
|
|
, ("reason", A.toJSON reason)
|
|
|
|
|
]
|
|
|
|
|
pure True
|
|
|
|
|
_ -> pure False
|
|
|
|
|
|
|
|
|
|
recordRoot :: MutationRecord -> Root
|
|
|
|
|
recordRoot rec
|
|
|
|
|
| mrPoolId rec == sharedPool = Shared
|
|
|
|
|
| otherwise = Personal
|
|
|
|
|
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
lastSeg :: Text -> Text
|
|
|
|
|
lastSeg t =
|
|
|
|
|
case reverse (T.splitOn "/" t) of
|
|
|
|
|
(x : _) -> x
|
|
|
|
|
[] -> t
|
|
|
|
|
|
2026-07-18 18:08:41 +09:00
|
|
|
requestCookies :: Request -> [(BS.ByteString, BS.ByteString)]
|
|
|
|
|
requestCookies request =
|
|
|
|
|
maybe [] parseCookies (lookup hCookie (requestHeaders request))
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 18:08:41 +09:00
|
|
|
parseCookieValueText
|
|
|
|
|
:: A.ToJSON error
|
|
|
|
|
=> error
|
|
|
|
|
-> BS.ByteString
|
|
|
|
|
-> Handler Text
|
|
|
|
|
parseCookieValueText operationError bytes =
|
|
|
|
|
case TE.decodeUtf8' bytes of
|
|
|
|
|
Right value | not (T.null value) && T.length value <= 8192 -> pure value
|
|
|
|
|
_ -> throwOperationError err400 operationError
|