Skip to content
Snippets Groups Projects
Select Git revision
  • d0c2e33678efae2f93104b6a244545b17f4bb7b1
  • develop default protected
  • ical-export
  • feature/audit_log
  • fix/index
  • badge-redeem-404
  • 720-schedule_source
  • room-docu
  • chore/event-views
  • 511-schedule-foo-fixed
  • 607-schedule-versions
  • deploy/curl-verbose
  • fix/public-badge-access-rights
  • 445-schedule-redirects
  • 623-wiki-im-baustellenmodus-sollte-mal-als-wiki-admin-trotzdem-seiten-anlegen-bearbeiten-konnen
  • fix/registration_mail_subject
  • feature/conference-query-set
  • feature/568-habitatmanagement
  • feat/unit-integration-tests
  • camp23-prod
  • production
  • prod-2024-12-27_20-15
  • prod-2024-12-27_16-37
  • prod-2024-12-27_16-01
  • prod-2024-12-27_13-29
  • prod-2024-12-27_00-34
  • prod-2024-12-26_21-45
  • prod-2024-12-26_13-12
  • prod-2024-12-26_00-21
  • prod-2024-12-25_21-04
  • prod-2024-12-25_15-54
  • prod-2024-12-25_01-29
  • prod-2024-12-24_14-48
  • prod-2024-12-23_23-39
  • prod-2024-12-22_21-12
  • prod-2024-12-22_17-25
  • prod-2024-12-22_01-34
  • prod-2024-12-22_00-55
  • prod-2024-12-21_13-42
  • prod-2024-12-21_10-44
  • prod-2024-12-20_12-25
41 results

voucher.py

Blame
  • Forked from hub / hub
    Source project has a limited visibility.
    Properties.hs 30.85 KiB
    {-# LANGUAGE DataKinds         #-}
    {-# LANGUAGE LambdaCase        #-}
    {-# LANGUAGE MultiWayIf        #-}
    {-# LANGUAGE NamedFieldPuns    #-}
    {-# LANGUAGE OverloadedStrings #-}
    {-# LANGUAGE RecordWildCards   #-}
    {-# LANGUAGE TupleSections     #-}
    {-# LANGUAGE TypeApplications  #-}
    
    -- | Contains checks for custom ties of the map json
    module Properties (checkMap, checkTileset, checkLayer) where
    
    
    import           Control.Monad     (forM, forM_, unless, when)
    import           Data.Text         (Text, intercalate, isPrefixOf, isInfixOf)
    import qualified Data.Text         as T
    import qualified Data.Vector       as V
    import           Tiled             (Layer (..), Object (..), Property (..),
                                        PropertyValue (..), Tile (..),
                                        Tiledmap (..), Tileset (..))
    import           TiledAbstract     (HasName (..), HasProperties (..),
                                        HasTypeName (..), IsProperty (..))
    import           Util              (layerIsEmpty, mkProxy, naiveEscapeHTML,
                                        prettyprint, showText)
    
    import           Badges            (Badge (Badge),
                                        BadgeArea (BadgePoint, BadgeRect),
                                        BadgeToken, parseToken)
    import           Data.Data         (Proxy (Proxy))
    import           Data.Functor      ((<&>))
    import           Data.List         ((\\))
    import           Data.Maybe        (fromMaybe, isJust)
    import           Data.Set          (Set)
    import qualified Data.Set          as S
    import           Data.Text.Metrics (damerauLevenshtein)
    import           Data.Vector       (Vector)
    import           GHC.TypeLits      (KnownSymbol)
    import           LayerData         (Collision, layerOverlaps)
    import           LintConfig        (LintConfig (..))
    import           LintWriter        (LintWriter, adjust, askContext,
                                        askFileDepth, complain, dependsOn, forbid,
                                        lintConfig, offersBadge, offersEntrypoint,
                                        suggest, warn, zoom)
    import           Paths             (PathResult (..), RelPath (..), getExtension,
                                        isOldStyle, parsePath)
    import           Types             (Dep (Link, Local, LocalMap, MapLink))
    import           Uris              (SubstError (..), applySubsts, parseUri, extractDomain)
    
    
    
    knownMapProperties :: Vector Text
    knownMapProperties = V.fromList
      [ "mapName", "mapDescription", "mapCopyright", "mapLink", "script" ]
    
    knownTilesetProperties :: Vector Text
    knownTilesetProperties = V.fromList
      [ "tilesetCopyright", "collides"]
    
    knownObjectProperties :: Vector Text
    knownObjectProperties = V.fromList
      [ "name", "url", "getBadge", "soundRadius", "default", "persist", "openLayer"
      , "closeLayer", "door", "bell", "openSound", "closeSound", "bellSound"
      , "allowapi"]
    
    knownTileLayerProperites :: Vector Text
    knownTileLayerProperites = V.fromList
      [ "jitsiRoom", "jitsiTrigger", "jitsiTriggerMessage", "jitsiWidth", "bbbRoom"
      , "bbbTrigger", "bbbTriggerMessage", "playAudio", "audioLoop", "audioVolumne"
      , "openWebsite", "openWebsiteTrigger", "openWebsiteTriggerMessage", "openTag"
      , "exitUrl", "startLayer", "silent", "getBadge", "zone", "name", "doorVariable"
      , "bindVariable", "bellVariable", "code", "openTriggerMessage"
      , "closeTriggerMessage", "autoOpen", "autoClose", "bellButtonText", "bellPopup"
      , "enterValue", "leaveValue" ]
    
    -- | Checks an entire map for "general" lints.
    --
    -- Note that it does /not/ check any tile layer/tileset properties;
    -- these are handled seperately in CheckMap, since these lints go
    -- into a different field of the output.
    checkMap :: LintWriter Tiledmap
    checkMap = do
      tiledmap <- askContext
      let layers = collectLayers tiledmap
      let unlessLayer = unlessElement layers
    
      -- test custom map properties
      mapM_ checkMapProperty (fromMaybe mempty $ tiledmapProperties tiledmap)
    
      -- can't have these with the rest of layer/tileset lints since they're
      -- not specific to any one of them
      refuseDoubledNames layers
      refuseDoubledNames (tiledmapTilesets tiledmap)
      refuseDoubledNames (getProperties tiledmap)
    
      -- some layers should exist
      unlessElementNamed layers "start"
        $ complain "The map must have one layer named \"start\"."
      unlessLayer (\l -> getName l == "floorLayer" && layerType l == "objectgroup")
        $ complain "The map must have one layer named \"floorLayer\" of type \"objectgroup\"."
      unlessLayer (`containsProperty` "exitUrl")
        $ complain "The map must contain at least one layer with the property \"exitUrl\" set."
    
      -- reject maps not suitable for workadventure
      unless (tiledmapOrientation tiledmap == "orthogonal")
        $ complain "The map's orientation must be set to \"orthogonal\"."
      unless (tiledmapTileheight tiledmap == 32 && tiledmapTilewidth tiledmap == 32)
        $ complain "The map's tile size must be 32 by 32 pixels."
    
      unlessHasProperty "mapCopyright"
        $ suggest "document the map's copyright via the \"mapCopyright\" property."
    
      -- TODO: this doesn't catch collisions with the default start layer!
      whenLayerCollisions layers (\(Property name _) -> name == "exitUrl" || name == "startLayer")
        $ \cols -> warn $ "collisions between entry and / or exit layers: " <> prettyprint cols
    
      let missingMetaInfo =
            ["mapName","mapDescription","mapLink"]
            \\ fmap getName (getProperties tiledmap)
    
      unless (null missingMetaInfo)
       $ suggest $ "consider adding meta information to your map using the "
                    <> prettyprint missingMetaInfo <> " properties."
    
      where
        -- recursively find all layers (to deal with nested group layers)
        collectLayers :: Tiledmap -> V.Vector Layer
        collectLayers tiledmap = tiledmapLayers tiledmap <>
          V.fromList (concatMap groupmembers (tiledmapLayers tiledmap))
         where groupmembers :: Layer -> [Layer]
               groupmembers layer = concatMap groupmembers layers <> layers
                where layers = fromMaybe [] $ layerLayers layer
    
    -- | Checks a single property of a map.
    checkMapProperty :: Property -> LintWriter Tiledmap
    checkMapProperty p@(Property name _) = case name of
      "mapName" -> naiveEscapeProperty p
      "mapDescription" -> naiveEscapeProperty p
      "mapCopyright" -> naiveEscapeProperty p
      "mapLink" -> pure ()
      -- usually the linter will complain if names aren't in their
      -- "canonical" form, but allowing that here so that multiple
      -- scripts can be used by one map
      _ | T.toLower name == "script" ->
          unwrapString p $ \str ->
            unless ((checkIsRc3Url str) &&
                    (not $ "/../" `isInfixOf` str) &&
                    (not $ "%" `isInfixOf` str) &&
                    (not $ "@" `isInfixOf` str))
            $ forbid "only scripts hosted on static.rc3.world are allowed."
        | name `elem` ["jitsiRoom", "bbbRoom", "playAudio", "openWebsite"
                      , "url", "exitUrl", "silent", "getBadge"]
              -> complain $ "property " <> name
                          <> " should be set on layers, not the map directly"
        | otherwise
              -> warnUnknown p knownMapProperties
    
    
    -- | check an embedded tile set.
    --
    -- Important to collect dependency files
    checkTileset ::  LintWriter Tileset
    checkTileset = do
      tileset <- askContext
      case tilesetImage tileset of
        Just str -> unwrapPath str (dependsOn . Local)
        Nothing  -> complain "Tileset does not refer to an image."
    
      refuseDoubledNames (getProperties tileset)
    
      -- reject tilesets unsuitable for workadventure
      unless (tilesetTilewidth tileset == 32 && tilesetTileheight tileset == 32)
        $ complain "Tilesets must have tile size 32x32."
    
      when (tilesetImageheight tileset > 4096 || tilesetImagewidth tileset > 4096)
        $ warn "Tilesets should not be larger than 4096x4096 pixels in total."
    
      when (isJust (tilesetSource tileset))
        $ complain "Tilesets must be embedded and cannot be loaded from external files."
    
      unlessHasProperty "tilesetCopyright"
        $ forbid "property \"tilesetCopyright\" for tilesets must be set."
    
      when (isJust (tilesetFileName tileset))
        $ complain "The \"filename\" property on tilesets was removed; use \"image\" instead (and perhaps a newer version of the Tiled Editor)."
    
      -- check individual tileset properties
      mapM_ checkTilesetProperty (fromMaybe mempty $ tilesetProperties tileset)
    
      case tilesetTiles tileset of
        Nothing -> pure ()
        Just tiles -> do
          -- can't set properties on the same tile twice
          refuseDoubledThings tileId
            (\tile -> complain $ "cannot set properties on the \
                      \tile with the id" <> showText (tileId tile) <> "twice.")
            tiles
    
          mapM_ checkTile tiles
      where
        checkTilesetProperty :: Property -> LintWriter Tileset
        checkTilesetProperty p@(Property name _value) = case name of
          "tilesetCopyright" -> naiveEscapeProperty p
          "collides"         -> warn "property \"collides\" should be set on individual tiles, not the tileset"
          _                  -> warn $ "unknown tileset property " <> prettyprint name
    
        checkTile :: Tile -> LintWriter Tileset
        checkTile tile = do
          refuseDoubledNames (getProperties tile)
          mapM_ checkTileProperty (getProperties tile)
          where checkTileProperty :: Property -> LintWriter Tileset
                checkTileProperty p@(Property name _) = case name of
                  "collides" -> isBool p
                  -- named tiles are needed for scripting and do not hurt otherwise
                  "name" -> isString p
                  "tilesetCopyright" -> warn "the \"tilesetCopyright\" property should be set on the entire tileset, \
                                             \not an individual tile."
                  _ -> warnUnknown' ("unknown tile property "
                          <> prettyprint name <> " in tile with global id "
                          <> showText (tileId tile)) p knownTilesetProperties
    
    
    -- | collect lints on a single map layer
    checkLayer :: LintWriter Layer
    checkLayer = do
      layer <- askContext
    
      refuseDoubledNames (getProperties layer)
    
      when (isJust (layerImage layer))
        $ complain "imagelayer are not supported."
    
      case layerType layer of
        "tilelayer" -> mapM_ checkTileLayerProperty (getProperties layer)
        "group" -> pure ()
        "objectgroup" -> do
    
          -- check object properties
          objs <- forM (layerObjects layer) $ mapM $ \object -> do
            -- this is a confusing constant zoom ...
            zoom (const layer) (const object) $ mapM_ checkObjectProperty (getProperties object)
          adjust (\l -> l { layerObjects = objs })
    
          -- all objects which don't define badges
          let publicObjects = fmap (V.filter (not . (`containsProperty` "getBadge"))) objs
    
          -- remove badges from output
          adjust $ \l -> l { layerObjects = publicObjects
                           , layerProperties = Nothing }
    
          -- check layer properties
          forM_ (getProperties layer) checkObjectGroupProperty
    
          unless (layerName layer == "floorLayer") $
            when (null (layerObjects layer) || layerObjects layer == Just mempty) $
              warn "objectgroup layer (which aren't the floorLayer) \
                   \are useless if they are empty."
    
        ty -> complain $ "unsupported layer type " <> prettyprint ty <> "."
    
      if layerType layer == "group"
        then when (null (layerLayers layer))
        $ warn "Empty group layers are pointless."
        else when (isJust (layerLayers layer))
        $ complain "Layer is not of type \"group\", but has sublayers."
    
    checkObjectProperty :: Property -> LintWriter Object
    checkObjectProperty p@(Property name _) = do
     obj <- askContext
     case name of
      "url" -> do
        unwrapURI (Proxy @"website") p
          (dependsOn . Link)
          (const $ forbid "using \"url\" to open local html files is disallowed.")
        unless (objectType obj == "website")
          $ complain "\"url\" can only be set for objects of type \"website\""
      "getBadge" -> do
        when (1 /= length (getProperties obj))
          $ warn "Objects with the property \"getBadge\" set are removed at runtime, \
                 \and any other properties set on them will be gone."
        unwrapString p $ \str ->
          unwrapBadgeToken str $ \token -> do
            case obj of
              ObjectPolygon {} -> complain "polygons are not supported."
              ObjectPolyline {} -> complain "polylines are not supported."
              ObjectText {} -> complain "cannot use texts to define badge areas."
              ObjectRectangle {..} ->
                if objectEllipse == Just True
                then complain "ellipses are not supported."
                else offersBadge
                $ Badge token $ case (objectWidth, objectHeight) of
                  (Just w, Just h) | w /= 0 && h /= 0 ->
                     BadgeRect objectX objectY w h
                  _ -> BadgePoint objectX objectY
      "soundRadius" -> do
        isIntInRange 0 maxBound p
        unless (containsProperty obj "door" || containsProperty obj "bell")
          $ complain "property \"soundRadius\" can only be set on objects with \
                     \either property \"bell\" or \"door\" also set."
    
      _ | name `elem` [ "default", "persist" ] ->
            suggestPropertyName' "door"
        -- extended API for doors and bells
        | name `elem` [ "openLayer", "closeLayer" ] -> do
            isString p
            suggestPropertyName' "door"
            -- extended API for doors and bells
        | name `elem` ["door", "bell"] -> do
            isBool p
            unless (objectType obj == "variable") $
              complain $ "the "<>prettyprint name<>" property should only be set \
                         \on objects of type \"variable\""
            when (null (objectName obj) || objectName obj == Just mempty) $
              complain $ "Objects with the property "<>prettyprint name<>" set must \
                         \be named."
        | name `elem` [ "openSound", "closeSound", "bellSound", "loadSound" ] -> do
            isString p
            unwrapURI (Proxy @"audio") p
              (dependsOn . Link)
              (dependsOn . Local)
            case name of
              "bellSound" ->
                suggestPropertyName' "bell"
              "closeSound" | containsProperty obj  "openSound" ->
                suggestPropertyName' "door"
              _ -> do
                suggestPropertyName' "door"
                suggestPropertyName "soundRadius"
                  "set \"soundRadius\" to limit the door sound to a certain area."
        | T.toLower name `elem` [ "allowapi" ]
          -> forbidProperty name
        | otherwise ->
            warnUnknown p knownObjectProperties
    
    -- | Checks a single (custom) property of an objectgroup layer
    checkObjectGroupProperty :: Property -> LintWriter Layer
    checkObjectGroupProperty (Property name _) = case name of
      "getBadge" -> warn "the property \"getBadge\" must be set on individual objects, \
                           \not the object layer."
      _ -> warn $ "unknown property " <> prettyprint name <> " for objectgroup layers"
    
    checkIsRc3Url :: Text -> Bool 
    checkIsRc3Url text= case extractDomain text of
        Nothing -> False
        Just domain -> do 
          domain == "https://static.rc3.world"
    
    
    -- | Checks a single (custom) property of a "normal" tile layer
    checkTileLayerProperty :: Property -> LintWriter Layer
    checkTileLayerProperty p@(Property name _value) = case name of
        "jitsiRoom" -> do
          lintConfig configAssemblyTag
            >>= setProperty "jitsiRoomAdminTag"
            . ("assembly-" <>) -- prepend "assembly-" to avoid namespace clashes
          uselessEmptyLayer
          unwrapString p $ \jitsiRoom -> do
            suggestProperty $ Property "jitsiTrigger" "onaction"
    
            -- prepend jitsi room names to avoid name clashes
            unless ("shared" `isPrefixOf` jitsiRoom) $ do
              assemblyname <- lintConfig configAssemblyTag
              setProperty "jitsiRoom" (assemblyname <> "-" <> jitsiRoom)
        "jitsiTrigger" -> do
          isString p
          unlessHasProperty "jitsiTriggerMessage"
           $ suggest "set \"jitsiTriggerMessage\" to a custom message to overwrite \
                     \the default \"press SPACE to enter in jitsi meet room\"."
          requireProperty "jitsiRoom"
        "jitsiTriggerMessage" -> do
          isString p
          requireProperty "jitsiTrigger"
        "jitsiWidth" ->
          isIntInRange 0 100 p
        "bbbRoom" -> do
          removeProperty "bbbRoom"
          unwrapString p $ \str -> case parseUri str of
            Just ("bbb",assembly_slug, room_slug)
              | "/" `isPrefixOf` room_slug
                && T.length room_slug >= 2  -> do
                  let link = "https://rc3.world/2021/room"<>room_slug
                  dependsOn (Link link)
                  setProperty "openTab" link
                  setProperty "silent" (BoolProp True)
                  setProperty "openWebsitePolicy"
                    ("fullscreen;camera;microphone;display-capture" :: Text)
            _ -> complain "the \"bbbRoom\" property must take a link of the form bbb://assembly_slug/room_slug."
        "bbbTrigger" -> do
          removeProperty "bbbTrigger"
          requireProperty "bbbRoom"
          unwrapString p
            (setProperty "openWebsiteTrigger")
          unlessHasProperty "bbbTriggerMessage" $ do
           setProperty "openWebsiteTriggerMessage"
             ("press SPACE to enter bbb room in a new tab" :: Text)
           suggest "set \"bbbTriggerMessage\" to a custom message to overwrite the\
                   \default \"press SPACE to enter the bbb room\""
        "bbbTriggerMessage" -> do
          removeProperty "bbbTriggerMessage"
          requireProperty "bbbRoom"
          unwrapString p
            (setProperty "openWebsiteTriggerMessage")
        "playAudio" -> do
          uselessEmptyLayer
          unwrapURI (Proxy @"audio") p
            (dependsOn . Link)
            (dependsOn . Local)
        "audioLoop" -> do
          isBool p
          requireProperty "playAudio"
        "playAudioLoop" ->
          deprecatedUseInstead "audioLoop"
        "audioVolume" -> do
          isOrdInRange unwrapFloat 0 1 p
          requireProperty "playAudio"
        "openWebsiteTrigger" -> do
          isString p
          requireOneOf ["openWebsite", "openTab"]
          unlessHasProperty "openWebsiteTriggerMessage"
            $ suggest "set \"openWebsiteTriggerMessage\" to a custom message to \
                      \overwrite the default \"press SPACE to open Website\"."
        "openWebsiteTriggerMessage" -> do
          isString p
          requireProperty "openWebsiteTrigger"
        "url" -> complain "the property \"url\" defining embedded iframes must be \
                          \set on an object in an objectgroup layer."
        "exitUrl" -> do
          forbidEmptyLayer
          unwrapURI (Proxy @"map") p
            (\link -> do
                dependsOn (MapLink link)
                setProperty "exitUrl" link
            )
            $ \path ->
              let ext = getExtension path in
              if | isOldStyle path ->
                   complain "Old-Style inter-repository links (using {<placeholder>}) \
                            \cannot be used at rC3 2021; please use world:// instead \
                            \(see howto.rc3.world)."
                 | ext == "tmx" ->
                     complain "Cannot use .tmx map format; use Tiled's json export instead."
                 | ext /= "json" ->
                     complain "All exit links must link to .json files."
                 | otherwise -> dependsOn . LocalMap $ path
        "exitSceneUrl" ->
          deprecatedUseInstead "exitUrl"
        "exitInstance" ->
          deprecatedUseInstead "exitUrl"
        "startLayer" -> do
          forbidEmptyLayer
          layer <- askContext
          unwrapBool p $ \case
            True  -> offersEntrypoint $ layerName layer
            False -> warn "property \"startLayer\" is useless if set to false."
        "silent" -> do
          isBool p
          uselessEmptyLayer
        -- "collides" ->
        --   unwrapBool p $ \case
        --     True  -> pure ()
        --     False -> warn "property \"collides\" set to 'false' is useless."
        "getBadge" -> complain "\"getBadge\" must be set on an \"objectgroup\" \
                               \ layer; it does not work on tile layers."
    
        -- extended API stuff
        "zone" -> do
          isString p
          uselessEmptyLayer
        -- name on tile layer unsupported
        "name" -> isUnsupported
        _ | name `elem` [ "doorVariable", "bindVariable", "bellVariable" ]
            -> do { isString p; requireProperty "zone" }
          | name `elem` [ "code", "openTriggerMessage", "closeTriggerMessage"]
            -> do { isString p; requireProperty "doorVariable" }
          | name `elem` [ "autoOpen", "autoClose"]
            -> do { isBool p; requireProperty "doorVariable" }
          | name `elem` [ "bellButtonText", "bellPopup" ]
            -> do { isString p; requireProperty "bellVariable" }
          | name `elem` [ "enterValue", "leaveValue" ]
            -> do { isString p; requireProperty "bindVariable" }
          | T.toLower name `elem` [ "jitsiurl", "jitsiconfig", "jitsiclientconfig"
                                  , "jitsiroomadmintag", "jitsiinterfaceconfig"
                                  , "openwebsitepolicy", "allowapi" ]
            -> forbidProperty name
            -- the openWebsite Api can only be allowed if the website is on static.rc3.world
          | T.toLower name == "openwebsiteallowapi"
            -> do
              properties <- askContext <&> getProperties
              unless (all (\(Property name value) -> case value of
                              StrProp str -> name /= "openWebsite" || checkIsRc3Url str
                              _ -> True
                          ) properties)
                $ complain "\"openWebsiteAllowApi\" can only be used with websites hosted \
                           \on https://static.rc3.world"
          | name `elem` [ "openWebsite", "openTab" ] -> do
              uselessEmptyLayer
              suggestProperty $ Property "openWebsiteTrigger" (StrProp "onaction")
              unwrapURI (Proxy @"website") p
                (dependsOn . Link)
                (const $ forbid "accessing local html files is disallowed.")
          | otherwise ->
            warnUnknown p knownTileLayerProperites
        where
          requireProperty req = propertyRequiredBy req name
          requireOneOf names = do
            context <- askContext
            when (all (not . containsProperty context) names)
              $ complain $ "property " <> prettyprint name <> " requires one of "
                        <> prettyprint names
    
          isUnsupported = warn $ "property " <> name <> " is not (yet) supported by walint."
          deprecatedUseInstead instead =
            warn $ "property \"" <> name <> "\" is deprecated. Use \"" <> instead <> "\" instead."
    
          -- | this property can only be used on a layer that contains
          -- | at least one tile
          forbidEmptyLayer = do
            layer <- askContext
            when (layerIsEmpty layer)
              $ complain ("property " <> prettyprint name <> " should not be set on an empty layer.")
    
          -- | this layer is allowed, but also useless on a layer that contains no tiles
          uselessEmptyLayer = do
            layer <- askContext
            when (layerIsEmpty layer)
              $ warn ("property " <> prettyprint name <> " set on an empty layer is useless.")
    
    
    -- | refuse doubled names in everything that's somehow a collection of names
    refuseDoubledNames
      :: (HasName a, HasTypeName a)
      => (Foldable t, Functor t)
      => t a
      -> LintWriter b
    refuseDoubledNames = refuseDoubledThings
      getName
      (\thing -> complain $ "cannot use " <> typeName (mkProxy thing) <> " name "
                         <> getName thing <> " multiple times.")
    
    -- | refuse doubled things via equality on after applying some function
    refuseDoubledThings
      :: (Eq a, Ord a, Foldable t, Functor t)
      => (a' -> a)
      -> (a' -> LintWriter b)
      -> t a'
      -> LintWriter b
    refuseDoubledThings f ifDouble things = foldr folding base things (mempty, mempty)
      where
        folding thing cont (seen, twice)
          | f thing `elem` seen && f thing `notElem` twice = do
            ifDouble thing
            cont (seen, S.insert (f thing) twice)
          | otherwise =
            cont (S.insert (f thing) seen, twice)
        base _ = pure ()
    
    
    warnUnknown' :: Text -> Property -> Vector Text -> LintWriter a
    warnUnknown' msg (Property name _) knowns =
      if snd minDist < 4
      then warn (msg <> ", perhaps you meant " <> prettyprint (fst minDist) <> "?")
      else warn msg
      where dists = V.map (\n -> (n, damerauLevenshtein name n)) knowns
            minDist = V.minimumBy (\(_,a) (_,b) -> compare a b) dists
    
    warnUnknown :: Property -> Vector Text -> LintWriter a
    warnUnknown p@(Property name _) =
      warnUnknown' ("unknown property " <> prettyprint name) p
    
    ---- General functions ----
    
    unlessElement
      :: Foldable f
      => f a
      -> (a -> Bool)
      -> LintWriter b
      -> LintWriter b
    unlessElement things op = unless (any op things)
    
    unlessElementNamed :: (HasName a, Foldable f)
      => f a -> Text -> LintWriter b -> LintWriter b
    unlessElementNamed things name =
      unlessElement things ((==) name . getName)
    
    unlessHasProperty :: HasProperties a => Text -> LintWriter a -> LintWriter a
    unlessHasProperty name linter =
      askContext >>= \ctxt ->
        unlessElementNamed (getProperties ctxt) name linter
    
    -- | does this layer have the given property?
    containsProperty :: HasProperties a => a -> Text -> Bool
    containsProperty thing name = any
      (\(Property name' _) -> name' == name) (getProperties thing)
    
    -- | should the layers fulfilling the given predicate collide, then perform andthen.
    whenLayerCollisions
      :: V.Vector Layer
      -> (Property -> Bool)
      -> (Set Collision -> LintWriter a)
      -> LintWriter a
    whenLayerCollisions layers f andthen = do
      let collisions = layerOverlaps . V.filter (any f . getProperties) $ layers
      unless (null collisions)
        $ andthen collisions
    
    ----- Functions with concrete lint messages -----
    
    -- | this property is forbidden and should not be used
    forbidProperty :: HasProperties a => Text -> LintWriter a
    forbidProperty name =
      forbid $ "property " <> prettyprint name <> " is disallowed."
    
    propertyRequiredBy :: HasProperties a => Text -> Text -> LintWriter a
    propertyRequiredBy req by =
      unlessHasProperty req
      $ complain $ "property " <> prettyprint req <>
                   " is required by property " <> prettyprint by <> "."
    
    -- | suggest some value for another property if that property does not
    -- also already exist
    suggestProperty :: HasProperties a => Property -> LintWriter a
    suggestProperty p@(Property name value) =
      suggestProperty' p $ "add property " <> prettyprint name <> " to \"" <> prettyprint value<>"\"."
    
    suggestProperty' :: HasProperties a => Property -> Text -> LintWriter a
    suggestProperty' (Property name _) msg =
      unlessHasProperty name (suggest msg)
    
    suggestPropertyName :: HasProperties a => Text -> Text -> LintWriter a
    suggestPropertyName name msg =
      unlessHasProperty name (suggest msg)
    
    suggestPropertyName' :: HasProperties a => Text -> LintWriter a
    suggestPropertyName' name = suggestPropertyName name
      $ "consider setting property " <> prettyprint name <> "."
    
    ---- Functions for adjusting the context -----
    
    
    -- | set a property, overwriting whatever value it had previously
    setProperty :: (IsProperty prop, HasProperties ctxt)
      => Text -> prop -> LintWriter ctxt
    setProperty name value = adjust $ \ctxt ->
      flip adjustProperties ctxt
      $ \ps -> Just $ Property name (asProperty value) : filter sameName ps
      where sameName (Property name' _) = name /= name'
    
    removeProperty :: HasProperties ctxt => Text -> LintWriter ctxt
    removeProperty name = adjust $ \ctxt ->
      flip adjustProperties ctxt
      $ \ps -> Just $ filter (\(Property name' _) -> name' /= name) ps
    
    naiveEscapeProperty :: HasProperties a =>  Property -> LintWriter a
    naiveEscapeProperty prop@(Property name _) =
      unwrapString prop (setProperty name . naiveEscapeHTML)
    
    ---- "unwrappers" checking that a property has some type, then do something ----
    
    -- | asserts that this property is a string, and unwraps it
    unwrapString :: Property -> (Text -> LintWriter a) -> LintWriter a
    unwrapString (Property name value) f = case value of
      StrProp str -> f str
      _ -> complain $ "type error: property "
                   <> prettyprint name <> " should be of type string."
    
    
    -- | asserts that this property is a boolean, and unwraps it
    unwrapBool :: Property -> (Bool -> LintWriter a) -> LintWriter a
    unwrapBool (Property name value) f = case value of
      BoolProp b -> f b
      _ -> complain $ "type error: property " <> prettyprint name
                   <> " should be of type bool."
    
    unwrapInt :: Property -> (Int -> LintWriter a) -> LintWriter a
    unwrapInt (Property name value) f = case value of
      IntProp float -> f float
      _ -> complain $ "type error: property " <> prettyprint name
                   <> " should be of type int."
    
    unwrapFloat :: Property -> (Float -> LintWriter a) -> LintWriter a
    unwrapFloat (Property name value) f = case value of
      FloatProp float -> f float
      _ -> complain $ "type error: property " <> prettyprint name
                   <> " should be of type float."
    
    unwrapPath :: Text -> (RelPath -> LintWriter a) -> LintWriter a
    unwrapPath str f = case parsePath str of
      OkRelPath p@(Path up _ _) -> do
        depth <- askFileDepth
        if up <= depth
          then f p
          else complain $ "cannot acess paths \"" <> str <> "\" which is outside your repository."
      NotAPath -> complain $ "path \"" <> str <> "\" is invalid."
      AbsolutePath -> forbid "absolute paths are disallowed. Use world:// instead."
      UnderscoreMapLink -> forbid "map links using /_/ are disallowed. Use world:// instead."
      AtMapLink -> forbid "map links using /@/ are disallowed. Use world:// instead."
      PathVarsDisallowed -> forbid "extended API variables are not allowed in asset paths."
    
    unwrapBadgeToken :: Text -> (BadgeToken -> LintWriter a) -> LintWriter a
    unwrapBadgeToken str f = case parseToken str of
      Just a  -> f a
      Nothing -> complain "invalid badge token."
    
    
    -- | unwraps a URI
    unwrapURI :: (KnownSymbol s, HasProperties a)
      => Proxy s
      -> Property
      -> (Text -> LintWriter a)
      -> (RelPath -> LintWriter a)
      -> LintWriter a
    unwrapURI sym p@(Property name _) f g = unwrapString p $ \link -> do
      subst <- lintConfig configUriSchemas
      case applySubsts sym subst link of
        Right uri -> do
          setProperty name uri
          f uri
        Left NotALink -> unwrapPath link g
        Left err -> do
          isLobby <- lintConfig configAssemblyTag <&> (== "lobby")
    
          (if isLobby then warn else complain) $ case err of
            DomainIsBlocked domains -> link <> " is a blocked site; links in this \
                                       \context may link to " <> prettyprint domains
            IsBlocked -> link <> " is blocked."
            DomainDoesNotExist domain -> "The domain " <> domain <> " does not exist; \
                                         \please make sure it is spelled correctly."
            SchemaDoesNotExist schema ->
              "the URI schema " <> schema <> ":// cannot be used."
            WrongScope schema allowed ->
              "the URI schema " <> schema <> ":// cannot be used in property \
              \\"" <> name <> "\"; allowed "
              <> (if length allowed == 1 then "is " else "are ")
              <> intercalate ", " (fmap (<> "://") allowed) <> "."
            VarsDisallowed -> "extended API links are disallowed in links"
    
    
    
    -- | just asserts that this is a string
    isString :: Property -> LintWriter a
    isString = flip unwrapString (const $ pure ())
    
    -- | just asserts that this is a boolean
    isBool :: Property -> LintWriter a
    isBool = flip unwrapBool (const $ pure ())
    
    isIntInRange :: Int -> Int -> Property -> LintWriter b
    isIntInRange = isOrdInRange @Int unwrapInt
    
    isOrdInRange :: (Ord a, Show a)
      => (Property -> (a -> LintWriter b) -> LintWriter b)
      -> a
      -> a
      -> Property
      -> LintWriter b
    isOrdInRange unwrapa l r p@(Property name _) = unwrapa p $ \int ->
      if l < int && int < r then pure ()
      else complain $ "Property " <> prettyprint name <> " should be between "
                   <> showText l <> " and " <> showText r<>"."