Coverage for gws-app/gws/base/web/action.py: 52%

154 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Web action for pages, assets and files.""" 

2 

3from typing import Optional, cast 

4 

5 

6import gws 

7import gws.base.action 

8import gws.base.client.bundles 

9import gws.lib.mime 

10import gws.lib.osx 

11import gws.lib.intl 

12 

13 

14class TemplateArgs(gws.TemplateArgs): 

15 """Asset template arguments.""" 

16 

17 project: Optional[gws.Project] 

18 """Current project.""" 

19 projects: list[gws.Project] 

20 """List of user projects.""" 

21 req: gws.WebRequester 

22 """Requester object.""" 

23 user: gws.User 

24 """Current user.""" 

25 params: dict 

26 """Request parameters.""" 

27 locale: gws.Locale 

28 """Locale object.""" 

29 

30 

31@gws.ext.config.action('web') 

32class Config(gws.base.action.Config): 

33 """Serves web pages, assets and files to the browser.""" 

34 

35 pass 

36 

37 

38@gws.ext.props.action('web') 

39class Props(gws.base.action.Props): 

40 pass 

41 

42 

43class AssetRequest(gws.Request): 

44 """Asset request.""" 

45 

46 path: str 

47 """Asset path, relative to the assets directory.""" 

48 

49 

50class PageRequest(gws.Request): 

51 """Page request.""" 

52 

53 name: str 

54 """Page name, ``home`` or ``project``.""" 

55 

56 

57class AssetResponse(gws.Request): 

58 """Asset response for API requests.""" 

59 

60 content: str 

61 """Asset content.""" 

62 mimeType: str 

63 """Asset MIME type.""" 

64 

65 

66class FileRequest(gws.Request): 

67 """Request for a file stored in a feature field.""" 

68 

69 preview: bool = False 

70 """Return a preview instead of the file.""" 

71 modelUid: str 

72 """Uid of the model.""" 

73 fieldName: str 

74 """Name of the file field.""" 

75 featureUid: str 

76 """Uid of the feature.""" 

77 

78 

79@gws.ext.object.action('web') 

80class Object(gws.base.action.Object): 

81 """Web action, serves pages, assets and files.""" 

82 

83 @gws.ext.command.api('webAsset') 

84 def api_asset(self, req: gws.WebRequester, p: AssetRequest) -> AssetResponse: 

85 """Return an asset of the site or of the project.""" 

86 res = self._serve_path(req, p) 

87 if res.contentPath: 

88 res.content = gws.u.read_file_b(res.contentPath) 

89 return AssetResponse(content=res.content, mimeType=res.mimeType) 

90 

91 @gws.ext.command.get('webAsset') 

92 def http_asset(self, req: gws.WebRequester, p: AssetRequest) -> gws.ContentResponse: 

93 """Serve an asset of the site or of the project.""" 

94 res = self._serve_path(req, p) 

95 return res 

96 

97 @gws.ext.command.get('webPage') 

98 def get_page(self, req: gws.WebRequester, p: PageRequest) -> gws.ContentResponse: 

99 """Render the application home page or a project page.""" 

100 tpl = None 

101 project = None 

102 

103 if p.name == 'home': 

104 tpl = self.root.app.templateMgr.find_template('application.home', where=[], user=req.user) 

105 if p.name == 'project': 

106 project = req.user.require_project(p.projectUid) 

107 tpl = self.root.app.templateMgr.find_template('project.home', where=[project], user=req.user) 

108 if not tpl: 

109 raise gws.NotFoundError('template not found for {p.name=}') 

110 

111 return self._serve_template(req, p, tpl, project=project) 

112 

113 @gws.ext.command.get('webDownload') 

114 def download(self, req: gws.WebRequester, p) -> gws.ContentResponse: 

115 """Serve an asset as a download attachment.""" 

116 res = self._serve_path(req, p) 

117 if res.contentPath: 

118 res.contentFilename = gws.lib.osx.parse_path(res.contentPath).filename 

119 return res 

120 

121 @gws.ext.command.get('webFile') 

122 def file(self, req: gws.WebRequester, p: FileRequest) -> gws.ContentResponse: 

123 """Serve a file stored in a feature field.""" 

124 model = cast(gws.Model, req.user.require(p.modelUid, gws.ext.object.model, gws.Access.read)) 

125 field = model.field(p.fieldName) 

126 if not field: 

127 raise gws.NotFoundError() 

128 fn = getattr(field, 'handle_web_file_request', None) 

129 if not fn: 

130 raise gws.NotFoundError() 

131 mc = gws.ModelContext( 

132 op=gws.ModelOperation.read, 

133 user=req.user, 

134 project=req.user.require_project(p.projectUid), 

135 maxDepth=0, 

136 ) 

137 res = fn(p.featureUid, p.preview, mc) 

138 if not res: 

139 raise gws.NotFoundError() 

140 return res 

141 

142 @gws.ext.command.get('webSystemAsset') 

143 def sys_asset(self, req: gws.WebRequester, p: AssetRequest) -> gws.ContentResponse: 

144 """Serve a client script or style sheet.""" 

145 locale = gws.lib.intl.locale(p.localeUid, self.root.app.localeUids) 

146 app_templates = f'{gws.c.APP_DIR}/gws/base/application/templates/' 

147 

148 # only accept '8.0.0.vendor.js' etc or simply 'vendor.js' 

149 path = p.path 

150 if path.startswith(self.root.app.version): 

151 path = path[len(self.root.app.version) + 1 :] 

152 

153 if path == 'vendor.js': 

154 return gws.ContentResponse(mimeType=gws.lib.mime.JS, content=gws.base.client.bundles.javascript(self.root, 'vendor', locale)) 

155 

156 if path == 'home.js': 

157 return gws.ContentResponse(mimeType=gws.lib.mime.JS, content=gws.u.read_file(f'{app_templates}/home.js')) 

158 # deprecated 

159 if path == 'util.js': 

160 return gws.ContentResponse(mimeType=gws.lib.mime.JS, content=gws.u.read_file(f'{app_templates}/home.js')) 

161 

162 if path == 'home.css': 

163 return gws.ContentResponse(mimeType=gws.lib.mime.CSS, content=gws.u.read_file(f'{app_templates}/home.css')) 

164 

165 if path == 'app.js': 

166 return gws.ContentResponse(mimeType=gws.lib.mime.JS, content=gws.base.client.bundles.javascript(self.root, 'app', locale)) 

167 

168 if path.endswith('.css'): 

169 s = path.split('.') 

170 if len(s) != 2: 

171 raise gws.NotFoundError(f'invalid css request: {p.path=}') 

172 content = gws.base.client.bundles.css(self.root, 'app', s[0]) 

173 if not content: 

174 raise gws.NotFoundError(f'invalid css request: {p.path=}') 

175 return gws.ContentResponse(mimeType=gws.lib.mime.CSS, content=content) 

176 

177 raise gws.NotFoundError(f'invalid system asset: {p.path=}') 

178 

179 def _serve_path(self, req: gws.WebRequester, p: AssetRequest): 

180 """Locate an asset in the project or site assets directory and serve or render it.""" 

181 req_path = str(p.get('path') or '') 

182 if not req_path: 

183 raise gws.NotFoundError('no path provided') 

184 

185 site_assets = req.site.assetsRoot 

186 

187 project = None 

188 project_assets = None 

189 

190 project_uid = p.get('projectUid') 

191 if project_uid: 

192 project = req.user.require_project(project_uid) 

193 project_assets = project.assetsRoot 

194 

195 real_path = None 

196 tpl = None 

197 

198 if project_assets: 

199 real_path = gws.lib.osx.abs_web_path(req_path, project_assets.dir) 

200 if not real_path and site_assets: 

201 real_path = gws.lib.osx.abs_web_path(req_path, site_assets.dir) 

202 

203 if real_path: 

204 tpl = self.root.app.templateMgr.template_from_path(real_path) 

205 # deprecated: support for index.cx.html and project.cx.html 

206 elif req_path == 'index.cx.html': 

207 tpl = self.root.app.templateMgr.find_template('application.home', where=[], user=req.user) 

208 elif req_path == 'project.cx.html': 

209 tpl = self.root.app.templateMgr.find_template('project.home', where=[project] if project else [], user=req.user) 

210 

211 if tpl: 

212 return self._serve_template(req, p, tpl, project) 

213 

214 if not real_path: 

215 raise gws.NotFoundError(f'no real path for {req_path=}') 

216 

217 mime_type = gws.lib.mime.for_path(real_path) 

218 

219 if not _valid_mime_type(mime_type, project_assets, site_assets): 

220 # NB: pretend the file doesn't exist 

221 raise gws.NotFoundError(f'invalid mime path={real_path!r} mime_type={mime_type!r}') 

222 

223 gws.log.debug(f'serving {real_path!r} for {req_path!r}') 

224 return gws.ContentResponse(contentPath=real_path, mimeType=mime_type) 

225 

226 def _serve_template(self, req: gws.WebRequester, p: gws.Request, tpl: gws.Template, project: Optional[gws.Project]): 

227 """Render a template with the asset template arguments.""" 

228 locale = gws.lib.intl.locale(p.localeUid, project.localeUids if project else self.root.app.localeUids) 

229 projects = [p for p in self.root.app.projects if req.user.can_use(p)] 

230 projects.sort(key=lambda p: p.title.lower()) 

231 

232 args = TemplateArgs( 

233 project=project, 

234 projects=projects, 

235 req=req, 

236 user=req.user, 

237 params=req.params(), 

238 ) 

239 

240 return tpl.render(gws.TemplateRenderInput(args=args, locale=locale)) 

241 

242 

243_DEFAULT_ALLOWED_MIME_TYPES = { 

244 gws.lib.mime.CSS, 

245 gws.lib.mime.CSV, 

246 gws.lib.mime.GEOJSON, 

247 gws.lib.mime.GIF, 

248 gws.lib.mime.GML, 

249 gws.lib.mime.GML3, 

250 gws.lib.mime.GZIP, 

251 gws.lib.mime.HTML, 

252 gws.lib.mime.JPEG, 

253 gws.lib.mime.JS, 

254 gws.lib.mime.JSON, 

255 gws.lib.mime.PDF, 

256 gws.lib.mime.PNG, 

257 gws.lib.mime.SVG, 

258 gws.lib.mime.TTF, 

259 gws.lib.mime.TXT, 

260 gws.lib.mime.XML, 

261 gws.lib.mime.ZIP, 

262} 

263 

264 

265def _valid_mime_type(mt, project_assets: Optional[gws.WebDocumentRoot], site_assets: Optional[gws.WebDocumentRoot]): 

266 """Check a MIME type against the allow and deny lists of the assets directories.""" 

267 if project_assets and project_assets.allowMime: 

268 return mt in project_assets.allowMime 

269 if site_assets and site_assets.allowMime: 

270 return mt in site_assets.allowMime 

271 if mt not in _DEFAULT_ALLOWED_MIME_TYPES: 

272 return False 

273 if project_assets and project_assets.denyMime: 

274 return mt not in project_assets.denyMime 

275 if site_assets and site_assets.denyMime: 

276 return mt not in site_assets.denyMime 

277 return True