Coverage for gws-app/gws/base/web/site.py: 88%

139 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Web site.""" 

2 

3from typing import Optional 

4 

5import re 

6 

7import gws 

8import gws.lib.net 

9 

10 

11class CorsConfig(gws.Config): 

12 """CORS headers added to responses, to allow cross-origin requests.""" 

13 

14 allowCredentials: bool = False 

15 """Allow credentials in cross-origin requests.""" 

16 allowHeaders: str = '' 

17 """Headers allowed in cross-origin requests.""" 

18 allowMethods: str = '' 

19 """Methods allowed in cross-origin requests.""" 

20 allowOrigin: str = '' 

21 """Origins allowed to make cross-origin requests.""" 

22 maxAge: int = 5 

23 """How long the browser caches the CORS preflight response.""" 

24 

25 

26class RewriteRuleConfig(gws.Config): 

27 """URL rewrite rule.""" 

28 

29 pattern: gws.Regex 

30 """Regular expression to match the URL path against.""" 

31 target: str 

32 """Target URL.""" 

33 reversed: bool = False 

34 """Rewrite URLs generated by the server instead of incoming URLs.""" 

35 

36 

37class SSLConfig(gws.Config): 

38 """SSL certificate and HSTS settings.""" 

39 

40 crt: gws.FilePath 

41 """Certificate bundle location.""" 

42 key: gws.FilePath 

43 """Private key file location.""" 

44 hsts: gws.Duration = '365d' 

45 """Max. age for the Strict-Transport-Security header.""" 

46 

47 

48class WebDirConfig(gws.Config): 

49 """Directory served over the web.""" 

50 

51 dir: gws.DirPath 

52 """Directory path.""" 

53 allowMime: Optional[list[str]] 

54 """Serve only files of these MIME types.""" 

55 denyMime: Optional[list[str]] 

56 """MIME types not to serve.""" 

57 

58 

59class Config(gws.Config): 

60 """Web site the server responds to.""" 

61 

62 assets: Optional[WebDirConfig] 

63 """Directory for assets.""" 

64 cors: Optional[CorsConfig] 

65 """CORS configuration.""" 

66 contentSecurityPolicy: str = "default-src 'self'; img-src * data: blob:" 

67 """Content security policy for the site.""" 

68 permissionsPolicy: str = 'geolocation=(self), camera=(), microphone=()' 

69 """Permissions policy for the site.""" 

70 xFrameOptions: str = 'SAMEORIGIN' 

71 """Whether the site may be embedded in frames.""" 

72 errorPage: Optional[gws.ext.config.template] 

73 """Template for HTTP error pages. (deprecated in 8.4)""" 

74 hostnames: Optional[list[str]] 

75 """Host names this site responds to, lowercase and without a port.""" 

76 host: str = '' 

77 """Host name. (deprecated in 8.4)""" 

78 rewrite: Optional[list[RewriteRuleConfig]] 

79 """Rewrite rules. (deprecated in 8.4)""" 

80 rewriteRules: Optional[list[RewriteRuleConfig]] 

81 """Rules to rewrite incoming URLs and URLs generated by the server.""" 

82 withDefaultRewriteRules: bool = True 

83 """Add the default rewrite rules for the application.""" 

84 canonicalHost: str = '' 

85 """Host name for canonical URLs.""" 

86 proxyCount: int = 0 

87 """Number of proxies in front of the server.""" 

88 root: Optional[WebDirConfig] 

89 """Directory for static documents.""" 

90 

91 

92DEFAULT_ASSETS_DIR = '/data/assets' 

93DEFAULT_WEB_DIR = '/data/web' 

94DEFAULT_REWRITE_RULES = [ 

95 gws.WebRewriteRule(pattern=r'^/$', target='/_/webPage/name/home'), 

96 gws.WebRewriteRule(pattern=r'^/project/([a-z0-9_-]+)(?=/@|$)', target='/_/webPage/name/project/projectUid/$1'), 

97 gws.WebRewriteRule(pattern=r'^/_/webPage/name/home$', target='/', reversed=True), 

98 gws.WebRewriteRule(pattern=r'^/_/webPage/name/project/projectUid/([a-z0-9_-]+)', target='/project/$1', reversed=True), 

99] 

100 

101 

102class Object(gws.WebSite): 

103 """Web site.""" 

104 

105 ssl: bool 

106 """The site is served over https.""" 

107 contentSecurityPolicy: str 

108 """Content-Security-Policy header value.""" 

109 permissionsPolicy: str 

110 """Permissions-Policy header value.""" 

111 xFrameOptions: str 

112 """X-Frame-Options header value.""" 

113 

114 def configure(self): 

115 self.hostnames = self.cfg('hostnames') or [] 

116 p = self.cfg('host') 

117 if p: 

118 self.root.config_warning('"web.site.host" is deprecated, use "web.site.hostnames"') 

119 if p and p != '*': 

120 self.hostnames = [p] 

121 

122 self.canonicalHost = self.cfg('canonicalHost') or '' 

123 if not self.canonicalHost and self.hostnames: 

124 self.canonicalHost = self.hostnames[0] 

125 

126 self.proxyCount = self.cfg('proxyCount') or 0 

127 self.ssl = self.cfg('ssl') 

128 self.corsOptions = self.cfg('cors') 

129 self.contentSecurityPolicy = self.cfg('contentSecurityPolicy') 

130 self.permissionsPolicy = self.cfg('permissionsPolicy') 

131 self.xFrameOptions = self.cfg('xFrameOptions') 

132 # deprecated 

133 if self.cfg('errorPage'): 

134 self.root.config_warning('"web.site.errorPage" is deprecated') 

135 self.errorPage = self.create_child_if_configured(gws.ext.object.template, self.cfg('errorPage')) 

136 

137 p = self.cfg('root') 

138 if p: 

139 self.staticRoot = gws.WebDocumentRoot(p) 

140 elif gws.u.is_dir(DEFAULT_WEB_DIR): 

141 self.staticRoot = gws.WebDocumentRoot(dir=DEFAULT_WEB_DIR) 

142 else: 

143 # note: web root must exist 

144 self.root.config_warning(f'web root {DEFAULT_WEB_DIR!r} does not exist, using temporary directory') 

145 self.staticRoot = gws.WebDocumentRoot(dir=gws.u.ensure_dir(gws.c.TMP_DIR + '/web')) 

146 

147 p = self.cfg('assets') 

148 if p: 

149 self.assetsRoot = gws.WebDocumentRoot(p) 

150 elif gws.u.is_dir(DEFAULT_ASSETS_DIR): 

151 self.assetsRoot = gws.WebDocumentRoot(dir=DEFAULT_ASSETS_DIR) 

152 else: 

153 # note: assets root is optional 

154 self.assetsRoot = None 

155 

156 self.rewriteRules = [] 

157 p = self.cfg('rewriteRules') 

158 if not p: 

159 # deprecated 

160 p = self.cfg('rewrite') 

161 if p: 

162 self.root.config_warning('"web.site.rewrite" is deprecated, use "web.site.rewriteRules"') 

163 if not p: 

164 p = [] 

165 for c in p: 

166 r = gws.WebRewriteRule(c) 

167 if not gws.lib.net.is_abs_url(r.target): 

168 # ensure rewriting from root 

169 r.target = '/' + r.target.lstrip('/') 

170 self.rewriteRules.append(r) 

171 

172 if self.cfg('withDefaultRewriteRules', default=True): 

173 patterns = set(r.pattern for r in self.rewriteRules) 

174 for c in DEFAULT_REWRITE_RULES: 

175 if c.pattern not in patterns: 

176 self.rewriteRules.append(c) 

177 

178 def url_for(self, req, path, mode, **params): 

179 if gws.lib.net.is_abs_url(path): 

180 return gws.lib.net.add_params(path, params) 

181 

182 path = self._apply_reverse_rewrite_rules(path) 

183 if gws.lib.net.is_abs_url(path): 

184 return gws.lib.net.add_params(path, params) 

185 

186 path = '/' + path.lstrip('/') 

187 u = gws.lib.net.parse_url(path) 

188 u.params.update(params) 

189 

190 if mode == 'relative': 

191 return gws.lib.net.make_relative_url(u.path, u.params) 

192 

193 u.scheme = req.scheme 

194 

195 if mode == 'canonical': 

196 u.hostname = self.canonicalHost 

197 if not u.hostname: 

198 u.hostname = req.host 

199 u.port = req.port 

200 if not u.hostname: 

201 raise gws.BadRequestError('no host for an absolute url') 

202 

203 return gws.lib.net.make_url(u) 

204 

205 def _apply_reverse_rewrite_rules(self, path): 

206 """Rewrite a path with the first matching reversed rule.""" 

207 for r in self.rewriteRules: 

208 if not r.reversed: 

209 continue 

210 m = re.search(r.pattern, path) 

211 if not m: 

212 continue 

213 # we use nginx syntax $1, need python's \1 

214 t = r.target.replace('$', '\\') 

215 return re.sub(r.pattern, t, path) 

216 

217 return path