slackblocks-3.1.0: src/Slackblocks/Internal/Rules.hs
-- | Handwritten relationships and receiving-context validation. Only modeled
-- child positions are traversed; arbitrary JSON metadata remains opaque.
module Slackblocks.Internal.Rules (validateFields, checkIntegerBounds, standaloneTask) where
import Control.Monad (unless, when)
import Data.Aeson (Value (..))
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.Char (isAscii, isAsciiUpper, isDigit, isHexDigit)
import Data.List (nub)
import Data.Maybe (isJust)
import Data.Scientific (isInteger)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Slackblocks.Internal.Decimal
import Slackblocks.Internal.JsonSyntax (checkJSONNumbers)
import Slackblocks.Internal.Schema
import Slackblocks.Validation
at :: Text -> Value -> Value
at key (Object fields) = KM.lookup (K.fromText key) fields `orValue` Null
where
orValue (Just value) _ = value; orValue Nothing fallback = fallback
at _ _ = Null
has :: Text -> Value -> Bool
has key (Object fields) = KM.member (K.fromText key) fields
has _ _ = False
array :: Value -> [Value]
array (Array values) = V.toList values
array _ = []
text :: Value -> Text
text (String value) = value
text _ = ""
count :: Value -> Integer
count = fromIntegral . T.length . text
limit :: Text -> Integer
limit key = case lookup key limits of
Just value -> value
Nothing -> error ("internal generated limit missing: " <> T.unpack key)
check :: ErrorCategory -> [PathPiece] -> Text -> Bool -> Result ()
check category path constraint ok = unless ok (failure category path constraint "constraint violated")
-- Reject bounded integer fields while they are still compact Scientific values.
-- Expanding 1e1000000000 into Integer just to compare it with 3000 can exhaust
-- memory. Fractions still reach the ordinary integer type check below.
checkIntegerBounds :: Text -> Value -> Result ()
checkIntegerBounds owner value = mapM_ field (fieldConstraints owner)
where
field (key, kind, prefix, bounds, _) = when (kind `elem` ["int", "long"]) $ case at key value of
number@(Number x) -> do
atField key (checkJSONNumbers number)
when (isInteger x) $ mapM_ (bound key prefix x) bounds
_ -> pure ()
bound key prefix x (suffix, n) = case suffix of
"min" -> check OutOfRange [Field key] (prefix <> ".min") (x >= fromInteger n)
"max" -> check OutOfRange [Field key] (prefix <> ".max") (x <= fromInteger n)
_ -> pure ()
validateFields :: Text -> Value -> Result ()
validateFields owner value = do
checkJSONNumbers value
mapM_ field (fieldConstraints owner)
rules owner value
where
field (key, kind, prefix, bounds, flags) = when (has key value && at key value /= Null) $ do
let v = at key value
measure = case kind of
"string" -> count v
"text" -> count (at "text" v)
_ -> fromIntegral (length (array v))
path = [Field key]
bound (suffix, n) =
let constraint = prefix <> "." <> suffix
lengthCheck predicate = check LengthExceeded path constraint (predicate measure n)
numberCheck predicate = case v of
Number x -> check OutOfRange path constraint (predicate x (fromInteger n))
_ -> failure TypeMismatch path constraint "expected a number"
in case suffix of
"min_length" -> lengthCheck (>=)
"max_length" -> lengthCheck (<=)
"min_items" -> lengthCheck (>=)
"max_items" -> lengthCheck (<=)
"min" -> numberCheck (>=)
"max" -> numberCheck (<=)
"exclusive_min" -> numberCheck (>)
"exclusive_max" -> numberCheck (<)
"item_max_length" -> mapM_ (\(i, item) -> check LengthExceeded (path <> [Index i]) constraint ((if kind == "textList" then count (at "text" item) else count item) <= n)) (zip [0 ..] (array v))
_ -> failure InvalidUsage path constraint "unknown internal constraint"
mapM_ bound bounds
unless (null flags) $ case v of
Object style -> mapM_ (\key' -> check TypeMismatch (path <> [Field (K.toText key')]) "style.flags" (K.toText key' `elem` flags)) (KM.keys style)
_ -> failure TypeMismatch path "style" "expected a style object"
rules :: Text -> Value -> Result ()
rules owner value = case owner of
"ButtonElement" -> check MissingRequired [Field "agent_prompt"] "button.agent_prompt_display.requires_agent_prompt" (not (present "agent_prompt_display") || present "agent_prompt")
"SlackIcon" -> check TypeMismatch [Field "name"] "slack_icon_names" (text (get "name") `elem` icons)
"ExternalMultiSelectElement" -> check InvalidUsage [Field "focus_on_load"] "external_multi_select.focus_on_load" (not (present "focus_on_load"))
"SectionBlock" -> do
check MissingRequired [] "section.content" (present "text" || not (null (array (get "fields"))))
check MissingRequired [Field "fields"] "section.fields" (not (present "fields") || not (null (array (get "fields"))))
"StaticSelectElement" -> options
"StaticMultiSelectElement" -> options
"CardBlock" -> do
check MissingRequired [] "card.content" (any present ["hero_image", "title", "actions", "body"])
check MutuallyExclusive [] "card.icon" (not (present "icon" && present "slack_icon"))
"ContainerBlock" -> do
check MissingRequired [] "container.title" (present "title" || present "rich_text_title")
check InvalidUsage [Field "default_collapsed"] "container.default_collapsed" (get "default_collapsed" /= Bool True || get "is_collapsible" == Bool True)
check InvalidUsage [Field "has_header_divider"] "container.has_header_divider" (get "has_header_divider" /= Bool True || get "is_collapsible" /= Bool True)
"ImageBlock" -> image
"ImageElement" -> image
"SlackFile" -> do
check MutuallyExclusive [] "slack_file.source" (present "id" /= present "url")
when (present "id") $
let identifier = text (get "id")
in check TypeMismatch [Field "id"] "slack_file.id" (T.length identifier >= 9 && T.isPrefixOf "F" identifier && T.all (\c -> isAsciiUpper c || (isAscii c && isDigit c)) (T.drop 1 identifier))
"ConversationFilter" -> do
check MissingRequired [] "conversation_filter.content" (any present ["include", "exclude_external_shared_channels", "exclude_bot_users"])
indexed "include" (\item -> check TypeMismatch [] "conversation_filter.include" (text item `elem` ["im", "mpim", "private", "public"]))
"DispatchActionConfiguration" -> check MissingRequired [Field "trigger_actions_on"] "dispatch_action_configuration.trigger_actions_on" (present "trigger_actions_on")
"DatePickerElement" -> when (present "initial_date") $ check TypeMismatch [Field "initial_date"] "date_picker.initial_date" (dateShape (text (get "initial_date")))
"TimePickerElement" -> when (present "initial_time") $ check TypeMismatch [Field "initial_time"] "time_picker.initial_time" (timeShape (text (get "initial_time")))
"PlainTextInputElement" -> case (get "min_length", get "max_length") of
(Number lo, Number hi) -> check OutOfRange [Field "min_length"] "plain_text_input.bounds" (lo <= hi)
_ -> Right ()
"NumberInputElement" -> do
mapM_ (\key -> when (present key) (check TypeMismatch [Field key] "number_input.decimal" (isJust (decimalParts (text (get key)))))) ["min_value", "max_value"]
when (present "min_value" && present "max_value") $ check OutOfRange [] "number_input.bounds" (compareDecimal (text (get "min_value")) (text (get "max_value")) /= Just GT)
"TableBlock" -> indexed "rows" (\row -> check LengthExceeded [] "table.columns.max_items" (fromIntegral (length (array row)) <= limit "table.columns.max_items"))
"DataTableBlock" -> dataTable value
"AxisConfig" -> do
indexed "categories" (\label -> check LengthExceeded [] "data_visualization.category_label.max_length" (count label <= limit "data_visualization.category_label.max_length"))
check InvalidUsage [Field "categories"] "data_visualization.categories.unique" (unique (array (get "categories")))
"ChartSegment" -> check OutOfRange [Field "value"] "data_visualization.segment.value.exclusive_min" (case get "value" of Number number -> number > fromInteger (limit "data_visualization.segment.value.exclusive_min"); _ -> False)
"LineChart" -> chart value
"BarChart" -> chart value
"AreaChart" -> chart value
"PlanBlock" -> check InvalidUsage [Field "tasks"] "plan.tasks.unique" (unique (map (at "task_id") (array (get "tasks"))))
"Attachment" -> do
when (present "color") $
let color = text (get "color")
in check TypeMismatch [Field "color"] "attachment.color" (color `elem` ["good", "warning", "danger"] || (T.length color == 7 && T.isPrefixOf "#" color && T.all (\c -> isAscii c && isHexDigit c) (T.drop 1 color)))
surface "message" [Field "blocks"] (array (get "blocks"))
"MessagePayload" -> message value
"MessageResponse" -> message value
"WebhookMessage" -> message value
"ModalView" -> do
surface "modal" [Field "blocks"] (array (get "blocks"))
check MissingRequired [Field "submit"] "view.submit.required" (present "submit" || not (any ((== String "input") . at "type") (array (get "blocks"))))
"HomeTabView" -> surface "home" [Field "blocks"] (array (get "blocks"))
_ -> Right ()
where
get key = at key value
present key = has key value
options = check MutuallyExclusive [] "select.options_or_groups" (not (present "options" && present "option_groups"))
image = do
check MissingRequired [] "image.source" (present "image_url" || present "slack_file")
check MutuallyExclusive [] "image.source" (not (present "image_url" && present "slack_file"))
indexed key f = mapM_ (\(i, item) -> atField key (atIndex i (f item))) (zip [0 ..] (array (get key)))
unique :: (Eq a) => [a] -> Bool
unique values = length (nub values) == length values
dateShape :: Text -> Bool
dateShape value = T.length value == 10 && and [if i `elem` [4, 7] then c == '-' else isAscii c && isDigit c | (i, c) <- zip [0 :: Int ..] (T.unpack value)]
timeShape :: Text -> Bool
timeShape value = case T.unpack value of
[a, b, ':', c, d] -> all (\x -> isAscii x && isDigit x) [a, b, c, d] && [a, b] <= "23" && [c, d] <= "59"
_ -> False
standaloneTask :: Value -> Result ()
standaloneTask value
| at "type" value == String "task_card" = check TypeMismatch [Field "status"] "task_card.pending" (at "status" value /= String "pending")
| at "type" value == String "container" = mapM_ (\(i, child) -> atField "child_blocks" (atIndex i (standaloneTask child))) (zip [0 ..] (array (at "child_blocks" value)))
| otherwise = Right ()
surface :: Text -> [PathPiece] -> [Value] -> Result ()
surface name path =
mapM_
( \(i, block) -> do
check TypeMismatch (path <> [Index i, Field "type"]) "surface_block_types" (text (at "type" block) `elem` surfaceBlocks name)
either (Left . fmap (\e -> e {errorPath = path <> [Index i] <> errorPath e})) Right (standaloneTask block)
)
. zip [0 ..]
-- Traverse only modeled nested blocks. Metadata values with a "type" key do
-- not count as blocks, charts, markdown or tables.
descendants :: Value -> [Value]
descendants block = block : if at "type" block == String "container" then concatMap descendants (array (at "child_blocks" block)) else []
message :: Value -> Result ()
message value = do
let blocks = array (at "blocks" value)
attached = concatMap (array . at "blocks") (array (at "attachments" value))
nested = concatMap descendants (blocks <> attached)
charts = filter ((== String "data_visualization") . at "type") nested
markdown = sum [count (at "text" b) | b <- nested, at "type" b == String "markdown"]
tables = sum [tableCharacters b | b <- nested, at "type" b == String "data_table"]
surface "message" [Field "blocks"] blocks
check LengthExceeded [] "message.data_visualizations.max_items" (fromIntegral (length charts) <= limit "message.data_visualizations.max_items")
check LengthExceeded [] "markdown.total_text.max_length" (markdown <= limit "markdown.total_text.max_length")
check LengthExceeded [] "data_table.total_content.max_length" (tables <= limit "data_table.total_content.max_length")
tableCharacters :: Value -> Integer
tableCharacters value = sum (map cellCharacters (concatMap array (array (at "rows" value))))
where
cellCharacters cell
| at "type" cell `elem` [String "raw_text", String "raw_number"] = count (at "text" cell)
| at "type" cell == String "rich_text" = sum (map richCharacters (array (at "elements" cell)))
| otherwise = 0
richCharacters element
| at "type" element == String "rich_text_list" = sum (map richCharacters (array (at "elements" element)))
| otherwise = sum [count (at "text" item) | item <- array (at "elements" element), at "type" item `elem` [String "text", String "link"]]
dataTable :: Value -> Result ()
dataTable value = do
let rows = map array (array (at "rows" value))
width = case rows of [] -> 0; first : _ -> length first
mapM_
( \(i, row) -> do
check LengthExceeded [Field "rows", Index i] "data_table.columns.max_items" (not (null row) && fromIntegral (length row) <= limit "data_table.columns.max_items")
check InvalidUsage [Field "rows", Index i] "data_table.rectangular" (length row == width)
mapM_
( \(j, cell) -> do
check TypeMismatch [Field "rows", Index i, Index j] "data_table.header" (i /= 0 || at "type" cell /= String "rich_text")
when (at "type" cell `elem` [String "raw_text", String "raw_number"]) $ check LengthExceeded [Field "rows", Index i, Index j, Field "text"] "data_table.cell.min_length" (count (at "text" cell) > 0)
)
(zip [0 ..] row)
)
(zip [0 ..] rows)
check InvalidUsage [Field "column_settings"] "data_table.column_settings" (not (has "column_settings" value))
check LengthExceeded [Field "rows"] "data_table.content.max_length" (tableCharacters value <= limit "data_table.content.max_length")
chart :: Value -> Result ()
chart value = do
let categories = array (at "categories" (at "axis_config" value))
series = array (at "series" value)
mapM_
( \(i, entry) ->
let points = array (at "data" entry)
in check InvalidUsage [Field "series", Index i, Field "data"] "data_visualization.series.categories" (length points == length categories && all (\label -> length (filter ((== label) . at "label") points) == 1) categories)
)
(zip [0 ..] series)
check InvalidUsage [Field "series"] "data_visualization.series.unique" (unique (map (at "name") series))