]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/wiki.py
wiki: Move edit action into own handler
[ipfire.org.git] / src / backend / wiki.py
CommitLineData
181d08f3
MT
1#!/usr/bin/python3
2
79dd9a0f
MT
3import PIL
4import io
181d08f3 5import logging
6ac7e934 6import os.path
181d08f3 7import re
9e90e800 8import urllib.parse
181d08f3
MT
9
10from . import misc
9523790a 11from . import util
181d08f3
MT
12from .decorators import *
13
181d08f3
MT
14class Wiki(misc.Object):
15 def _get_pages(self, query, *args):
16 res = self.db.query(query, *args)
17
18 for row in res:
19 yield Page(self.backend, row.id, data=row)
20
d398ca08
MT
21 def _get_page(self, query, *args):
22 res = self.db.get(query, *args)
23
24 if res:
25 return Page(self.backend, res.id, data=res)
26
6ac7e934
MT
27 def get_page_title(self, page, default=None):
28 doc = self.get_page(page)
29 if doc:
30 return doc.title
31
0b62a7f9 32 return default or os.path.basename(page)
6ac7e934 33
181d08f3
MT
34 def get_page(self, page, revision=None):
35 page = Page.sanitise_page_name(page)
36 assert page
37
38 if revision:
d398ca08 39 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
40 AND timestamp = %s", page, revision)
41 else:
d398ca08 42 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
43 ORDER BY timestamp DESC LIMIT 1", page)
44
11afe905
MT
45 def get_recent_changes(self, account, limit=None):
46 pages = self._get_pages("SELECT * FROM wiki \
f9db574a 47 WHERE timestamp >= NOW() - INTERVAL '4 weeks' \
11afe905
MT
48 ORDER BY timestamp DESC")
49
50 for page in pages:
51 if not page.check_acl(account):
52 continue
53
54 yield page
55
56 limit -= 1
57 if not limit:
58 break
181d08f3 59
495e9dc4 60 def create_page(self, page, author, content, changes=None, address=None):
181d08f3
MT
61 page = Page.sanitise_page_name(page)
62
495e9dc4 63 return self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
df01767e 64 VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
181d08f3 65
495e9dc4 66 def delete_page(self, page, author, **kwargs):
181d08f3
MT
67 # Do nothing if the page does not exist
68 if not self.get_page(page):
69 return
70
71 # Just creates a blank last version of the page
495e9dc4 72 self.create_page(page, author=author, content=None, **kwargs)
181d08f3 73
3168788e
MT
74 def make_breadcrumbs(self, url):
75 # Split and strip all empty elements (double slashes)
181d08f3
MT
76 parts = list(e for e in url.split("/") if e)
77
3168788e 78 ret = []
b1bf7d48 79 for part in ("/".join(parts[:i]) for i in range(1, len(parts))):
3168788e 80 ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part))))
181d08f3 81
3168788e 82 return ret
181d08f3 83
11afe905 84 def search(self, query, account=None, limit=None):
9523790a
MT
85 query = util.parse_search_query(query)
86
87 res = self._get_pages("SELECT wiki.* FROM wiki_search_index search_index \
88 LEFT JOIN wiki ON search_index.wiki_id = wiki.id \
89 WHERE search_index.document @@ to_tsquery('english', %s) \
11afe905
MT
90 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC",
91 query, query)
9523790a 92
11afe905
MT
93 for page in res:
94 # Skip any pages the user doesn't have permission for
95 if not page.check_acl(account):
96 continue
97
98 # Return any other pages
99 yield page
100
101 limit -= 1
102 if not limit:
103 break
9523790a
MT
104
105 def refresh(self):
106 """
107 Needs to be called after a page has been changed
108 """
109 self.db.execute("REFRESH MATERIALIZED VIEW wiki_search_index")
110
11afe905
MT
111 # ACL
112
113 def check_acl(self, page, account):
114 res = self.db.query("SELECT * FROM wiki_acls \
115 WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page)
116
117 for row in res:
118 # Access not permitted when user is not logged in
119 if not account:
120 return False
121
122 # If user is in a matching group, we grant permission
123 for group in row.groups:
124 if group in account.groups:
125 return True
126
127 # Otherwise access is not permitted
128 return False
129
130 # If no ACLs are found, we permit access
131 return True
132
f2cfd873
MT
133 # Files
134
135 def _get_files(self, query, *args):
136 res = self.db.query(query, *args)
137
138 for row in res:
139 yield File(self.backend, row.id, data=row)
140
141 def _get_file(self, query, *args):
142 res = self.db.get(query, *args)
143
144 if res:
145 return File(self.backend, res.id, data=res)
146
147 def get_files(self, path):
148 files = self._get_files("SELECT * FROM wiki_files \
149 WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path)
150
151 return list(files)
152
153 def get_file_by_path(self, path):
154 path, filename = os.path.dirname(path), os.path.basename(path)
155
156 return self._get_file("SELECT * FROM wiki_files \
157 WHERE path = %s AND filename = %s AND deleted_at IS NULL", path, filename)
158
159 def upload(self, path, filename, data, mimetype, author, address):
160 # Upload the blob first
161 blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) RETURNING id", data)
162
163 # Create entry for file
164 return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \
165 mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path,
166 filename, author.uid, address, mimetype, blob.id, len(data))
167
9e90e800
MT
168 def find_image(self, path, filename):
169 for p in (path, os.path.dirname(path)):
170 file = self.get_file_by_path(os.path.join(p, filename))
171
172 if file and file.is_image():
173 return file
174
181d08f3
MT
175
176class Page(misc.Object):
177 def init(self, id, data=None):
178 self.id = id
179 self.data = data
180
181 def __lt__(self, other):
182 if isinstance(other, self.__class__):
183 if self.page == other.page:
184 return self.timestamp < other.timestamp
185
186 return self.page < other.page
187
188 @staticmethod
189 def sanitise_page_name(page):
190 if not page:
191 return "/"
192
193 # Make sure that the page name does NOT end with a /
194 if page.endswith("/"):
195 page = page[:-1]
196
197 # Make sure the page name starts with a /
198 if not page.startswith("/"):
199 page = "/%s" % page
200
201 # Remove any double slashes
202 page = page.replace("//", "/")
203
204 return page
205
206 @property
207 def url(self):
db8448d9 208 return self.page
181d08f3
MT
209
210 @property
211 def page(self):
212 return self.data.page
213
214 @property
215 def title(self):
216 return self._title or self.page[1:]
217
218 @property
219 def _title(self):
220 if not self.markdown:
221 return
222
223 # Find first H1 headline in markdown
224 markdown = self.markdown.splitlines()
225
226 m = re.match(r"^# (.*)( #)?$", markdown[0])
227 if m:
228 return m.group(1)
229
3b05ef6e
MT
230 @lazy_property
231 def author(self):
232 if self.data.author_uid:
233 return self.backend.accounts.get_by_uid(self.data.author_uid)
234
181d08f3
MT
235 def _render(self, text):
236 logging.debug("Rendering %s" % self)
237
9e90e800
MT
238 # Link images
239 replacements = []
240 for match in re.finditer(r"!\[(.*)\]\((.*)\)", text):
241 alt_text, url = match.groups()
242
243 # Skip any absolute and external URLs
244 if url.startswith("/") or url.startswith("https://") or url.startswith("http://"):
245 continue
246
247 # Try to split query string
248 url, delimiter, qs = url.partition("?")
249
250 # Parse query arguments
251 args = urllib.parse.parse_qs(qs)
252
253 # Find image
254 file = self.backend.wiki.find_image(self.page, url)
255 if not file:
256 continue
257
258 # Scale down the image if not already done
259 if not "s" in args:
260 args["s"] = "768"
261
262 # Format URL
263 url = "%s?%s" % (file.url, urllib.parse.urlencode(args))
264
bf59e35d 265 replacements.append((match.span(), file, alt_text, url))
9e90e800
MT
266
267 # Apply all replacements
bf59e35d
MT
268 for (start, end), file, alt_text, url in reversed(replacements):
269 text = text[:start] + "[![%s](%s)](%s?action=detail)" % (alt_text, url, file.url) + text[end:]
9e90e800 270
9e90e800 271 # Add wiki links
574794da
MT
272 patterns = (
273 (r"\[\[([\w\d\/]+)(?:\|([\w\d\s]+))\]\]", r"/\1", r"\2", None, None),
274 (r"\[\[([\w\d\/\-]+)\]\]", r"/\1", r"\1", self.backend.wiki.get_page_title, r"\1"),
275 )
276
277 for pattern, link, title, repl, args in patterns:
278 replacements = []
279
280 for match in re.finditer(pattern, text):
281 l = match.expand(link)
282 t = match.expand(title)
283
284 if callable(repl):
285 t = repl(match.expand(args)) or t
286
287 replacements.append((match.span(), t or l, l))
288
289 # Apply all replacements
290 for (start, end), t, l in reversed(replacements):
291 text = text[:start] + "[%s](%s)" % (t, l) + text[end:]
292
045ea3db
MT
293 # Borrow this from the blog
294 return self.backend.blog._render_text(text, lang="markdown")
181d08f3
MT
295
296 @property
297 def markdown(self):
298 return self.data.markdown
299
300 @property
301 def html(self):
302 return self.data.html or self._render(self.markdown)
303
304 @property
305 def timestamp(self):
306 return self.data.timestamp
307
308 def was_deleted(self):
309 return self.markdown is None
310
311 @lazy_property
312 def breadcrumbs(self):
313 return self.backend.wiki.make_breadcrumbs(self.page)
314
315 def get_latest_revision(self):
7d699684
MT
316 revisions = self.get_revisions()
317
318 # Return first object
319 for rev in revisions:
320 return rev
321
322 def get_revisions(self):
323 return self.backend.wiki._get_pages("SELECT * FROM wiki \
324 WHERE page = %s ORDER BY timestamp DESC", self.page)
091ac36b 325
d398ca08
MT
326 @property
327 def changes(self):
328 return self.data.changes
329
11afe905
MT
330 # ACL
331
332 def check_acl(self, account):
333 return self.backend.wiki.check_acl(self.page, account)
334
091ac36b
MT
335 # Sidebar
336
337 @lazy_property
338 def sidebar(self):
339 parts = self.page.split("/")
340
341 while parts:
3cc5f666 342 sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts))
091ac36b
MT
343 if sidebar:
344 return sidebar
345
346 parts.pop()
f2cfd873
MT
347
348
349class File(misc.Object):
350 def init(self, id, data):
351 self.id = id
352 self.data = data
353
354 @property
355 def url(self):
356 return os.path.join(self.path, self.filename)
357
358 @property
359 def path(self):
360 return self.data.path
361
362 @property
363 def filename(self):
364 return self.data.filename
365
366 @property
367 def mimetype(self):
368 return self.data.mimetype
369
370 @property
371 def size(self):
372 return self.data.size
373
8cb0bea4
MT
374 @lazy_property
375 def author(self):
376 if self.data.author_uid:
377 return self.backend.accounts.get_by_uid(self.data.author_uid)
378
379 @property
380 def created_at(self):
381 return self.data.created_at
382
383 def is_pdf(self):
384 return self.mimetype in ("application/pdf", "application/x-pdf")
385
f2cfd873
MT
386 def is_image(self):
387 return self.mimetype.startswith("image/")
388
389 @lazy_property
390 def blob(self):
391 res = self.db.get("SELECT data FROM wiki_blobs \
392 WHERE id = %s", self.data.blob_id)
393
394 if res:
395 return bytes(res.data)
79dd9a0f
MT
396
397 def get_thumbnail(self, size):
75d9b3da
MT
398 cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size))
399
400 # Try to fetch the data from the cache
401 thumbnail = self.memcache.get(cache_key)
402 if thumbnail:
403 return thumbnail
404
405 # Generate the thumbnail
406 thumbnail = self._generate_thumbnail(size)
407
408 # Put it into the cache for forever
409 self.memcache.set(cache_key, thumbnail)
410
411 return thumbnail
412
413 def _generate_thumbnail(self, size):
79dd9a0f
MT
414 image = PIL.Image.open(io.BytesIO(self.blob))
415
416 # Resize the image to the desired resolution
417 image.thumbnail((size, size), PIL.Image.ANTIALIAS)
418
419 with io.BytesIO() as f:
420 # If writing out the image does not work with optimization,
421 # we try to write it out without any optimization.
422 try:
423 image.save(f, image.format, optimize=True, quality=98)
424 except:
425 image.save(f, image.format, quality=98)
426
427 return f.getvalue()