{-# LANGUAGE OverloadedStrings #-} module ChuSQL.Web.API ( AppEnv (..), WebSession (..), Live (..), defaultLive, setLive, readLive, applyLiveKeys, webApp, sessionCookieName, parseCookieHeader, isIdentifier, renderRow, tableInfoJson, systemTableInfo, apiErrorJson, newAppEnv, newAppEnvAt, ) where import ChuSQL.Core.Model (Row, Value (..)) import ChuSQL.Core.Protocol (Account (..), QueryResult (..), SchemaColumn (..), TableInfo (..)) import ChuSQL.Interface.AccountTable (accountColumns, accountTableName, isAccountTable, passwordColumn, systemTableInfo) import ChuSQL.Interface.Actions ( ColumnSpec (..), CreateTableSpec (..), alterUserSql, coerceValue, columnTypeOf, createDatabaseSql, createIndexSql, createRoleSql, createTableSql, createUserSql, deleteRowSql, dropColumnSql, dropDatabaseSql, dropIndexSql, dropRoleSql, dropTableSql, dropUserSql, grantPrivilegesSql, grantRoleSql, insertRowSql, isIdentifier, revokePrivilegesSql, revokeRoleSql, selectRowsSql, updateRowSql, ) import ChuSQL.Interface.Auth ( PayloadStore, SessionPolicy (..), createPayloadSession, defaultSessionPolicy, dropPayload, lookupPayload, newPayloadStore, sessionToken, setPayloadPolicy, sweepPayloads, ) import ChuSQL.Interface.Link ( Client, clientPing, clientPolicy, clientReloadPolicy, closeClient, connectClient, ) import ChuSQL.Interface.Protocol (Grant (..), RoleView (..)) import ChuSQL.Interface.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord, setRateLimit) import ChuSQL.Interface.Session ( Session, authenticateSession, catalog, databases, isPlainIdentifier, newSession, roleViews, runStatement, sessionDatabase, sessionIsAdmin, switchDatabase, ) import qualified ChuSQL.Interface.Session as FrontSession import ChuSQL.Interface.Settings ( SettingItem (..), applySettings, defaultOf, isLockedSetting, liveKeys, readSettingsFile, settingCatalogue, writeSettingsFile, ) import ChuSQL.Interface.TOML (defaultConfigFile) import ChuSQL.Web.Demo (SeedReport (..), seedDemo) import ChuSQL.Web.Secure (bodyLimitDynamic, sameOriginOnly, securityHeaders, contentSecurityPolicy) import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) import ChuSQL.Web.UISettings ( readUISettings, resolveUISettingsFile, uiSettingsFileCandidates, validateUISettings, writeUISettings, ) import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, withMVar) import Control.Exception (IOException, try) import Data.Aeson (FromJSON (..), eitherDecode, object, withObject, (.:), (.=)) import qualified Data.Aeson as A import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as BL import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.List (nub, sortBy) import qualified Data.Map.Strict as Map import Data.Maybe (catMaybes, fromMaybe) import Data.Scientific (fromFloatDigits) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding.Error as TEE import qualified Data.Text.Lazy as TL import Data.Time.Clock (getCurrentTime) import qualified Data.Vector as V import Network.HTTP.Types (Status, status204, status400, status401, status403, status404, status409, status413, status429, status500) import Network.Wai (Application, Request (..)) import Text.Read (readMaybe) import Web.Scotty -- REST API 与页面交付:鉴权、JSON 与引擎结果的互转、错误到状态码的映射。 -- | 一组可热改的运行参数 data Live = Live { lvBodyLimit :: Int , lvSessionIdle :: Int , lvSessionMax :: Int , lvLoginMaxAttempts :: Int , lvLoginWindow :: Int , lvPageSize :: Int , lvMaxPageSize :: Int , lvMaxRows :: Int , lvMaxSqlLength :: Int } deriving (Show, Eq) -- | 热改项的默认值,与配置默认一致 defaultLive :: Live defaultLive = Live { lvBodyLimit = 65536 , lvSessionIdle = 8 * 3600 , lvSessionMax = 24 * 3600 , lvLoginMaxAttempts = 5 , lvLoginWindow = 5 * 60 , lvPageSize = 25 , lvMaxPageSize = 500 , lvMaxRows = 1000 , lvMaxSqlLength = 20000 } -- | Web 进程共享的运行环境 data AppEnv = AppEnv { aeServerHost :: Text , aeServerPort :: Int , aeSessions :: PayloadStore WebSession , aeLimiter :: RateLimiter , aeStaticDir :: FilePath , aeCookieSecure :: Bool , aeLive :: IORef Live , aeSettingsFile :: FilePath , aeUISettingsFile :: FilePath , aeEffective :: Map.Map Text Text , aeLog :: Text -> IO () } -- | 一条网页会话:TCP 连接、登录状态与账号名 data WebSession = WebSession { wsClient :: Client , wsSession :: Session , wsUser :: Text , wsAdmin :: Bool } -- | 会话 Cookie 名 sessionCookieName :: Text sessionCookieName = "chusql_session" -- | 造一份运行环境,各项上限用内置默认 newAppEnv :: Text -> Int -> RateLimiter -> FilePath -> IO AppEnv newAppEnv host port limiter staticDir = do settingsFile <- resolveSettingsFileSafe newAppEnvAt settingsFile host port limiter staticDir -- | 同上,但显式指定配置文件路径 newAppEnvAt :: FilePath -> Text -> Int -> RateLimiter -> FilePath -> IO AppEnv newAppEnvAt settingsFile host port limiter staticDir = do sessions <- newPayloadStore getCurrentTime defaultSessionPolicy liveRef <- newIORef defaultLive uiSettingsFile <- resolveUISettingsFileSafe pure AppEnv { aeServerHost = host , aeServerPort = port , aeSessions = sessions , aeLimiter = limiter , aeStaticDir = staticDir , aeCookieSecure = False , aeLive = liveRef , aeSettingsFile = settingsFile , aeUISettingsFile = uiSettingsFile , aeEffective = Map.empty , aeLog = const (pure ()) } -- | 系统库:账号表住在这里,只有管理员能进 systemDatabase :: Text systemDatabase = "system" -- | 保留库:只有系统库删不掉、也建不了。 reservedDatabase :: Text -> Bool reservedDatabase name = name == systemDatabase -- | 定位配置文件路径,失败时回落到固定名 resolveSettingsFileSafe :: IO FilePath resolveSettingsFileSafe = do found <- try defaultConfigFile :: IO (Either IOException FilePath) pure (either (const "chusql.toml") id found) -- | 定位 IDE 设置文件,找不到就定下首个候选路径 resolveUISettingsFileSafe :: IO FilePath resolveUISettingsFileSafe = do found <- try resolveUISettingsFile :: IO (Either IOException FilePath) pure (either (const fallbackUISettingsFile) id found) where fallbackUISettingsFile = case uiSettingsFileCandidates of (p : _) -> p [] -> "chusql.ui.settings.json" -- | 换一组热改参数(起服务前按配置文件初始化用) setLive :: AppEnv -> Live -> IO () setLive env = writeIORef (aeLive env) -- | 现在生效的热改参数 readLive :: AppEnv -> IO Live readLive = readIORef . aeLive -- | WAI 中间件用的实时请求体上限 liveBodyLimit :: AppEnv -> IO Int liveBodyLimit env = lvBodyLimit <$> readLive env -- | 组装 WAI 应用:路由外套四层中间件 webApp :: AppEnv -> IO Application webApp env = do inner <- scottyApp (routes env) locks <- newMVar Map.empty pure (securityHeaders (sameOriginOnly (bodyLimitDynamic (liveBodyLimit env) (serializeSessions locks inner)))) -- | 同一条会话的请求串行执行 serializeSessions :: MVar (Map.Map Text (MVar ())) -> Application -> Application serializeSessions locks app req respond = case sessionKey req of Nothing -> app req respond Just key -> do lock <- lockFor locks key withMVar lock (const (app req respond)) where -- | 取请求所属的会话令牌 sessionKey _ = do rawCookie <- lookup "Cookie" (requestHeaders req) parseCookieHeader sessionCookieName (TE.decodeUtf8With TEE.lenientDecode rawCookie) -- | 一条会话一把锁(按 Cookie 令牌去重) lockFor :: MVar (Map.Map Text (MVar ())) -> Text -> IO (MVar ()) lockFor locks key = modifyMVar locks $ \known -> case Map.lookup key known of Just lock -> pure (known, lock) Nothing -> do lock <- newMVar () pure (Map.insert key lock known, lock) -- | 路由表:界面一键操作全走结构化接口 routes :: AppEnv -> ScottyM () routes env = do get "/" (indexH env) get "/static/:file" (staticH env) get "/api/health" healthH get "/api/status" (statusH env) post "/api/login" (loginH env) post "/api/logout" (logoutH env) get "/api/session" (sessionH env) get "/api/settings" (settingsH env) put "/api/settings" (putSettingsH env) get "/api/ui-settings" (uiSettingsH env) put "/api/ui-settings" (putUISettingsH env) get "/api/databases" (databasesH env) post "/api/databases" (withDatabase env createDatabaseH) delete "/api/databases/:name" (withDatabase env dropDatabaseH) get "/api/tables" (withDatabase env tablesH) post "/api/tables" (withDatabase env createTableH) delete "/api/tables/:t" (withDatabase env dropTableH) get "/api/tables/:t" (withDatabase env tableH) get "/api/tables/:t/rows" (withDatabase env rowsH) post "/api/tables/:t/rows" (withDatabase env insertRowH) patch "/api/tables/:t/rows/:id" (withDatabase env updateRowH) delete "/api/tables/:t/rows/:id" (withDatabase env deleteRowH) post "/api/tables/:t/indexes" (withDatabase env createIndexH) delete "/api/tables/:t/indexes/:col" (withDatabase env dropIndexH) delete "/api/tables/:t/columns/:col" (withDatabase env dropColumnH) post "/api/demo-data" (withDatabase env demoDataH) get "/api/roles" (withDatabase env rolesH) post "/api/roles" (withDatabase env createRoleH) delete "/api/roles/:name" (withDatabase env dropRoleH) post "/api/roles/:name/grants" (withDatabase env grantRoleH) delete "/api/roles/:name/grants" (withDatabase env revokeRoleH) post "/api/roles/:name/members" (withDatabase env addRoleMemberH) delete "/api/roles/:name/members/:user" (withDatabase env removeRoleMemberH) post "/api/query" (withDatabase env queryH) notFound (notFoundH env) -- | 先认会话,再按请求头切本会话的库 withDatabase :: AppEnv -> (AppEnv -> ActionM ()) -> ActionM () withDatabase env action = do ws <- requireSession env chosen <- maybe "" (T.toLower . TL.toStrict) <$> header "X-ChuSQL-Database" if not (T.null chosen) && (not (isPlainIdentifier chosen) || T.length chosen > 64) then reply400 "bad_request" "invalid database name" else if chosen == systemDatabase && not (wsAdmin ws) then reply403 "the system database is only available to the administrator" else do ready <- liftIO (if T.null chosen then pure (Right ()) else switchDatabase (wsSession ws) chosen) case ready of Left message -> serverError message Right () -> action env -- | 取当前库名,未选库时报错 requireDatabaseName :: WebSession -> ActionM Text requireDatabaseName ws = do current <- liftIO (sessionDatabase (wsSession ws)) if T.null current then apiError "no_database" "no database selected" else pure current -- | 取本请求的会话,没有令牌就 401 requireSession :: AppEnv -> ActionM WebSession requireSession env = do token <- requestSessionToken >>= maybe (apiError "unauthorized" "sign in first") pure found <- liftIO (lookupPayload (aeSessions env) token) case found of Nothing -> apiError "unauthorized" "sign in first" Just (_, ws) -> pure ws -- | 管理员专属操作 requireAdminSession :: AppEnv -> ActionM WebSession requireAdminSession env = do ws <- requireSession env adminOnly ws pure ws -- | 只有管理员能碰的东西 adminOnly :: WebSession -> ActionM () adminOnly ws | wsAdmin ws = pure () | otherwise = apiError "forbidden" "administrator required" -- | 数据库清单;系统库 system 只给管理员看。 databasesH :: AppEnv -> ActionM () databasesH env = do ws <- requireSession env result <- liftIO (databases (wsSession ws)) case result of Left message -> serverError message Right names -> json (if wsAdmin ws then names else filter (/= systemDatabase) names) -- | 新建数据库:校验名字后交给 server createDatabaseH :: AppEnv -> ActionM () createDatabaseH env = do ws <- requireAdminSession env withJsonObject env $ \o -> case textField "name" o of Left err -> reply400 "bad_request" err Right name -> case createDatabaseSql name of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 删除数据库;系统库 system 一律拒绝。 dropDatabaseH :: AppEnv -> ActionM () dropDatabaseH env = do ws <- requireAdminSession env name <- pathParam "name" if reservedDatabase name then reply400 "bad_request" "the system database cannot be dropped" else case dropDatabaseSql name of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 首页:登录与管理界面同一个 HTML indexH :: AppEnv -> ActionM () indexH env = do content <- liftIO (readStatic (aeStaticDir env) "index.html") case content of Nothing -> do status status500 json (apiErrorJson "missing_static" "index.html not found (check the --static directory)") Just page -> do nonce <- liftIO sessionToken let (beforeHead, afterHead) = BS.breakSubstring "" page nonceMeta = TE.encodeUtf8 (" nonce <> "\">") pageHtml = beforeHead <> nonceMeta <> afterHead setHeader "Content-Security-Policy" (TL.fromStrict (contentSecurityPolicy (Just nonce))) setHeader "Content-Type" "text/html; charset=utf-8" raw (BL.fromStrict pageHtml) -- | 静态文件:白名单之外一律 404 staticH :: AppEnv -> ActionM () staticH env = do rel <- pathParam "file" if rel == "index.html" then indexH env else case safeRelative rel of Nothing -> reply404 Just _ -> do content <- liftIO (readStatic (aeStaticDir env) (T.unpack rel)) case content of Nothing -> reply404 Just page -> do setHeader "Content-Type" (TL.fromStrict (contentTypeOf (T.unpack rel))) raw (BL.fromStrict page) -- | 统一的 JSON 404 reply404 :: ActionM () reply404 = do status status404 json (apiErrorJson "not_found" "no such resource") -- | 兜底:任何没匹配上的路径 notFoundH :: AppEnv -> ActionM () notFoundH _ = reply404 -- | 只回答"进程在",不碰存储 healthH :: ActionM () healthH = json (object ["status" .= ("ok" :: Text)]) -- | 探活:回进程与存储状态,顺带清理过期会话 statusH :: AppEnv -> ActionM () statusH env = do expired <- liftIO (sweepPayloads (aeSessions env)) liftIO (mapM_ (closeClient . wsClient) expired) opened <- liftIO (connectClient (aeServerHost env) (aeServerPort env)) case opened of Left message -> json (object ["status" .= ("ok" :: Text), "storage" .= ("down" :: Text), "detail" .= message]) Right client -> do probe <- liftIO (clientPing client) liftIO (closeClient client) case probe of Right () -> json (object ["status" .= ("ok" :: Text), "storage" .= ("up" :: Text)]) Left message -> json (object ["status" .= ("ok" :: Text), "storage" .= ("down" :: Text), "detail" .= message]) -- | 登录:对了发 Cookie,错了 401/429 loginH :: AppEnv -> ActionM () loginH env = do rawBody <- body limit <- liftIO (liveBodyLimit env) if BL.length rawBody > fromIntegral limit then reply413 else case eitherDecode rawBody of Left _ -> reply400 "bad_request" "request body is not valid JSON" Right reqBody -> do let user = T.strip (lrUser reqBody) password = lrPassword reqBody -- 口令允许为空:配置里口令留空时管理员就是免密登录 if T.null user || T.length user > 64 || T.length password > 256 then reply400 "bad_request" "user name is empty or too long" else do blocked <- liftIO (rateLimitBlock (aeLimiter env) user) case blocked of Just left -> do setHeader "Retry-After" (TL.fromStrict (T.pack (show (ceiling left :: Int)))) status status429 json (apiErrorJson "too_many_attempts" "too many failed sign-in attempts, try again later") Nothing -> openSession env user password -- | 用用户口令连 server,成功后建会话 openSession :: AppEnv -> Text -> Text -> ActionM () openSession env user password = do live <- liftIO (readLive env) opened <- liftIO (connectClient (aeServerHost env) (aeServerPort env)) case opened of Left message -> do liftIO (aeLog env ("sign-in failed user=" <> user <> " " <> message)) status status500 json (apiErrorJson "internal" message) Right client -> do session <- liftIO (newSession client) signed <- liftIO (authenticateSession session user password) case signed of Left message -> do liftIO (closeClient client) liftIO (rateLimitRecord (aeLimiter env) user) liftIO (aeLog env ("sign-in failed user=" <> user <> " " <> message)) if errorCodeOf message == "unauthorized" then do status status401 json (apiErrorJson "unauthorized" "invalid user name or password") else serverError message Right () -> do admin <- liftIO (sessionIsAdmin session) token <- liftIO (createPayloadSession (aeSessions env) user (WebSession client session user admin)) liftIO (rateLimitClear (aeLimiter env) user) setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env token (Just (lvSessionMax live)))) liftIO (aeLog env ("sign-in ok user=" <> user)) status status204 -- | 退出:收掉会话与连接,清除 Cookie logoutH :: AppEnv -> ActionM () logoutH env = do token <- requestSessionToken case token of Nothing -> pure () Just t -> do gone <- liftIO (dropPayload (aeSessions env) t) case gone of Nothing -> pure () Just ws -> liftIO (closeClient (wsClient ws)) setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env "" (Just 0))) status status204 -- | 当前登录者与生效的口令策略 sessionH :: AppEnv -> ActionM () sessionH env = do ws <- requireSession env policy <- liftIO (clientPolicy (wsClient ws)) case policy of Left message -> serverError message Right (minLength, classes) -> json ( object [ "user" .= wsUser ws , "administrator" .= wsAdmin ws , "policy" .= object ["minLength" .= minLength, "classes" .= classes] ] ) -- | 读设置:文件值、启动生效值与内置默认一起给出 settingsH :: AppEnv -> ActionM () settingsH env = do ws <- requireSession env fileValues <- liftIO (readSettingsFile (aeSettingsFile env)) let owner = wsAdmin ws json ( object [ "file" .= aeSettingsFile env , "account" .= wsUser ws , "owner" .= owner , "items" .= map (itemJson (aeEffective env) fileValues owner) settingCatalogue ] ) -- | 一个配置项在界面上的样子 itemJson :: Map.Map Text Text -> Map.Map Text Text -> Bool -> SettingItem -> A.Value itemJson effectiveValues fileValues owner spec = object [ "key" .= siKey spec , "label" .= siLabel spec , "group" .= siGroup spec , "kind" .= siKind spec , "default" .= siDefault spec , "value" .= shownValue , "source" .= source , "rootOnly" .= siRootOnly spec , "editable" .= (not (isLockedSetting (siKey spec)) && (owner || not (siRootOnly spec))) , "locked" .= isLockedSetting (siKey spec) , "restart" .= siRestart spec , "live" .= (siKey spec `elem` liveKeys) , "set" .= hasValue ] where secret = siKind spec == "secret" fromFile = Map.lookup (siKey spec) fileValues fromProcess = Map.lookup (siKey spec) effectiveValues effectiveValue = fromMaybe (defaultOf (siKey spec)) fromProcess shownValue = if secret then "" else fromMaybe effectiveValue fromFile hasValue = fromFile /= Nothing source :: Text source | secret = if hasValue then "configured" else "not set" | fromFile /= Nothing = "settings file" | otherwise = "default" -- | 改设置:写配置文件并让可热改项立即生效 putSettingsH :: AppEnv -> ActionM () putSettingsH env = do ws <- requireSession env let user = wsUser ws withJsonObject env $ \o -> case settingsUpdates o of Left err -> reply400 "bad_request" err Right updates -> do let locked = [k | k <- Map.keys updates, isLockedSetting k] forbidden = [ k | k <- Map.keys updates , maybe False siRootOnly (findSetting k) , not (wsAdmin ws) ] if not (null locked) then reply403 ("administrator credentials live in the settings file and cannot be changed here: " <> T.intercalate ", " locked) else if not (null forbidden) then reply403 ("administrator required to change: " <> T.intercalate ", " forbidden) else do current <- liftIO (readSettingsFile (aeSettingsFile env)) case applySettings current updates of Left err -> reply400 "bad_request" (T.pack err) Right merged -> do written <- liftIO (writeSettingsFile (aeSettingsFile env) merged) case written of Left err -> reply500 (T.pack err) Right () -> do applied <- liftIO (applyLiveKeys env updates) -- 口令策略住在 server:让它重读自己的设置文件 reloaded <- liftIO (clientReloadPolicy (wsClient ws)) let policyKeys = ["password-min-length", "password-classes"] appliedNow = case reloaded of Right _ -> nub (applied ++ [k | k <- policyKeys, Map.member k updates]) Left _ -> applied needsRestart = [k | k <- Map.keys updates, not (k `elem` appliedNow), not (T.null (Map.findWithDefault "" k updates))] liftIO (aeLog env ("settings updated by " <> user <> ": " <> T.intercalate ", " (Map.keys updates))) json ( object [ "ok" .= True , "file" .= aeSettingsFile env , "applied" .= appliedNow , "restartRequired" .= needsRestart ] ) -- | 读前端 IDE 的设置文件 uiSettingsH :: AppEnv -> ActionM () uiSettingsH env = do _ <- requireUser env value <- liftIO (readUISettings (aeUISettingsFile env)) json value -- | 整份覆盖前端 IDE 设置,校验不过一律 400 putUISettingsH :: AppEnv -> ActionM () putUISettingsH env = do _ <- requireUser env rawBody <- body limit <- liftIO (liveBodyLimit env) if BL.length rawBody > fromIntegral limit then reply413 else case eitherDecode rawBody of Left _ -> reply400 "bad_request" "request body is not valid JSON" Right payload -> case validateUISettings payload of Left err -> reply400 "bad_request" (T.pack err) Right valid -> do written <- liftIO (writeUISettings (aeUISettingsFile env) valid) case written of Left err -> reply500 (T.pack err) Right () -> status status204 -- | 点账号表时只认系统库,否则 400 accountRoute :: WebSession -> Text -> ActionM Bool accountRoute ws name | not (isAccountTable name) = pure False | otherwise = do current <- liftIO (sessionDatabase (wsSession ws)) if current /= systemDatabase then do reply400 "bad_request" "the account table lives in the system database" finish else pure True -- | 账号行:平铺的各列 accountRowsOf :: [Account] -> [Row] accountRowsOf = map accountRow where -- | 一个账号转成一行 accountRow a = [ ("id", VInt (fromIntegral (accountId a))) , ("user", VStr (T.unpack (accountUser a))) , (passwordColumn, VStr (T.unpack (accountHash a))) , ("registered_at", VStr (T.unpack (accountRegisteredAt a))) , ("last_login_at", maybe VNull (VStr . T.unpack) (accountLastLoginAt a)) ] -- | 账号表的线上信息(列与普通表同构) systemTableJson :: Int -> A.Value systemTableJson count = object [ "table" .= (accountTableName :: String) , "kind" .= ("system" :: Text) , "rowCount" .= count , "indexes" .= [object ["column" .= ("user" :: Text), "builtIn" .= False]] , "columns" .= map accountColumnJson accountColumns ] where -- | 一列转成线上 JSON accountColumnJson sc = object [ "name" .= scName sc , "type" .= scType sc , "primaryKey" .= scPrimaryKey sc , "indexed" .= (scPrimaryKey sc || scUnique sc) , "prime" .= (scPrimaryKey sc || scUnique sc) , "unique" .= scUnique sc , "nullable" .= scNullable sc , "distinct" .= (-1 :: Int) , "statsCapped" .= False , "secret" .= (scName sc == passwordColumn) ] -- | 读一遍账号表,读不到就按 server 给的码回 requireAccounts :: WebSession -> ActionM [Account] requireAccounts ws = do result <- liftIO (FrontSession.accounts (wsSession ws)) either serverError pure result -- | 口令列不接受排序与筛选 rejectSecretColumn :: Maybe (Text, Bool) -> [(Text, Value)] -> Either Text () rejectSecretColumn order filters | Just (col, _) <- order, col == T.pack passwordColumn = Left "the password column cannot be sorted" | any ((== T.pack passwordColumn) . fst) filters = Left "the password column cannot be filtered" | otherwise = Right () -- | 账号表的一页数据(排序与筛选都在内存里做) baseRowsH :: WebSession -> AppEnv -> ActionM () baseRowsH ws env = do adminOnly ws live <- liftIO (readLive env) limit <- intParam "limit" (lvPageSize live) 1 (lvMaxPageSize live) offset <- intParam "offset" 0 0 1000000 rawFilter <- (queryParamMaybe "filter" :: ActionM (Maybe Text)) order <- sortParam systemTableInfo case (order, parseFilters systemTableInfo rawFilter) of (Left err, _) -> reply400 "bad_request" err (_, Left err) -> reply400 "bad_request" err (Right sortOrder, Right filters) -> case rejectSecretColumn sortOrder filters of Left err -> reply400 "bad_request" err Right () -> do accounts <- requireAccounts ws let rows = filterAccountRows filters (sortAccountRows sortOrder (accountRowsOf accounts)) page = take limit (drop offset rows) cols = map scName accountColumns (sortColumn, ascending) = splitSort sortOrder json ( object [ "table" .= accountTableName , "columns" .= cols , "rows" .= map (renderRow cols) page , "total" .= length rows , "limit" .= limit , "offset" .= offset , "sort" .= maybe A.Null A.String sortColumn , "dir" .= (if ascending then "asc" else "desc" :: Text) , "filters" .= map fst filters ] ) -- | 按用户名排序(其余列不支持排序) sortAccountRows :: Maybe (Text, Bool) -> [Row] -> [Row] sortAccountRows Nothing rows = rows sortAccountRows (Just (column, ascending)) rows = sortBy compareUser rows where -- | 比两个账号单元 compareUser a b = case (accountCell a column, accountCell b column) of (Just x, Just y) -> if ascending then compare x y else compare y x _ -> EQ -- | 取账号行里某列的文本值 accountCell row name = case lookup (T.unpack name) row of Just VNull -> Just "" Just (VStr s) -> Just s Just (VInt n) -> Just (show n) Just (VFloat d) -> Just (show d) _ -> Nothing -- | 按等值条件筛账号行 filterAccountRows :: [(Text, Value)] -> [Row] -> [Row] filterAccountRows [] rows = rows filterAccountRows filters rows = [r | r <- rows, all (matches r) filters] where -- | 行是否满足一条等值条件 matches row (column, wanted) = case lookup (T.unpack column) row of Just value -> value == wanted Nothing -> False -- | 账号表写入:插一行 = CREATE USER baseInsertH :: AppEnv -> WebSession -> ActionM () baseInsertH env ws = do adminOnly ws withJsonObject env $ \o -> case valuesField o >>= readRowValues False accountColumns of Left err -> reply400 "bad_request" err Right values -> case (lookupValue "user" values, lookupValue (T.pack passwordColumn) values) of (Just (VStr user), Just (VStr password)) -> case createUserSql (T.pack user) (T.pack password) of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql _ -> reply400 "bad_request" "user and password are required" -- | 账号表写入:改一行 = ALTER USER baseUpdateH :: AppEnv -> WebSession -> Text -> ActionM () baseUpdateH env ws target = do adminOnly ws withJsonObject env $ \o -> case valuesField o >>= readRowValues False accountColumns of Left err -> reply400 "bad_request" err Right values -> case (values, lookupValue (T.pack passwordColumn) values) of ([(_, VStr password)], Just _) -> case alterUserSql target (T.pack password) of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql _ -> reply400 "bad_request" "only the password of an existing account can be changed" -- | 账号表写入:删一行 = DROP USER baseDeleteH :: AppEnv -> WebSession -> Text -> ActionM () baseDeleteH _env ws target = do adminOnly ws case dropUserSql target of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 取一个列值 lookupValue :: Text -> [(String, Value)] -> Maybe Value lookupValue name values = lookup (T.unpack name) values -- | 错误码 + 消息 → 状态码 + 统一错误体 apiError :: Text -> Text -> ActionM a apiError code message = do status (statusOf code) json (apiErrorJson code message) finish -- | 拆开 server 的 code: message 并映射 serverError :: Text -> ActionM a serverError message | code `elem` knownCodes = apiError code detail | otherwise = apiError "internal" message where (rawCode, rest) = T.breakOn ":" message code = T.strip rawCode detail = T.strip (T.drop 1 rest) knownCodes = ["unauthorized", "forbidden", "not_found", "conflict", "bad_request", "no_database", "query_error", "storage_error", "too_large"] -- | 取错误消息里的错误码 errorCodeOf :: Text -> Text errorCodeOf message = T.strip (fst (T.breakOn ":" message)) -- | 错误码 → HTTP 状态码 statusOf :: Text -> Status statusOf code = case code of "unauthorized" -> status401 "forbidden" -> status403 "not_found" -> status404 "conflict" -> status409 "too_many_attempts" -> status429 "too_large" -> status413 "storage_error" -> status500 "internal" -> status500 _ -> status400 -- | 按 key 找目录项 findSetting :: Text -> Maybe SettingItem findSetting key = case [i | i <- settingCatalogue, siKey i == key] of (i : _) -> Just i [] -> Nothing -- | 把热改设置应用到当前进程 applyLiveKeys :: AppEnv -> Map.Map Text Text -> IO [Text] applyLiveKeys env updates = do before <- readLive env let wanted = [k | k <- liveKeys, Map.member k updates] -- | 取热改项的整数值 intOf key = case T.strip (Map.findWithDefault "" key updates) of "" -> intFromText (effectiveValueOf (aeEffective env) key) given -> intFromText given after = before { lvBodyLimit = intOf "body-limit" , lvSessionIdle = intOf "session-idle" , lvSessionMax = intOf "session-max" , lvLoginMaxAttempts = intOf "login-max-attempts" , lvLoginWindow = intOf "login-window" , lvPageSize = intOf "rows-per-page" , lvMaxPageSize = intOf "max-page-size" , lvMaxRows = intOf "max-rows" , lvMaxSqlLength = intOf "max-sql-length" } writeIORef (aeLive env) after setPayloadPolicy (aeSessions env) (SessionPolicy (fromIntegral (lvSessionIdle after)) (fromIntegral (lvSessionMax after))) setRateLimit (aeLimiter env) (lvLoginMaxAttempts after) (fromIntegral (lvLoginWindow after)) pure wanted -- | 某项无配置时的实际生效值 effectiveValueOf :: Map.Map Text Text -> Text -> Text effectiveValueOf effectiveValues key = fromMaybe (defaultOf key) (Map.lookup key effectiveValues) -- | 把字符串解析成整数,坏值给 0 intFromText :: Text -> Int intFromText given = fromMaybe 0 (readMaybe (T.unpack (T.strip given))) -- | 表清单;管理员额外看到账号表 tablesH :: AppEnv -> ActionM () tablesH env = do ws <- requireSession env current <- requireDatabaseName ws result <- liftIO (catalog (wsSession ws)) case result of Left message -> serverError message Right infos -> do let tables = [info | info <- infos, not (isAccountTable (T.pack (tiTable info)))] extra <- if current == systemDatabase && wsAdmin ws then (: []) . systemTableJson . length <$> requireAccounts ws else pure [] json (map tableInfoJson tables ++ extra) -- | 单表结构 tableH :: AppEnv -> ActionM () tableH env = do ws <- requireSession env _ <- requireDatabaseName ws name <- pathParam "t" account <- accountRoute ws name if account then do adminOnly ws accounts <- requireAccounts ws json (systemTableJson (length accounts)) else if not (isIdentifier name) then reply400 "bad_request" "invalid table name" else do result <- liftIO (catalog (wsSession ws)) case result of Left message -> serverError message Right infos -> case findTable name infos of Nothing -> reply404 Just info -> json (tableInfoJson info) -- | 分页浏览一张表,支持排序与等值过滤;账号表走内存里的账号行 rowsH :: AppEnv -> ActionM () rowsH env = do ws <- requireSession env _ <- requireDatabaseName ws name <- pathParam "t" account <- accountRoute ws name if account then baseRowsH ws env else if not (isIdentifier name) then reply400 "bad_request" "invalid table name" else do info <- requireTable ws name live <- liftIO (readLive env) limit <- intParam "limit" (lvPageSize live) 1 (lvMaxPageSize live) offset <- intParam "offset" 0 0 1000000 rawFilter <- (queryParamMaybe "filter" :: ActionM (Maybe Text)) order <- sortParam info case (order, parseFilters info rawFilter) of (Left err, _) -> reply400 "bad_request" err (_, Left err) -> reply400 "bad_request" err (Right sortOrder, Right filters) -> case selectRowsSql name sortOrder filters of Left err -> reply400 "bad_request" (T.pack err) Right sql -> do result <- liftIO (runStatement (wsSession ws) (T.pack sql)) case result of Left message -> serverError message Right res -> do let cols = map T.unpack (qrColumns res) page = take limit (drop offset (qrRows res)) (sortColumn, ascending) = splitSort sortOrder json ( object [ "table" .= tiTable info , "columns" .= cols , "rows" .= map (map valueJson) page , "total" .= qrRowCount res , "limit" .= limit , "offset" .= offset , "sort" .= maybe A.Null A.String sortColumn , "dir" .= (if ascending then "asc" else "desc" :: Text) , "filters" .= map fst filters ] ) -- | 等值过滤条件:列名与原值 data RowFilter = RowFilter Text Text instance FromJSON RowFilter where parseJSON = withObject "RowFilter" $ \o -> RowFilter <$> o .: "column" <*> o .: "value" -- | 解析 filter 参数,非法一律 400 parseFilters :: TableInfo -> Maybe Text -> Either Text [(Text, Value)] parseFilters _ Nothing = Right [] parseFilters info (Just rawFilter) | T.null (T.strip rawFilter) = Right [] | otherwise = case eitherDecode (BL.fromStrict (TE.encodeUtf8 rawFilter)) of Left _ -> Left "filter must be a JSON array of {column, value}" Right specs -> catMaybes <$> mapM toPair specs where -- | 一条过滤条件转成列与引擎值 toPair (RowFilter column rawValue) | T.null rawValue = Right Nothing | otherwise = do schema <- maybe (Left ("unknown column: " <> column)) Right (lookupColumn column) ty <- maybe (Left ("unsupported column type for: " <> column)) Right (columnTypeOf (T.pack (scType schema))) value <- either (Left . T.pack) Right (coerceValue ty (A.String rawValue)) pure (Just (column, value)) -- | 按列名找表结构里的列 lookupColumn column = case [c | c <- tiColumns info, T.pack (scName c) == column] of (c : _) -> Just c [] -> Nothing -- | 排序键拆开(没给排序时给 Nothing / asc) splitSort :: Maybe (Text, Bool) -> (Maybe Text, Bool) splitSort Nothing = (Nothing, True) splitSort (Just (col, asc)) = (Just col, asc) -- | 读 sort/dir 参数并校验列存在 sortParam :: TableInfo -> ActionM (Either Text (Maybe (Text, Bool))) sortParam info = do rawSort <- (queryParamMaybe "sort" :: ActionM (Maybe Text)) rawDir <- (queryParamMaybe "dir" :: ActionM (Maybe Text)) pure $ case rawSort of Nothing -> Right Nothing Just column | not (isIdentifier column) -> Left "sort must be a plain column name" | column `notElem` map (T.pack . scName) (tiColumns info) -> Left ("unknown column: " <> column) | otherwise -> Right (Just (column, dirOf rawDir)) where -- | dir 参数转排序方向 dirOf given = case fmap (T.toLower . T.strip) given of Just "desc" -> False _ -> True -- | 建表:按列结构拼 CREATE TABLE createTableH :: AppEnv -> ActionM () createTableH env = do ws <- requireSession env _ <- requireDatabaseName ws adminOnly ws withJsonObject env $ \o -> case decodeCreateTable o of Left err -> reply400 "bad_request" err Right spec -> case createTableSql spec of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 删表;账号表不许删 dropTableH :: AppEnv -> ActionM () dropTableH env = do ws <- requireSession env _ <- requireDatabaseName ws adminOnly ws name <- pathParam "t" account <- accountRoute ws name if account then reply400 "bad_request" "the account table cannot be dropped" else case dropTableSql name of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 插一行;账号表上等于 CREATE USER insertRowH :: AppEnv -> ActionM () insertRowH env = do ws <- requireSession env _ <- requireDatabaseName ws name <- pathParam "t" account <- accountRoute ws name if account then baseInsertH env ws else do info <- requireTable ws name withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of Left err -> reply400 "bad_request" err Right row -> case insertRowSql name row of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 改一行,只改给出的列 updateRowH :: AppEnv -> ActionM () updateRowH env = do ws <- requireSession env _ <- requireDatabaseName ws name <- pathParam "t" account <- accountRoute ws name if account then do target <- pathParam "id" baseUpdateH env ws target else do key <- rowIdParam info <- requireTable ws name withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of Left err -> reply400 "bad_request" err Right assigns -> case updateRowSql name key assigns of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 删一行;账号表上等于 DROP USER deleteRowH :: AppEnv -> ActionM () deleteRowH env = do ws <- requireSession env _ <- requireDatabaseName ws name <- pathParam "t" account <- accountRoute ws name if account then do target <- pathParam "id" baseDeleteH env ws target else do key <- rowIdParam _ <- requireTable ws name case deleteRowSql name key of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 建索引,索引列必须整数且唯一;账号表没有索引可建 createIndexH :: AppEnv -> ActionM () createIndexH env = do ws <- requireSession env _ <- requireDatabaseName ws adminOnly ws name <- pathParam "t" account <- accountRoute ws name if account then reply400 "bad_request" "the account table has no indexes" else do info <- requireTable ws name withJsonObject env $ \o -> case textField "column" o of Left err -> reply400 "bad_request" err Right column -> case columnTypeOf (columnTypeText (tiColumns info) column) of Nothing -> reply400 "bad_request" ("unknown column: " <> column) Just _ -> case createIndexSql name column of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 删索引,内建 id 索引不放行 dropIndexH :: AppEnv -> ActionM () dropIndexH env = do ws <- requireSession env _ <- requireDatabaseName ws adminOnly ws name <- pathParam "t" column <- pathParam "col" account <- accountRoute ws name if account then reply400 "bad_request" "the account table has no indexes" else do _ <- requireTable ws name if column == T.pack builtInIndexColumn then reply400 "bad_request" "the built-in id index cannot be dropped" else case dropIndexSql name column of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 删列,内建 id 列不放行;账号表的列是固定的 dropColumnH :: AppEnv -> ActionM () dropColumnH env = do ws <- requireSession env _ <- requireDatabaseName ws adminOnly ws name <- pathParam "t" column <- pathParam "col" account <- accountRoute ws name if account then reply400 "bad_request" "the account table has no columns to drop" else do _ <- requireTable ws name if column == T.pack builtInIndexColumn then reply400 "bad_request" "the built-in id column cannot be dropped" else case dropColumnSql name column of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite ws sql -- | 一键灌演示数据(缺什么补什么,可以反复点) demoDataH :: AppEnv -> ActionM () demoDataH env = do ws <- requireAdminSession env _ <- requireDatabaseName ws result <- liftIO (seedDemo (wsSession ws)) case result of Left message -> serverError message Right report -> json ( object [ "ok" .= True , "created" .= srCreated report , "skipped" .= srSkipped report , "indexes" .= srIndexes report ] ) -- | 角色总览:只有管理员能看 rolesH :: AppEnv -> ActionM () rolesH env = do ws <- requireAdminSession env result <- liftIO (roleViews (wsSession ws)) case result of Left message -> serverError message Right views -> json (map roleViewJson views) -- | 一个角色的线上信息 roleViewJson :: RoleView -> A.Value roleViewJson view = object [ "name" .= roleName view , "grants" .= map grantJson (roleGrants view) , "members" .= roleMembers view ] -- | 一条授权的线上信息 grantJson :: Grant -> A.Value grantJson grant = object [ "privilege" .= grantPrivilege grant , "object" .= grantObject grant , "grantable" .= grantable grant ] -- | 建角色 createRoleH :: AppEnv -> ActionM () createRoleH env = do ws <- requireAdminSession env withJsonObject env $ \o -> case textField "name" o of Left err -> reply400 "bad_request" err Right name -> runPrivilegeSql ws (createRoleSql name) -- | 删角色(连带清掉它的授权与成员) dropRoleH :: AppEnv -> ActionM () dropRoleH env = do ws <- requireAdminSession env name <- pathParam "name" runPrivilegeSql ws (dropRoleSql name) -- | 给角色加权限 grantRoleH :: AppEnv -> ActionM () grantRoleH env = do ws <- requireAdminSession env role <- pathParam "name" withJsonObject env $ \o -> case grantFields o of Left err -> reply400 "bad_request" err Right (privileges, objectName, withOption) -> runPrivilegeSql ws (grantPrivilegesSql privileges objectName role withOption) -- | 收角色的权限 revokeRoleH :: AppEnv -> ActionM () revokeRoleH env = do ws <- requireAdminSession env role <- pathParam "name" withJsonObject env $ \o -> case grantFields o of Left err -> reply400 "bad_request" err Right (privileges, objectName, _) -> runPrivilegeSql ws (revokePrivilegesSql privileges objectName role) -- | 把用户加进角色 addRoleMemberH :: AppEnv -> ActionM () addRoleMemberH env = do ws <- requireAdminSession env role <- pathParam "name" withJsonObject env $ \o -> case memberFields o of Left err -> reply400 "bad_request" err Right users -> runPrivilegeSql ws (grantRoleSql role users) -- | 把用户移出角色 removeRoleMemberH :: AppEnv -> ActionM () removeRoleMemberH env = do ws <- requireAdminSession env role <- pathParam "name" user <- pathParam "user" runPrivilegeSql ws (revokeRoleSql role [user]) -- | 权限(一条或一串)、对象与是否带转授权 grantFields :: A.Object -> Either Text ([Text], Text, Bool) grantFields o = do objectName <- textField "object" o privileges <- case KM.lookup "privileges" o of Just (A.Array xs) -> mapM stringValue (V.toList xs) _ -> Left "missing or non-array field: privileges" withOption <- case KM.lookup "grantOption" o of Nothing -> Right False Just (A.Bool flag) -> Right flag _ -> Left "grantOption must be a boolean" if null privileges then Left "at least one privilege is required" else pure (privileges, objectName, withOption) -- | 成员(一条或一串) memberFields :: A.Object -> Either Text [Text] memberFields o = case (KM.lookup "user" o, KM.lookup "users" o) of (Just (A.String one), _) -> Right [one] (_, Just (A.Array xs)) -> mapM stringValue (V.toList xs) _ -> Left "missing or non-string field: user" -- | 只接受字符串的取值 stringValue :: A.Value -> Either Text Text stringValue (A.String value) = Right value stringValue _ = Left "values must be strings" -- | 角色与授权命令:拼 SQL 交给 server runPrivilegeSql :: WebSession -> Either String String -> ActionM () runPrivilegeSql ws statement = case statement of Left err -> reply400 "bad_request" (T.pack err) Right sql -> do result <- liftIO (runStatement (wsSession ws) (T.pack sql)) case result of Left message -> serverError message Right _ -> json (object ["ok" .= True]) -- | 写操作统一回执 runWrite :: WebSession -> String -> ActionM () runWrite ws sql = do result <- liftIO (runStatement (wsSession ws) (T.pack sql)) case result of Left message -> serverError message Right _ -> json (object ["ok" .= True]) -- | 只收 JSON 对象,超限 413,非法 400 withJsonObject :: AppEnv -> (A.Object -> ActionM ()) -> ActionM () withJsonObject env k = do rawBody <- body limit <- liftIO (liveBodyLimit env) if BL.length rawBody > fromIntegral limit then reply413 else case eitherDecode rawBody of Left _ -> reply400 "bad_request" "request body is not valid JSON" Right (A.Object o) -> k o Right _ -> reply400 "bad_request" "request body must be a JSON object" -- | 表必须存在,否则 400/404 短路 requireTable :: WebSession -> Text -> ActionM TableInfo requireTable ws name | not (isIdentifier name) = do reply400 "bad_request" "invalid table name" finish | otherwise = do result <- liftIO (catalog (wsSession ws)) case result of Left message -> serverError message Right infos -> case findTable name infos of Nothing -> do reply404 finish Just info -> pure info -- | 路径里的行 id rowIdParam :: ActionM Int rowIdParam = do rawId <- pathParam "id" case readMaybe (T.unpack rawId) :: Maybe Int of Just n -> pure n Nothing -> do reply400 "bad_request" "row id must be an integer" finish -- | 取一个字符串字段 textField :: Text -> A.Object -> Either Text Text textField key o = case KM.lookup (K.fromText key) o of Just (A.String s) -> Right s _ -> Left ("missing or non-string field: " <> key) -- | 取 `values` 对象 valuesField :: A.Object -> Either Text A.Object valuesField o = case KM.lookup "values" o of Just (A.Object v) -> Right v _ -> Left "missing or non-object field: values" -- | 取设置请求里的 values,值当字符串 settingsUpdates :: A.Object -> Either Text (Map.Map Text Text) settingsUpdates o = do values <- valuesField o if KM.null values then Left "no settings were given" else Map.fromList <$> mapM convert (KM.toList values) where -- | 一个设置值转成文本 convert (k, v) = case v of A.String s -> Right (K.toText k, s) A.Number n -> Right (K.toText k, T.pack (show n)) A.Bool b -> Right (K.toText k, if b then "1" else "0") _ -> Left ("setting " <> K.toText k <> " must be a string, number or boolean") -- | 建表请求体 decodeCreateTable :: A.Object -> Either Text CreateTableSpec decodeCreateTable o = do name <- textField "name" o colsValue <- maybe (Left "missing field: columns") Right (KM.lookup "columns" o) cols <- case colsValue of A.Array xs -> mapM decodeColumn (V.toList xs) _ -> Left "columns must be an array" pure (CreateTableSpec name cols) where -- | 一列 {name, type} 转成列结构 decodeColumn (A.Object c) = ColumnSpec <$> textField "name" c <*> textField "type" c decodeColumn _ = Left "each column must be {name, type}" -- | 把请求里的列值按表结构转成引擎值 readRowValues :: Bool -> [SchemaColumn] -> A.Object -> Either Text [(String, Value)] readRowValues requireAll cols o = do provided <- mapM convert (KM.toList o) if requireAll then do let missing = [scName c | c <- cols, scName c `notElem` map fst provided] if null missing then pure provided else Left ("missing columns: " <> T.intercalate ", " (map T.pack missing)) else pure provided where -- | 一个列值按结构转成引擎值 convert (k, v) = do let name = K.toString k schema <- maybe (Left ("unknown column: " <> T.pack name)) Right (lookupSchema name) ty <- maybe (Left ("unsupported column type for: " <> T.pack name)) Right (columnTypeOf (T.pack (scType schema))) value <- either (Left . T.pack) Right (coerceValue ty v) pure (name, value) -- | 按列名找表结构里的列 lookupSchema name = case [c | c <- cols, scName c == name] of (c : _) -> Just c [] -> Nothing -- | 某列在数据字典里的类型字符串(找不到就给空串) columnTypeText :: [SchemaColumn] -> Text -> Text columnTypeText cols name = case [c | c <- cols, scName c == T.unpack name] of (c : _) -> T.pack (scType c) [] -> "" -- | 查询接口:校验请求体后执行 SQL queryH :: AppEnv -> ActionM () queryH env = do ws <- requireSession env let user = wsUser ws rawBody <- body live <- liftIO (readLive env) if BL.length rawBody > fromIntegral (lvBodyLimit live) then reply413 else case eitherDecode rawBody of Left _ -> reply400 "bad_request" "request body is not valid JSON" Right reqBody -> do let sql = T.strip (qrSql reqBody) if T.null sql then reply400 "bad_request" "sql must not be empty" else if T.length sql > lvMaxSqlLength live then reply413 else do liftIO (aeLog env ("query user=" <> user <> " sql=" <> sql)) executeSql ws live sql -- | 把 SQL 交给 server,结果按控制台口径回执 executeSql :: WebSession -> Live -> Text -> ActionM () executeSql ws live sql = do result <- liftIO (runStatement (wsSession ws) sql) case result of Left message -> serverError message Right res -> do let shown = take (lvMaxRows live) (qrRows res) json ( object [ "columns" .= qrColumns res , "rows" .= map (map valueJson) shown , "rowCount" .= qrRowCount res , "truncated" .= (qrTruncated res || qrRowCount res > lvMaxRows live) , "database" .= qrDatabase res ] ) -- | 拿当前 Session 用户;没有就 401 requireUser :: AppEnv -> ActionM Text requireUser env = wsUser <$> requireSession env -- | 请求里的会话令牌 requestSessionToken :: ActionM (Maybe Text) requestSessionToken = do rawHeader <- header "Cookie" pure (fmap TL.toStrict rawHeader >>= \h -> parseCookieHeader sessionCookieName h) -- | 从 Cookie 头里取一个键(没有就给 Nothing) parseCookieHeader :: Text -> Text -> Maybe Text parseCookieHeader name headerValue = case [v | part <- T.splitOn ";" headerValue, Just v <- [valueOf name part]] of (v : _) -> Just v [] -> Nothing where -- | 从一段 Cookie 里取指定键的值 valueOf key part = let (k, rest) = T.breakOn "=" part value = T.strip (T.drop 1 rest) in if T.strip k == key && not (T.null value) then Just value else Nothing -- | 取一个整数查询参数;非法给 400,越界就夹到区间里 intParam :: Text -> Int -> Int -> Int -> ActionM Int intParam name def lo hi = do rawParam <- (queryParamMaybe (TL.fromStrict name) :: ActionM (Maybe Text)) case rawParam of Nothing -> pure def Just rawText -> case readMaybe (T.unpack (T.strip rawText)) :: Maybe Int of Nothing -> do status status400 json (apiErrorJson "bad_request" (name <> " must be an integer")) finish Just n -> pure (max lo (min hi n)) -- | 会话 Cookie 的值(maxAge 给 0 就是删掉) sessionCookie :: AppEnv -> Text -> Maybe Int -> Text sessionCookie env token maxAge = T.concat [ sessionCookieName , "=" , token , "; Path=/; HttpOnly; SameSite=Strict" , maybe "" (\n -> "; Max-Age=" <> T.pack (show n)) maxAge , if aeCookieSecure env then "; Secure" else "" ] -- | 一行按给定列名渲染成 JSON 数组(缺列给 null) renderRow :: [String] -> Row -> [A.Value] renderRow cols row = [maybe A.Null valueJson (lookup c row) | c <- cols] -- | 一个值转 JSON valueJson :: Value -> A.Value valueJson VNull = A.Null valueJson (VInt n) = A.Number (fromIntegral n) valueJson (VFloat d) = A.Number (fromFloatDigits d) valueJson (VStr s) = A.String (T.pack s) valueJson (VBool b) = A.Bool b -- | 一张表的线上信息:列、行数、索引与统计 tableInfoJson :: TableInfo -> A.Value tableInfoJson info = object [ "table" .= tiTable info , "kind" .= ("table" :: Text) , "rowCount" .= tiRows info , "indexes" .= [object ["column" .= c, "builtIn" .= (c == builtInIndexColumn)] | c <- tiIndexes info] , "columns" .= map columnJson (tiColumns info) ] where -- | 一列转成线上 JSON columnJson sc = object [ "name" .= scName sc , "type" .= scType sc , "primaryKey" .= isPrimaryKey , "indexed" .= isIndexed , "prime" .= (isPrimaryKey || isIndexed) , "distinct" .= distinctCount , "statsCapped" .= statsCapped , "secret" .= False , "nullable" .= scNullable sc , "default" .= fmap valueJson (scDefault sc) , "autoIncrement" .= scAutoIncrement sc , "unique" .= scUnique sc , "check" .= scCheck sc ] where name = scName sc isPrimaryKey = scPrimaryKey sc || (name == builtInIndexColumn && scType sc == "int") isIndexed = name `elem` tiIndexes info stat = [s | s@(n, _, _) <- tiStats info, n == name] distinctCount = case stat of ((_, d, _) : _) -> d [] -> -1 statsCapped = case stat of ((_, _, capped) : _) -> capped [] -> False -- | 存储层的内建索引列名 id,界面与服务端都按这条规则认 builtInIndexColumn :: String builtInIndexColumn = "id" -- | 统一错误体 apiErrorJson :: Text -> Text -> A.Value apiErrorJson code message = object ["error" .= code, "message" .= message] -- | 400 reply400 :: Text -> Text -> ActionM () reply400 code message = do status status400 json (apiErrorJson code message) -- | 403(登录了但这件事不归你管) reply403 :: Text -> ActionM () reply403 message = do status status403 json (apiErrorJson "forbidden" message) -- | 413 reply413 :: ActionM () reply413 = do status status413 json (apiErrorJson "too_large" "request body too large") -- | 500(部署/后端问题专用,用户输入错误一律 400) reply500 :: Text -> ActionM () reply500 message = do status status500 json (apiErrorJson "internal" message) -- | 在字典里按名字找表 findTable :: Text -> [TableInfo] -> Maybe TableInfo findTable name = go where -- | 顺序找表 go [] = Nothing go (i : is) = if tiTable i == T.unpack name then Just i else go is -- | 登录请求体 data LoginRequest = LoginRequest { lrUser :: Text , lrPassword :: Text } instance FromJSON LoginRequest where parseJSON = withObject "LoginRequest" $ \o -> LoginRequest <$> o .: "user" <*> o .: "password" -- | 查询请求体 data QueryRequest = QueryRequest { qrSql :: Text } instance FromJSON QueryRequest where parseJSON = withObject "QueryRequest" $ \o -> QueryRequest <$> o .: "sql"