2026-09-25 17:41:51 +08:00
|
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
module ChuSQL.Web.API (
|
2026-09-25 17:41:51 +08:00
|
|
|
|
AppEnv (..),
|
2026-10-02 14:53:02 +08:00
|
|
|
|
WebSession (..),
|
2026-09-25 17:41:51 +08:00
|
|
|
|
Live (..),
|
|
|
|
|
|
defaultLive,
|
|
|
|
|
|
setLive,
|
|
|
|
|
|
readLive,
|
|
|
|
|
|
applyLiveKeys,
|
|
|
|
|
|
webApp,
|
|
|
|
|
|
sessionCookieName,
|
|
|
|
|
|
parseCookieHeader,
|
|
|
|
|
|
isIdentifier,
|
|
|
|
|
|
renderRow,
|
|
|
|
|
|
tableInfoJson,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
systemTableInfo,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
apiErrorJson,
|
|
|
|
|
|
newAppEnv,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
newAppEnvAt,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
) where
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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 (
|
2026-09-25 17:41:51 +08:00
|
|
|
|
ColumnSpec (..),
|
|
|
|
|
|
CreateTableSpec (..),
|
2026-10-02 14:53:02 +08:00
|
|
|
|
alterUserSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
coerceValue,
|
|
|
|
|
|
columnTypeOf,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
createDatabaseSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
createIndexSql,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
createRoleSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
createTableSql,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
createUserSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
deleteRowSql,
|
|
|
|
|
|
dropColumnSql,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
dropDatabaseSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
dropIndexSql,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
dropRoleSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
dropTableSql,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
dropUserSql,
|
|
|
|
|
|
grantPrivilegesSql,
|
|
|
|
|
|
grantRoleSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
insertRowSql,
|
|
|
|
|
|
isIdentifier,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
revokePrivilegesSql,
|
|
|
|
|
|
revokeRoleSql,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
selectRowsSql,
|
|
|
|
|
|
updateRowSql,
|
|
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import ChuSQL.Interface.Auth (
|
|
|
|
|
|
PayloadStore,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
SessionPolicy (..),
|
2026-10-02 14:53:02 +08:00
|
|
|
|
createPayloadSession,
|
|
|
|
|
|
defaultSessionPolicy,
|
|
|
|
|
|
dropPayload,
|
|
|
|
|
|
lookupPayload,
|
|
|
|
|
|
newPayloadStore,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
sessionToken,
|
2026-10-02 14:53:02 +08:00
|
|
|
|
setPayloadPolicy,
|
|
|
|
|
|
sweepPayloads,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import ChuSQL.Interface.Link (
|
|
|
|
|
|
Client,
|
|
|
|
|
|
clientPing,
|
|
|
|
|
|
clientPolicy,
|
|
|
|
|
|
clientReloadPolicy,
|
|
|
|
|
|
closeClient,
|
|
|
|
|
|
connectClient,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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 (
|
2026-09-25 17:41:51 +08:00
|
|
|
|
SettingItem (..),
|
|
|
|
|
|
applySettings,
|
|
|
|
|
|
defaultOf,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
isLockedSetting,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
liveKeys,
|
|
|
|
|
|
readSettingsFile,
|
|
|
|
|
|
settingCatalogue,
|
|
|
|
|
|
writeSettingsFile,
|
|
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import ChuSQL.Interface.TOML (defaultConfigFile)
|
|
|
|
|
|
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
|
|
|
|
|
|
import ChuSQL.Web.Secure (bodyLimitDynamic, sameOriginOnly, securityHeaders, contentSecurityPolicy)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import ChuSQL.Web.UISettings (
|
|
|
|
|
|
readUISettings,
|
|
|
|
|
|
resolveUISettingsFile,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
uiSettingsFileCandidates,
|
2026-10-01 20:48:38 +08:00
|
|
|
|
validateUISettings,
|
|
|
|
|
|
writeUISettings,
|
2026-09-25 17:41:51 +08:00
|
|
|
|
)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, withMVar)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import qualified Data.ByteString.Lazy as BL
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Data.List (nub, sortBy)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Data.Map.Strict as Map
|
|
|
|
|
|
import Data.Maybe (catMaybes, fromMaybe)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
import Data.Scientific (fromFloatDigits)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import Data.Text (Text)
|
|
|
|
|
|
import qualified Data.Text as T
|
|
|
|
|
|
import qualified Data.Text.Encoding as TE
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import qualified Data.Text.Encoding.Error as TEE
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Data.Text.Lazy as TL
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Data.Time.Clock (getCurrentTime)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import qualified Data.Vector as V
|
2026-10-02 14:53:02 +08:00
|
|
|
|
import Network.HTTP.Types (Status, status204, status400, status401, status403, status404, status409, status413, status429, status500)
|
|
|
|
|
|
import Network.Wai (Application, Request (..))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
import Text.Read (readMaybe)
|
|
|
|
|
|
import Web.Scotty
|
|
|
|
|
|
|
|
|
|
|
|
-- REST API 与页面交付:鉴权、JSON 与引擎结果的互转、错误到状态码的映射。
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一组可热改的运行参数
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | Web 进程共享的运行环境
|
2026-09-25 17:41:51 +08:00
|
|
|
|
data AppEnv = AppEnv
|
2026-10-02 14:53:02 +08:00
|
|
|
|
{ aeServerHost :: Text
|
|
|
|
|
|
, aeServerPort :: Int
|
|
|
|
|
|
, aeSessions :: PayloadStore WebSession
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, aeLimiter :: RateLimiter
|
|
|
|
|
|
, aeStaticDir :: FilePath
|
|
|
|
|
|
, aeCookieSecure :: Bool
|
|
|
|
|
|
, aeLive :: IORef Live
|
|
|
|
|
|
, aeSettingsFile :: FilePath
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, aeUISettingsFile :: FilePath
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, aeEffective :: Map.Map Text Text
|
|
|
|
|
|
, aeLog :: Text -> IO ()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一条网页会话:TCP 连接、登录状态与账号名
|
|
|
|
|
|
data WebSession = WebSession
|
|
|
|
|
|
{ wsClient :: Client
|
|
|
|
|
|
, wsSession :: Session
|
|
|
|
|
|
, wsUser :: Text
|
|
|
|
|
|
, wsAdmin :: Bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 会话 Cookie 名
|
|
|
|
|
|
sessionCookieName :: Text
|
|
|
|
|
|
sessionCookieName = "chusql_session"
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 造一份运行环境,各项上限用内置默认
|
|
|
|
|
|
newAppEnv :: Text -> Int -> RateLimiter -> FilePath -> IO AppEnv
|
|
|
|
|
|
newAppEnv host port limiter staticDir = do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
settingsFile <- resolveSettingsFileSafe
|
2026-10-02 14:53:02 +08:00
|
|
|
|
newAppEnvAt settingsFile host port limiter staticDir
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 同上,但显式指定配置文件路径
|
|
|
|
|
|
newAppEnvAt :: FilePath -> Text -> Int -> RateLimiter -> FilePath -> IO AppEnv
|
|
|
|
|
|
newAppEnvAt settingsFile host port limiter staticDir = do
|
|
|
|
|
|
sessions <- newPayloadStore getCurrentTime defaultSessionPolicy
|
2026-10-01 20:48:38 +08:00
|
|
|
|
liveRef <- newIORef defaultLive
|
|
|
|
|
|
uiSettingsFile <- resolveUISettingsFileSafe
|
2026-09-25 17:41:51 +08:00
|
|
|
|
pure
|
|
|
|
|
|
AppEnv
|
2026-10-02 14:53:02 +08:00
|
|
|
|
{ aeServerHost = host
|
|
|
|
|
|
, aeServerPort = port
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, aeSessions = sessions
|
|
|
|
|
|
, aeLimiter = limiter
|
|
|
|
|
|
, aeStaticDir = staticDir
|
|
|
|
|
|
, aeCookieSecure = False
|
|
|
|
|
|
, aeLive = liveRef
|
|
|
|
|
|
, aeSettingsFile = settingsFile
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, aeUISettingsFile = uiSettingsFile
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, aeEffective = Map.empty
|
|
|
|
|
|
, aeLog = const (pure ())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 系统库:账号表住在这里,只有管理员能进
|
2026-10-01 20:48:38 +08:00
|
|
|
|
systemDatabase :: Text
|
|
|
|
|
|
systemDatabase = "system"
|
|
|
|
|
|
|
|
|
|
|
|
-- | 保留库:只有系统库删不掉、也建不了。
|
|
|
|
|
|
reservedDatabase :: Text -> Bool
|
|
|
|
|
|
reservedDatabase name = name == systemDatabase
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 定位配置文件路径,失败时回落到固定名
|
2026-09-25 17:41:51 +08:00
|
|
|
|
resolveSettingsFileSafe :: IO FilePath
|
|
|
|
|
|
resolveSettingsFileSafe = do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
found <- try defaultConfigFile :: IO (Either IOException FilePath)
|
|
|
|
|
|
pure (either (const "chusql.toml") id found)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 定位 IDE 设置文件,找不到就定下首个候选路径
|
2026-10-01 20:48:38 +08:00
|
|
|
|
resolveUISettingsFileSafe :: IO FilePath
|
|
|
|
|
|
resolveUISettingsFileSafe = do
|
|
|
|
|
|
found <- try resolveUISettingsFile :: IO (Either IOException FilePath)
|
|
|
|
|
|
pure (either (const fallbackUISettingsFile) id found)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
where
|
2026-10-01 20:48:38 +08:00
|
|
|
|
fallbackUISettingsFile = case uiSettingsFileCandidates of
|
2026-09-25 17:41:51 +08:00
|
|
|
|
(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
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 组装 WAI 应用:路由外套四层中间件
|
2026-09-25 17:41:51 +08:00
|
|
|
|
webApp :: AppEnv -> IO Application
|
|
|
|
|
|
webApp env = do
|
|
|
|
|
|
inner <- scottyApp (routes env)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 路由表:界面一键操作全走结构化接口
|
|
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
notFound (notFoundH env)
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 先认会话,再按请求头切本会话的库
|
2026-10-01 20:48:38 +08:00
|
|
|
|
withDatabase :: AppEnv -> (AppEnv -> ActionM ()) -> ActionM ()
|
|
|
|
|
|
withDatabase env action = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
chosen <- maybe "" (T.toLower . TL.toStrict) <$> header "X-ChuSQL-Database"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
if not (T.null chosen) && (not (isPlainIdentifier chosen) || T.length chosen > 64)
|
|
|
|
|
|
then reply400 "bad_request" "invalid database name"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
else
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 取当前库名,未选库时报错
|
|
|
|
|
|
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"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 数据库清单;系统库 system 只给管理员看。
|
|
|
|
|
|
databasesH :: AppEnv -> ActionM ()
|
|
|
|
|
|
databasesH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
result <- liftIO (databases (wsSession ws))
|
2026-10-01 20:48:38 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
|
|
|
|
|
Right names -> json (if wsAdmin ws then names else filter (/= systemDatabase) names)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 新建数据库:校验名字后交给 server
|
2026-10-01 20:48:38 +08:00
|
|
|
|
createDatabaseH :: AppEnv -> ActionM ()
|
|
|
|
|
|
createDatabaseH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 删除数据库;系统库 system 一律拒绝。
|
|
|
|
|
|
dropDatabaseH :: AppEnv -> ActionM ()
|
|
|
|
|
|
dropDatabaseH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 首页:登录与管理界面同一个 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 "</head>" page
|
|
|
|
|
|
nonceMeta = TE.encodeUtf8 ("<meta name=\"chusql-style-nonce\" content=\"" <> 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)])
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 探活:回进程与存储状态,顺带清理过期会话
|
2026-09-25 17:41:51 +08:00
|
|
|
|
statusH :: AppEnv -> ActionM ()
|
|
|
|
|
|
statusH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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])
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 登录:对了发 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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- 口令允许为空:配置里口令留空时管理员就是免密登录
|
|
|
|
|
|
if T.null user || T.length user > 64 || T.length password > 256
|
|
|
|
|
|
then reply400 "bad_request" "user name is empty or too long"
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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")
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Nothing -> openSession env user password
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 用用户口令连 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
|
2026-09-25 17:41:51 +08:00
|
|
|
|
logoutH :: AppEnv -> ActionM ()
|
|
|
|
|
|
logoutH env = do
|
|
|
|
|
|
token <- requestSessionToken
|
|
|
|
|
|
case token of
|
|
|
|
|
|
Nothing -> pure ()
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Just t -> do
|
|
|
|
|
|
gone <- liftIO (dropPayload (aeSessions env) t)
|
|
|
|
|
|
case gone of
|
|
|
|
|
|
Nothing -> pure ()
|
|
|
|
|
|
Just ws -> liftIO (closeClient (wsClient ws))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env "" (Just 0)))
|
|
|
|
|
|
status status204
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 当前登录者与生效的口令策略
|
2026-09-25 17:41:51 +08:00
|
|
|
|
sessionH :: AppEnv -> ActionM ()
|
|
|
|
|
|
sessionH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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]
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 读设置:文件值、启动生效值与内置默认一起给出
|
|
|
|
|
|
settingsH :: AppEnv -> ActionM ()
|
|
|
|
|
|
settingsH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
2026-09-25 17:41:51 +08:00
|
|
|
|
fileValues <- liftIO (readSettingsFile (aeSettingsFile env))
|
2026-10-02 14:53:02 +08:00
|
|
|
|
let owner = wsAdmin ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
json
|
|
|
|
|
|
( object
|
|
|
|
|
|
[ "file" .= aeSettingsFile env
|
2026-10-02 14:53:02 +08:00
|
|
|
|
, "account" .= wsUser ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, "owner" .= owner
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, "items" .= map (itemJson (aeEffective env) fileValues owner) settingCatalogue
|
2026-09-25 17:41:51 +08:00
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
-- | 一个配置项在界面上的样子
|
2026-10-01 20:48:38 +08:00
|
|
|
|
itemJson :: Map.Map Text Text -> Map.Map Text Text -> Bool -> SettingItem -> A.Value
|
|
|
|
|
|
itemJson effectiveValues fileValues owner spec =
|
2026-09-25 17:41:51 +08:00
|
|
|
|
object
|
|
|
|
|
|
[ "key" .= siKey spec
|
|
|
|
|
|
, "label" .= siLabel spec
|
|
|
|
|
|
, "group" .= siGroup spec
|
|
|
|
|
|
, "kind" .= siKind spec
|
|
|
|
|
|
, "default" .= siDefault spec
|
|
|
|
|
|
, "value" .= shownValue
|
|
|
|
|
|
, "source" .= source
|
|
|
|
|
|
, "rootOnly" .= siRootOnly spec
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, "editable" .= (not (isLockedSetting (siKey spec)) && (owner || not (siRootOnly spec)))
|
|
|
|
|
|
, "locked" .= isLockedSetting (siKey spec)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, "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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
hasValue = fromFile /= Nothing
|
2026-09-25 17:41:51 +08:00
|
|
|
|
source :: Text
|
|
|
|
|
|
source
|
|
|
|
|
|
| secret = if hasValue then "configured" else "not set"
|
|
|
|
|
|
| fromFile /= Nothing = "settings file"
|
|
|
|
|
|
| otherwise = "default"
|
|
|
|
|
|
|
|
|
|
|
|
-- | 改设置:写配置文件并让可热改项立即生效
|
|
|
|
|
|
putSettingsH :: AppEnv -> ActionM ()
|
|
|
|
|
|
putSettingsH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
let user = wsUser ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
withJsonObject env $ \o -> case settingsUpdates o of
|
|
|
|
|
|
Left err -> reply400 "bad_request" err
|
|
|
|
|
|
Right updates -> do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
let locked = [k | k <- Map.keys updates, isLockedSetting k]
|
|
|
|
|
|
forbidden =
|
2026-09-25 17:41:51 +08:00
|
|
|
|
[ k
|
|
|
|
|
|
| k <- Map.keys updates
|
|
|
|
|
|
, maybe False siRootOnly (findSetting k)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
, not (wsAdmin ws)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
]
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- 口令策略住在 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))]
|
2026-10-01 20:48:38 +08:00
|
|
|
|
liftIO (aeLog env ("settings updated by " <> user <> ": " <> T.intercalate ", " (Map.keys updates)))
|
|
|
|
|
|
json
|
|
|
|
|
|
( object
|
|
|
|
|
|
[ "ok" .= True
|
|
|
|
|
|
, "file" .= aeSettingsFile env
|
2026-10-02 14:53:02 +08:00
|
|
|
|
, "applied" .= appliedNow
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, "restartRequired" .= needsRestart
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 读前端 IDE 的设置文件
|
2026-09-25 17:41:51 +08:00
|
|
|
|
uiSettingsH :: AppEnv -> ActionM ()
|
|
|
|
|
|
uiSettingsH env = do
|
|
|
|
|
|
_ <- requireUser env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
value <- liftIO (readUISettings (aeUISettingsFile env))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
json value
|
|
|
|
|
|
|
|
|
|
|
|
-- | 整份覆盖前端 IDE 设置,校验不过一律 400
|
2026-10-01 20:48:38 +08:00
|
|
|
|
putUISettingsH :: AppEnv -> ActionM ()
|
|
|
|
|
|
putUISettingsH env = do
|
2026-09-25 17:41:51 +08:00
|
|
|
|
_ <- 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"
|
2026-10-01 20:48:38 +08:00
|
|
|
|
Right payload -> case validateUISettings payload of
|
2026-09-25 17:41:51 +08:00
|
|
|
|
Left err -> reply400 "bad_request" (T.pack err)
|
|
|
|
|
|
Right valid -> do
|
2026-10-01 20:48:38 +08:00
|
|
|
|
written <- liftIO (writeUISettings (aeUISettingsFile env) valid)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
case written of
|
|
|
|
|
|
Left err -> reply500 (T.pack err)
|
|
|
|
|
|
Right () -> status status204
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 点账号表时只认系统库,否则 400
|
|
|
|
|
|
accountRoute :: WebSession -> Text -> ActionM Bool
|
|
|
|
|
|
accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
| not (isAccountTable name) = pure False
|
2026-10-02 14:53:02 +08:00
|
|
|
|
| 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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 账号行:平铺的各列
|
|
|
|
|
|
accountRowsOf :: [Account] -> [Row]
|
|
|
|
|
|
accountRowsOf = map accountRow
|
|
|
|
|
|
where
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一个账号转成一行
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一列转成线上 JSON
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 读一遍账号表,读不到就按 server 给的码回
|
|
|
|
|
|
requireAccounts :: WebSession -> ActionM [Account]
|
|
|
|
|
|
requireAccounts ws = do
|
|
|
|
|
|
result <- liftIO (FrontSession.accounts (wsSession ws))
|
|
|
|
|
|
either serverError pure result
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 口令列不接受排序与筛选
|
|
|
|
|
|
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 ()
|
|
|
|
|
|
|
|
|
|
|
|
-- | 账号表的一页数据(排序与筛选都在内存里做)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
baseRowsH :: WebSession -> AppEnv -> ActionM ()
|
|
|
|
|
|
baseRowsH ws env = do
|
|
|
|
|
|
adminOnly ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
accounts <- requireAccounts ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 比两个账号单元
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 取账号行里某列的文本值
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 行是否满足一条等值条件
|
2026-10-01 20:48:38 +08:00
|
|
|
|
matches row (column, wanted) = case lookup (T.unpack column) row of
|
|
|
|
|
|
Just value -> value == wanted
|
|
|
|
|
|
Nothing -> False
|
|
|
|
|
|
|
|
|
|
|
|
-- | 账号表写入:插一行 = CREATE USER
|
2026-10-02 14:53:02 +08:00
|
|
|
|
baseInsertH :: AppEnv -> WebSession -> ActionM ()
|
|
|
|
|
|
baseInsertH env ws = do
|
|
|
|
|
|
adminOnly ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
(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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
_ -> reply400 "bad_request" "user and password are required"
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 账号表写入:改一行 = ALTER USER
|
|
|
|
|
|
baseUpdateH :: AppEnv -> WebSession -> Text -> ActionM ()
|
|
|
|
|
|
baseUpdateH env ws target = do
|
|
|
|
|
|
adminOnly ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
([(_, VStr password)], Just _) -> case alterUserSql target (T.pack password) of
|
|
|
|
|
|
Left err -> reply400 "bad_request" (T.pack err)
|
|
|
|
|
|
Right sql -> runWrite ws sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
_ -> reply400 "bad_request" "only the password of an existing account can be changed"
|
|
|
|
|
|
|
|
|
|
|
|
-- | 账号表写入:删一行 = DROP USER
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 取一个列值
|
|
|
|
|
|
lookupValue :: Text -> [(String, Value)] -> Maybe Value
|
|
|
|
|
|
lookupValue name values = lookup (T.unpack name) values
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 错误码 + 消息 → 状态码 + 统一错误体
|
|
|
|
|
|
apiError :: Text -> Text -> ActionM a
|
|
|
|
|
|
apiError code message = do
|
|
|
|
|
|
status (statusOf code)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
json (apiErrorJson code message)
|
|
|
|
|
|
finish
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 拆开 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
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 按 key 找目录项
|
|
|
|
|
|
findSetting :: Text -> Maybe SettingItem
|
|
|
|
|
|
findSetting key = case [i | i <- settingCatalogue, siKey i == key] of
|
|
|
|
|
|
(i : _) -> Just i
|
|
|
|
|
|
[] -> Nothing
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 把热改设置应用到当前进程
|
2026-09-25 17:41:51 +08:00
|
|
|
|
applyLiveKeys :: AppEnv -> Map.Map Text Text -> IO [Text]
|
|
|
|
|
|
applyLiveKeys env updates = do
|
|
|
|
|
|
before <- readLive env
|
|
|
|
|
|
let wanted = [k | k <- liveKeys, Map.member k updates]
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 取热改项的整数值
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
setPayloadPolicy
|
2026-09-25 17:41:51 +08:00
|
|
|
|
(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)
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 把字符串解析成整数,坏值给 0
|
2026-09-25 17:41:51 +08:00
|
|
|
|
intFromText :: Text -> Int
|
|
|
|
|
|
intFromText given = fromMaybe 0 (readMaybe (T.unpack (T.strip given)))
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 表清单;管理员额外看到账号表
|
2026-09-25 17:41:51 +08:00
|
|
|
|
tablesH :: AppEnv -> ActionM ()
|
|
|
|
|
|
tablesH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
current <- requireDatabaseName ws
|
|
|
|
|
|
result <- liftIO (catalog (wsSession ws))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-10-01 20:48:38 +08:00
|
|
|
|
Right infos -> do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
let tables = [info | info <- infos, not (isAccountTable (T.pack (tiTable info)))]
|
|
|
|
|
|
extra <- if current == systemDatabase && wsAdmin ws
|
|
|
|
|
|
then (: []) . systemTableJson . length <$> requireAccounts ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
else pure []
|
2026-10-02 14:53:02 +08:00
|
|
|
|
json (map tableInfoJson tables ++ extra)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 单表结构
|
|
|
|
|
|
tableH :: AppEnv -> ActionM ()
|
|
|
|
|
|
tableH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
|
|
|
|
|
then do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
adminOnly ws
|
|
|
|
|
|
accounts <- requireAccounts ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
json (systemTableJson (length accounts))
|
|
|
|
|
|
else
|
|
|
|
|
|
if not (isIdentifier name)
|
|
|
|
|
|
then reply400 "bad_request" "invalid table name"
|
|
|
|
|
|
else do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
result <- liftIO (catalog (wsSession ws))
|
2026-10-01 20:48:38 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-10-01 20:48:38 +08:00
|
|
|
|
Right infos -> case findTable name infos of
|
|
|
|
|
|
Nothing -> reply404
|
|
|
|
|
|
Just info -> json (tableInfoJson info)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 分页浏览一张表,支持排序与等值过滤;账号表走内存里的账号行
|
2026-09-25 17:41:51 +08:00
|
|
|
|
rowsH :: AppEnv -> ActionM ()
|
|
|
|
|
|
rowsH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
2026-10-02 14:53:02 +08:00
|
|
|
|
then baseRowsH ws env
|
|
|
|
|
|
else
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if not (isIdentifier name)
|
|
|
|
|
|
then reply400 "bad_request" "invalid table name"
|
|
|
|
|
|
else do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 等值过滤条件:列名与原值
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一条过滤条件转成列与引擎值
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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))
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 按列名找表结构里的列
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | dir 参数转排序方向
|
2026-09-25 17:41:51 +08:00
|
|
|
|
dirOf given = case fmap (T.toLower . T.strip) given of
|
|
|
|
|
|
Just "desc" -> False
|
|
|
|
|
|
_ -> True
|
|
|
|
|
|
|
|
|
|
|
|
-- | 建表:按列结构拼 CREATE TABLE
|
|
|
|
|
|
createTableH :: AppEnv -> ActionM ()
|
|
|
|
|
|
createTableH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
|
|
|
|
|
adminOnly ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 删表;账号表不许删
|
2026-09-25 17:41:51 +08:00
|
|
|
|
dropTableH :: AppEnv -> ActionM ()
|
|
|
|
|
|
dropTableH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
|
|
|
|
|
adminOnly ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 插一行;账号表上等于 CREATE USER
|
2026-09-25 17:41:51 +08:00
|
|
|
|
insertRowH :: AppEnv -> ActionM ()
|
|
|
|
|
|
insertRowH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
2026-10-02 14:53:02 +08:00
|
|
|
|
then baseInsertH env ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
else do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
info <- requireTable ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of
|
|
|
|
|
|
Left err -> reply400 "bad_request" err
|
|
|
|
|
|
Right row -> case insertRowSql name row of
|
2026-09-25 17:41:51 +08:00
|
|
|
|
Left err -> reply400 "bad_request" (T.pack err)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 改一行,只改给出的列
|
2026-10-01 20:48:38 +08:00
|
|
|
|
updateRowH :: AppEnv -> ActionM ()
|
|
|
|
|
|
updateRowH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
|
|
|
|
|
then do
|
|
|
|
|
|
target <- pathParam "id"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
baseUpdateH env ws target
|
2026-10-01 20:48:38 +08:00
|
|
|
|
else do
|
|
|
|
|
|
key <- rowIdParam
|
2026-10-02 14:53:02 +08:00
|
|
|
|
info <- requireTable ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 删一行;账号表上等于 DROP USER
|
|
|
|
|
|
deleteRowH :: AppEnv -> ActionM ()
|
|
|
|
|
|
deleteRowH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
|
|
|
|
|
then do
|
|
|
|
|
|
target <- pathParam "id"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
baseDeleteH env ws target
|
2026-10-01 20:48:38 +08:00
|
|
|
|
else do
|
|
|
|
|
|
key <- rowIdParam
|
2026-10-02 14:53:02 +08:00
|
|
|
|
_ <- requireTable ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
case deleteRowSql name key of
|
|
|
|
|
|
Left err -> reply400 "bad_request" (T.pack err)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 建索引,索引列必须整数且唯一;账号表没有索引可建
|
|
|
|
|
|
createIndexH :: AppEnv -> ActionM ()
|
|
|
|
|
|
createIndexH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
|
|
|
|
|
adminOnly ws
|
2026-10-01 20:48:38 +08:00
|
|
|
|
name <- pathParam "t"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
|
|
|
|
|
then reply400 "bad_request" "the account table has no indexes"
|
|
|
|
|
|
else do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
info <- requireTable ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 删索引,内建 id 索引不放行
|
2026-09-25 17:41:51 +08:00
|
|
|
|
dropIndexH :: AppEnv -> ActionM ()
|
|
|
|
|
|
dropIndexH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
|
|
|
|
|
adminOnly ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
name <- pathParam "t"
|
|
|
|
|
|
column <- pathParam "col"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
|
|
|
|
|
then reply400 "bad_request" "the account table has no indexes"
|
|
|
|
|
|
else do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
_ <- requireTable ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 删列,内建 id 列不放行;账号表的列是固定的
|
2026-09-25 17:41:51 +08:00
|
|
|
|
dropColumnH :: AppEnv -> ActionM ()
|
|
|
|
|
|
dropColumnH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
|
|
|
|
|
adminOnly ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
name <- pathParam "t"
|
|
|
|
|
|
column <- pathParam "col"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
account <- accountRoute ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
if account
|
|
|
|
|
|
then reply400 "bad_request" "the account table has no columns to drop"
|
|
|
|
|
|
else do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
_ <- requireTable ws name
|
2026-10-01 20:48:38 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right sql -> runWrite ws sql
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 一键灌演示数据(缺什么补什么,可以反复点)
|
|
|
|
|
|
demoDataH :: AppEnv -> ActionM ()
|
|
|
|
|
|
demoDataH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
|
|
|
|
|
_ <- requireDatabaseName ws
|
|
|
|
|
|
result <- liftIO (seedDemo (wsSession ws))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-09-25 17:41:51 +08:00
|
|
|
|
Right report ->
|
|
|
|
|
|
json
|
|
|
|
|
|
( object
|
|
|
|
|
|
[ "ok" .= True
|
|
|
|
|
|
, "created" .= srCreated report
|
|
|
|
|
|
, "skipped" .= srSkipped report
|
|
|
|
|
|
, "indexes" .= srIndexes report
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-01 20:48:38 +08:00
|
|
|
|
-- | 角色总览:只有管理员能看
|
|
|
|
|
|
rolesH :: AppEnv -> ActionM ()
|
|
|
|
|
|
rolesH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
|
|
|
|
|
result <- liftIO (roleViews (wsSession ws))
|
2026-10-01 20:48:38 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-10-01 20:48:38 +08:00
|
|
|
|
Right views -> json (map roleViewJson views)
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一个角色的线上信息
|
2026-10-01 20:48:38 +08:00
|
|
|
|
roleViewJson :: RoleView -> A.Value
|
|
|
|
|
|
roleViewJson view =
|
|
|
|
|
|
object
|
|
|
|
|
|
[ "name" .= roleName view
|
|
|
|
|
|
, "grants" .= map grantJson (roleGrants view)
|
|
|
|
|
|
, "members" .= roleMembers view
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一条授权的线上信息
|
2026-10-01 20:48:38 +08:00
|
|
|
|
grantJson :: Grant -> A.Value
|
|
|
|
|
|
grantJson grant =
|
|
|
|
|
|
object
|
|
|
|
|
|
[ "privilege" .= grantPrivilege grant
|
|
|
|
|
|
, "object" .= grantObject grant
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
-- | 建角色
|
|
|
|
|
|
createRoleH :: AppEnv -> ActionM ()
|
2026-10-02 14:53:02 +08:00
|
|
|
|
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)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 删角色(连带清掉它的授权与成员)
|
|
|
|
|
|
dropRoleH :: AppEnv -> ActionM ()
|
2026-10-02 14:53:02 +08:00
|
|
|
|
dropRoleH env = do
|
|
|
|
|
|
ws <- requireAdminSession env
|
|
|
|
|
|
name <- pathParam "name"
|
|
|
|
|
|
runPrivilegeSql ws (dropRoleSql name)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 给角色加权限
|
|
|
|
|
|
grantRoleH :: AppEnv -> ActionM ()
|
|
|
|
|
|
grantRoleH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
role <- pathParam "name"
|
|
|
|
|
|
withJsonObject env $ \o -> case grantFields o of
|
|
|
|
|
|
Left err -> reply400 "bad_request" err
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right (privileges, objectName) -> runPrivilegeSql ws (grantPrivilegesSql privileges objectName role)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 收角色的权限
|
|
|
|
|
|
revokeRoleH :: AppEnv -> ActionM ()
|
|
|
|
|
|
revokeRoleH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
role <- pathParam "name"
|
|
|
|
|
|
withJsonObject env $ \o -> case grantFields o of
|
|
|
|
|
|
Left err -> reply400 "bad_request" err
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right (privileges, objectName) -> runPrivilegeSql ws (revokePrivilegesSql privileges objectName role)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 把用户加进角色
|
|
|
|
|
|
addRoleMemberH :: AppEnv -> ActionM ()
|
|
|
|
|
|
addRoleMemberH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
role <- pathParam "name"
|
|
|
|
|
|
withJsonObject env $ \o -> case memberFields o of
|
|
|
|
|
|
Left err -> reply400 "bad_request" err
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Right users -> runPrivilegeSql ws (grantRoleSql role users)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 把用户移出角色
|
|
|
|
|
|
removeRoleMemberH :: AppEnv -> ActionM ()
|
|
|
|
|
|
removeRoleMemberH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireAdminSession env
|
2026-10-01 20:48:38 +08:00
|
|
|
|
role <- pathParam "name"
|
|
|
|
|
|
user <- pathParam "user"
|
2026-10-02 14:53:02 +08:00
|
|
|
|
runPrivilegeSql ws (revokeRoleSql role [user])
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 权限(一条或一串)与对象
|
|
|
|
|
|
grantFields :: A.Object -> Either Text ([Text], Text)
|
|
|
|
|
|
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"
|
|
|
|
|
|
if null privileges then Left "at least one privilege is required" else pure (privileges, objectName)
|
|
|
|
|
|
|
|
|
|
|
|
-- | 成员(一条或一串)
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 只接受字符串的取值
|
2026-10-01 20:48:38 +08:00
|
|
|
|
stringValue :: A.Value -> Either Text Text
|
|
|
|
|
|
stringValue (A.String value) = Right value
|
|
|
|
|
|
stringValue _ = Left "values must be strings"
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 角色与授权命令:拼 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])
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 写操作统一回执
|
2026-10-02 14:53:02 +08:00
|
|
|
|
runWrite :: WebSession -> String -> ActionM ()
|
|
|
|
|
|
runWrite ws sql = do
|
|
|
|
|
|
result <- liftIO (runStatement (wsSession ws) (T.pack sql))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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 短路
|
2026-10-02 14:53:02 +08:00
|
|
|
|
requireTable :: WebSession -> Text -> ActionM TableInfo
|
|
|
|
|
|
requireTable ws name
|
2026-09-25 17:41:51 +08:00
|
|
|
|
| not (isIdentifier name) = do
|
|
|
|
|
|
reply400 "bad_request" "invalid table name"
|
|
|
|
|
|
finish
|
|
|
|
|
|
| otherwise = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
result <- liftIO (catalog (wsSession ws))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一个设置值转成文本
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一列 {name, type} 转成列结构
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一个列值按结构转成引擎值
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 按列名找表结构里的列
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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)
|
|
|
|
|
|
[] -> ""
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 查询接口:校验请求体后执行 SQL
|
2026-09-25 17:41:51 +08:00
|
|
|
|
queryH :: AppEnv -> ActionM ()
|
|
|
|
|
|
queryH env = do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
ws <- requireSession env
|
|
|
|
|
|
let user = wsUser ws
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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))
|
2026-10-02 14:53:02 +08:00
|
|
|
|
executeSql ws live sql
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 把 SQL 交给 server,结果按控制台口径回执
|
|
|
|
|
|
executeSql :: WebSession -> Live -> Text -> ActionM ()
|
|
|
|
|
|
executeSql ws live sql = do
|
|
|
|
|
|
result <- liftIO (runStatement (wsSession ws) sql)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
case result of
|
2026-10-02 14:53:02 +08:00
|
|
|
|
Left message -> serverError message
|
2026-10-01 20:48:38 +08:00
|
|
|
|
Right res -> do
|
2026-10-02 14:53:02 +08:00
|
|
|
|
let shown = take (lvMaxRows live) (qrRows res)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
json
|
|
|
|
|
|
( object
|
2026-10-02 14:53:02 +08:00
|
|
|
|
[ "columns" .= qrColumns res
|
|
|
|
|
|
, "rows" .= map (map valueJson) shown
|
|
|
|
|
|
, "rowCount" .= qrRowCount res
|
|
|
|
|
|
, "truncated" .= (qrTruncated res || qrRowCount res > lvMaxRows live)
|
|
|
|
|
|
, "database" .= qrDatabase res
|
2026-10-01 20:48:38 +08:00
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-25 17:41:51 +08:00
|
|
|
|
-- | 拿当前 Session 用户;没有就 401
|
|
|
|
|
|
requireUser :: AppEnv -> ActionM Text
|
2026-10-02 14:53:02 +08:00
|
|
|
|
requireUser env = wsUser <$> requireSession env
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
|
|
-- | 请求里的会话令牌
|
|
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 从一段 Cookie 里取指定键的值
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-01 20:48:38 +08:00
|
|
|
|
valueJson VNull = A.Null
|
2026-09-25 17:41:51 +08:00
|
|
|
|
valueJson (VInt n) = A.Number (fromIntegral n)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
valueJson (VFloat d) = A.Number (fromFloatDigits d)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
valueJson (VStr s) = A.String (T.pack s)
|
|
|
|
|
|
valueJson (VBool b) = A.Bool b
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一张表的线上信息:列、行数、索引与统计
|
2026-09-25 17:41:51 +08:00
|
|
|
|
tableInfoJson :: TableInfo -> A.Value
|
|
|
|
|
|
tableInfoJson info =
|
|
|
|
|
|
object
|
|
|
|
|
|
[ "table" .= tiTable info
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, "kind" .= ("table" :: Text)
|
2026-09-25 17:41:51 +08:00
|
|
|
|
, "rowCount" .= tiRows info
|
|
|
|
|
|
, "indexes" .= [object ["column" .= c, "builtIn" .= (c == builtInIndexColumn)] | c <- tiIndexes info]
|
|
|
|
|
|
, "columns" .= map columnJson (tiColumns info)
|
|
|
|
|
|
]
|
|
|
|
|
|
where
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 一列转成线上 JSON
|
2026-09-25 17:41:51 +08:00
|
|
|
|
columnJson sc =
|
|
|
|
|
|
object
|
|
|
|
|
|
[ "name" .= scName sc
|
|
|
|
|
|
, "type" .= scType sc
|
|
|
|
|
|
, "primaryKey" .= isPrimaryKey
|
|
|
|
|
|
, "indexed" .= isIndexed
|
|
|
|
|
|
, "prime" .= (isPrimaryKey || isIndexed)
|
|
|
|
|
|
, "distinct" .= distinctCount
|
|
|
|
|
|
, "statsCapped" .= statsCapped
|
2026-10-01 20:48:38 +08:00
|
|
|
|
, "secret" .= False
|
|
|
|
|
|
, "nullable" .= scNullable sc
|
|
|
|
|
|
, "default" .= fmap valueJson (scDefault sc)
|
|
|
|
|
|
, "autoIncrement" .= scAutoIncrement sc
|
|
|
|
|
|
, "unique" .= scUnique sc
|
|
|
|
|
|
, "check" .= scCheck sc
|
2026-09-25 17:41:51 +08:00
|
|
|
|
]
|
|
|
|
|
|
where
|
|
|
|
|
|
name = scName sc
|
2026-10-01 20:48:38 +08:00
|
|
|
|
isPrimaryKey = scPrimaryKey sc || (name == builtInIndexColumn && scType sc == "int")
|
2026-09-25 17:41:51 +08:00
|
|
|
|
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
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 顺序找表
|
2026-09-25 17:41:51 +08:00
|
|
|
|
go [] = Nothing
|
|
|
|
|
|
go (i : is) = if tiTable i == T.unpack name then Just i else go is
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 登录请求体
|
2026-09-25 17:41:51 +08:00
|
|
|
|
data LoginRequest = LoginRequest
|
|
|
|
|
|
{ lrUser :: Text
|
|
|
|
|
|
, lrPassword :: Text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
instance FromJSON LoginRequest where
|
|
|
|
|
|
parseJSON = withObject "LoginRequest" $ \o ->
|
|
|
|
|
|
LoginRequest <$> o .: "user" <*> o .: "password"
|
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
|
-- | 查询请求体
|
2026-09-25 17:41:51 +08:00
|
|
|
|
data QueryRequest = QueryRequest
|
|
|
|
|
|
{ qrSql :: Text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
instance FromJSON QueryRequest where
|
|
|
|
|
|
parseJSON = withObject "QueryRequest" $ \o -> QueryRequest <$> o .: "sql"
|