]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/wiki.py
wiki: Fix deleted page detection
[ipfire.org.git] / src / backend / wiki.py
CommitLineData
181d08f3
MT
1#!/usr/bin/python3
2
4ed1dadb 3import difflib
181d08f3 4import logging
6ac7e934 5import os.path
181d08f3 6import re
addc18d5 7import tornado.gen
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
c78ad26e
MT
27 def make_path(self, page, path):
28 # Nothing to do for absolute links
29 if path.startswith("/"):
30 pass
31
32 # Relative links (one-level down)
33 elif path.startswith("./"):
34 path = os.path.join(page, path)
35
36 # All other relative links
37 else:
38 p = os.path.dirname(page)
39 path = os.path.join(p, path)
40
41 # Normalise links
42 return os.path.normpath(path)
43
9ff59d70
MT
44 def page_exists(self, path):
45 page = self.get_page(path)
46
47 # Page must have been found and not deleted
48 return page and not page.was_deleted()
49
6ac7e934 50 def get_page_title(self, page, default=None):
50c8dc11
MT
51 # Try to retrieve title from cache
52 title = self.memcache.get("wiki:title:%s" % page)
53 if title:
54 return title
55
56 # If the title has not been in the cache, we will
57 # have to look it up
6ac7e934
MT
58 doc = self.get_page(page)
59 if doc:
50c8dc11
MT
60 title = doc.title
61 else:
62 title = os.path.basename(page)
6ac7e934 63
50c8dc11
MT
64 # Save in cache for forever
65 self.memcache.set("wiki:title:%s" % page, title)
66
67 return title
6ac7e934 68
181d08f3
MT
69 def get_page(self, page, revision=None):
70 page = Page.sanitise_page_name(page)
71 assert page
72
73 if revision:
d398ca08 74 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
75 AND timestamp = %s", page, revision)
76 else:
d398ca08 77 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
78 ORDER BY timestamp DESC LIMIT 1", page)
79
11afe905
MT
80 def get_recent_changes(self, account, limit=None):
81 pages = self._get_pages("SELECT * FROM wiki \
11afe905
MT
82 ORDER BY timestamp DESC")
83
84 for page in pages:
85 if not page.check_acl(account):
86 continue
87
88 yield page
89
90 limit -= 1
91 if not limit:
92 break
181d08f3 93
495e9dc4 94 def create_page(self, page, author, content, changes=None, address=None):
181d08f3
MT
95 page = Page.sanitise_page_name(page)
96
aba5e58a
MT
97 # Write page to the database
98 page = self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
df01767e 99 VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
181d08f3 100
50c8dc11 101 # Update cache
980e486d 102 self.memcache.set("wiki:title:%s" % page.page, page.title)
50c8dc11 103
aba5e58a
MT
104 # Send email to all watchers
105 page._send_watcher_emails(excludes=[author])
106
107 return page
108
495e9dc4 109 def delete_page(self, page, author, **kwargs):
181d08f3
MT
110 # Do nothing if the page does not exist
111 if not self.get_page(page):
112 return
113
114 # Just creates a blank last version of the page
495e9dc4 115 self.create_page(page, author=author, content=None, **kwargs)
181d08f3 116
3168788e
MT
117 def make_breadcrumbs(self, url):
118 # Split and strip all empty elements (double slashes)
181d08f3
MT
119 parts = list(e for e in url.split("/") if e)
120
3168788e 121 ret = []
b1bf7d48 122 for part in ("/".join(parts[:i]) for i in range(1, len(parts))):
3168788e 123 ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part))))
181d08f3 124
3168788e 125 return ret
181d08f3 126
11afe905 127 def search(self, query, account=None, limit=None):
9523790a
MT
128 query = util.parse_search_query(query)
129
130 res = self._get_pages("SELECT wiki.* FROM wiki_search_index search_index \
131 LEFT JOIN wiki ON search_index.wiki_id = wiki.id \
132 WHERE search_index.document @@ to_tsquery('english', %s) \
11afe905
MT
133 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC",
134 query, query)
9523790a 135
df80be2c 136 pages = []
11afe905
MT
137 for page in res:
138 # Skip any pages the user doesn't have permission for
139 if not page.check_acl(account):
140 continue
141
142 # Return any other pages
df80be2c 143 pages.append(page)
11afe905 144
df80be2c
MT
145 # Break when we have found enough pages
146 if limit and len(pages) >= limit:
11afe905 147 break
9523790a 148
df80be2c
MT
149 return pages
150
9523790a
MT
151 def refresh(self):
152 """
153 Needs to be called after a page has been changed
154 """
155 self.db.execute("REFRESH MATERIALIZED VIEW wiki_search_index")
156
2f23c558
MT
157 def get_watchlist(self, account):
158 pages = self._get_pages(
159 "WITH pages AS (SELECT * FROM wiki_current \
160 LEFT JOIN wiki ON wiki_current.id = wiki.id) \
161 SELECT * FROM wiki_watchlist watchlist \
162 LEFT JOIN pages ON watchlist.page = pages.page \
163 WHERE watchlist.uid = %s",
164 account.uid,
165 )
166
167 return sorted(pages)
168
11afe905
MT
169 # ACL
170
171 def check_acl(self, page, account):
172 res = self.db.query("SELECT * FROM wiki_acls \
173 WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page)
174
175 for row in res:
176 # Access not permitted when user is not logged in
177 if not account:
178 return False
179
180 # If user is in a matching group, we grant permission
181 for group in row.groups:
182 if group in account.groups:
183 return True
184
185 # Otherwise access is not permitted
186 return False
187
188 # If no ACLs are found, we permit access
189 return True
190
f2cfd873
MT
191 # Files
192
193 def _get_files(self, query, *args):
194 res = self.db.query(query, *args)
195
196 for row in res:
197 yield File(self.backend, row.id, data=row)
198
199 def _get_file(self, query, *args):
200 res = self.db.get(query, *args)
201
202 if res:
203 return File(self.backend, res.id, data=res)
204
205 def get_files(self, path):
206 files = self._get_files("SELECT * FROM wiki_files \
207 WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path)
208
209 return list(files)
210
ff14dea3 211 def get_file_by_path(self, path, revision=None):
f2cfd873
MT
212 path, filename = os.path.dirname(path), os.path.basename(path)
213
ff14dea3
MT
214 if revision:
215 # Fetch a specific revision
216 return self._get_file("SELECT * FROM wiki_files \
217 WHERE path = %s AND filename = %s AND created_at <= %s \
218 ORDER BY created_at DESC LIMIT 1", path, filename, revision)
219
220 # Fetch latest version
221 return self._get_file("SELECT * FROM wiki_files \
222 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
223 path, filename)
224
225 def get_file_by_path_and_filename(self, path, filename):
f2cfd873 226 return self._get_file("SELECT * FROM wiki_files \
ff14dea3
MT
227 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
228 path, filename)
f2cfd873
MT
229
230 def upload(self, path, filename, data, mimetype, author, address):
ff14dea3
MT
231 # Replace any existing files
232 file = self.get_file_by_path_and_filename(path, filename)
233 if file:
234 file.delete(author)
235
f2cfd873 236 # Upload the blob first
a3a8a163
MT
237 blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) \
238 ON CONFLICT (digest(data, %s)) DO UPDATE SET data = EXCLUDED.data \
239 RETURNING id", data, "MD5")
f2cfd873
MT
240
241 # Create entry for file
242 return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \
243 mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path,
244 filename, author.uid, address, mimetype, blob.id, len(data))
245
2901b734
MT
246 def render(self, path, text):
247 r = WikiRenderer(self.backend, path)
181d08f3 248
2901b734 249 return r.render(text)
e2205cff 250
154f6179 251
2901b734 252class Page(misc.Object):
181d08f3
MT
253 def init(self, id, data=None):
254 self.id = id
255 self.data = data
256
dc847af5
MT
257 def __repr__(self):
258 return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp)
259
c21ffadb
MT
260 def __eq__(self, other):
261 if isinstance(other, self.__class__):
262 return self.id == other.id
263
181d08f3
MT
264 def __lt__(self, other):
265 if isinstance(other, self.__class__):
266 if self.page == other.page:
267 return self.timestamp < other.timestamp
268
269 return self.page < other.page
270
271 @staticmethod
272 def sanitise_page_name(page):
273 if not page:
274 return "/"
275
276 # Make sure that the page name does NOT end with a /
277 if page.endswith("/"):
278 page = page[:-1]
279
280 # Make sure the page name starts with a /
281 if not page.startswith("/"):
282 page = "/%s" % page
283
284 # Remove any double slashes
285 page = page.replace("//", "/")
286
287 return page
288
289 @property
290 def url(self):
db8448d9 291 return self.page
181d08f3 292
4ed1dadb
MT
293 @property
294 def full_url(self):
295 return "https://wiki.ipfire.org%s" % self.url
296
181d08f3
MT
297 @property
298 def page(self):
299 return self.data.page
300
301 @property
302 def title(self):
51e7a876 303 return self._title or os.path.basename(self.page[1:])
181d08f3
MT
304
305 @property
306 def _title(self):
307 if not self.markdown:
308 return
309
310 # Find first H1 headline in markdown
311 markdown = self.markdown.splitlines()
312
0074e919 313 m = re.match(r"^#\s*(.*)( #)?$", markdown[0])
181d08f3
MT
314 if m:
315 return m.group(1)
316
3b05ef6e
MT
317 @lazy_property
318 def author(self):
319 if self.data.author_uid:
320 return self.backend.accounts.get_by_uid(self.data.author_uid)
321
181d08f3
MT
322 @property
323 def markdown(self):
c21ffadb 324 return self.data.markdown or ""
181d08f3
MT
325
326 @property
327 def html(self):
2901b734 328 return self.backend.wiki.render(self.page, self.markdown)
addc18d5 329
181d08f3
MT
330 @property
331 def timestamp(self):
332 return self.data.timestamp
333
334 def was_deleted(self):
4c13230c 335 return not self.markdown
181d08f3
MT
336
337 @lazy_property
338 def breadcrumbs(self):
339 return self.backend.wiki.make_breadcrumbs(self.page)
340
d4c68c5c
MT
341 def is_latest_revision(self):
342 return self.get_latest_revision() == self
343
181d08f3 344 def get_latest_revision(self):
7d699684
MT
345 revisions = self.get_revisions()
346
347 # Return first object
348 for rev in revisions:
349 return rev
350
351 def get_revisions(self):
352 return self.backend.wiki._get_pages("SELECT * FROM wiki \
353 WHERE page = %s ORDER BY timestamp DESC", self.page)
091ac36b 354
c21ffadb
MT
355 @lazy_property
356 def previous_revision(self):
357 return self.backend.wiki._get_page("SELECT * FROM wiki \
358 WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \
359 LIMIT 1", self.page, self.timestamp)
360
d398ca08
MT
361 @property
362 def changes(self):
363 return self.data.changes
364
11afe905
MT
365 # ACL
366
367 def check_acl(self, account):
368 return self.backend.wiki.check_acl(self.page, account)
369
091ac36b
MT
370 # Sidebar
371
372 @lazy_property
373 def sidebar(self):
374 parts = self.page.split("/")
375
376 while parts:
3cc5f666 377 sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts))
091ac36b
MT
378 if sidebar:
379 return sidebar
380
381 parts.pop()
f2cfd873 382
d64a1e35
MT
383 # Watchers
384
4ed1dadb
MT
385 @lazy_property
386 def diff(self):
387 if self.previous_revision:
388 diff = difflib.unified_diff(
389 self.previous_revision.markdown.splitlines(),
390 self.markdown.splitlines(),
391 )
392
393 return "\n".join(diff)
394
aba5e58a
MT
395 @property
396 def watchers(self):
397 res = self.db.query("SELECT uid FROM wiki_watchlist \
398 WHERE page = %s", self.page)
399
400 for row in res:
401 # Search for account by UID and skip if none was found
402 account = self.backend.accounts.get_by_uid(row.uid)
403 if not account:
404 continue
405
406 # Return the account
407 yield account
408
f2e25ded 409 def is_watched_by(self, account):
d64a1e35
MT
410 res = self.db.get("SELECT 1 FROM wiki_watchlist \
411 WHERE page = %s AND uid = %s", self.page, account.uid)
412
413 if res:
414 return True
415
416 return False
417
418 def add_watcher(self, account):
f2e25ded 419 if self.is_watched_by(account):
d64a1e35
MT
420 return
421
422 self.db.execute("INSERT INTO wiki_watchlist(page, uid) \
423 VALUES(%s, %s)", self.page, account.uid)
424
425 def remove_watcher(self, account):
426 self.db.execute("DELETE FROM wiki_watchlist \
427 WHERE page = %s AND uid = %s", self.page, account.uid)
428
aba5e58a
MT
429 def _send_watcher_emails(self, excludes=[]):
430 # Nothing to do if there was no previous revision
431 if not self.previous_revision:
432 return
433
434 for watcher in self.watchers:
435 # Skip everyone who is excluded
436 if watcher in excludes:
437 logging.debug("Excluding %s" % watcher)
438 continue
439
516da0a9
MT
440 # Check permissions
441 if not self.backend.wiki.check_acl(self.page, watcher):
442 logging.debug("Watcher %s does not have permissions" % watcher)
443 continue
444
aba5e58a
MT
445 logging.debug("Sending watcher email to %s" % watcher)
446
4ed1dadb
MT
447 # Compose message
448 self.backend.messages.send_template("wiki/messages/page-changed",
213e6929 449 recipients=[watcher], page=self, priority=-10)
aba5e58a 450
d4c68c5c
MT
451 def restore(self, author, address):
452 changes = "Restore to revision from %s" % self.timestamp.isoformat()
453
454 return self.backend.wiki.create_page(self.page,
455 author, self.markdown, changes=changes, address=address)
456
f2cfd873
MT
457
458class File(misc.Object):
459 def init(self, id, data):
460 self.id = id
461 self.data = data
462
ff14dea3
MT
463 def __eq__(self, other):
464 if isinstance(other, self.__class__):
465 return self.id == other.id
466
f2cfd873
MT
467 @property
468 def url(self):
469 return os.path.join(self.path, self.filename)
470
471 @property
472 def path(self):
473 return self.data.path
474
475 @property
476 def filename(self):
477 return self.data.filename
478
479 @property
480 def mimetype(self):
481 return self.data.mimetype
482
483 @property
484 def size(self):
485 return self.data.size
486
8cb0bea4
MT
487 @lazy_property
488 def author(self):
489 if self.data.author_uid:
490 return self.backend.accounts.get_by_uid(self.data.author_uid)
491
492 @property
493 def created_at(self):
494 return self.data.created_at
495
b26c705a
MT
496 def delete(self, author=None):
497 self.db.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \
498 WHERE id = %s", author.uid if author else None, self.id)
ff14dea3
MT
499
500 @property
501 def deleted_at(self):
502 return self.data.deleted_at
503
504 def get_latest_revision(self):
505 revisions = self.get_revisions()
506
507 # Return first object
508 for rev in revisions:
509 return rev
510
511 def get_revisions(self):
512 revisions = self.backend.wiki._get_files("SELECT * FROM wiki_files \
513 WHERE path = %s ORDER BY created_at DESC", self.path)
514
515 return list(revisions)
516
8cb0bea4
MT
517 def is_pdf(self):
518 return self.mimetype in ("application/pdf", "application/x-pdf")
519
f2cfd873
MT
520 def is_image(self):
521 return self.mimetype.startswith("image/")
522
523 @lazy_property
524 def blob(self):
525 res = self.db.get("SELECT data FROM wiki_blobs \
526 WHERE id = %s", self.data.blob_id)
527
528 if res:
529 return bytes(res.data)
79dd9a0f
MT
530
531 def get_thumbnail(self, size):
75d9b3da
MT
532 cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size))
533
534 # Try to fetch the data from the cache
535 thumbnail = self.memcache.get(cache_key)
536 if thumbnail:
537 return thumbnail
538
539 # Generate the thumbnail
5ef115cd 540 thumbnail = util.generate_thumbnail(self.blob, size)
75d9b3da
MT
541
542 # Put it into the cache for forever
543 self.memcache.set(cache_key, thumbnail)
544
545 return thumbnail
2901b734
MT
546
547
548class WikiRenderer(misc.Object):
4ddad3e5
MT
549 schemas = (
550 "ftp://",
551 "git://",
552 "http://",
553 "https://",
554 "rsync://",
555 "sftp://",
556 "ssh://",
557 "webcal://",
558 )
559
560 # Links
561 links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
2901b734 562
c78ad26e 563 # Images
e9c6d581 564 images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
c78ad26e 565
2901b734
MT
566 def init(self, path):
567 self.path = path
568
4ddad3e5
MT
569 def _render_link(self, m):
570 url, text = m.groups()
2901b734 571
4ddad3e5
MT
572 # Emails
573 if "@" in url:
574 # Strip mailto:
575 if url.startswith("mailto:"):
576 url = url[7:]
2901b734 577
4ddad3e5
MT
578 return """<a class="link-external" href="mailto:%s">%s</a>""" % \
579 (url, text or url)
2901b734 580
4ddad3e5
MT
581 # External Links
582 for schema in self.schemas:
583 if url.startswith(schema):
584 return """<a class="link-external" href="%s">%s</a>""" % \
585 (url, text or url)
2901b734 586
4ddad3e5
MT
587 # Everything else must be an internal link
588 path = self.backend.wiki.make_path(self.path, url)
2901b734 589
4ddad3e5
MT
590 return """<a href="%s">%s</a>""" % \
591 (path, text or self.backend.wiki.get_page_title(path))
2901b734 592
c78ad26e 593 def _render_image(self, m):
e9c6d581 594 alt_text, url, caption = m.groups()
2901b734 595
c78ad26e
MT
596 # Skip any absolute and external URLs
597 if url.startswith("/") or url.startswith("https://") or url.startswith("http://"):
fa8c5edd
MT
598 return """<figure class="figure"><img src="%s" class="figure-img img-fluid rounded" alt="%s">
599 <figcaption class="figure-caption">%s</figcaption></figure>
9881e9ef 600 """ % (url, alt_text, caption or "")
2901b734 601
c78ad26e
MT
602 # Try to split query string
603 url, delimiter, qs = url.partition("?")
2901b734 604
c78ad26e
MT
605 # Parse query arguments
606 args = urllib.parse.parse_qs(qs)
2901b734 607
c78ad26e
MT
608 # Build absolute path
609 url = self.backend.wiki.make_path(self.path, url)
2901b734 610
c78ad26e
MT
611 # Find image
612 file = self.backend.wiki.get_file_by_path(url)
613 if not file or not file.is_image():
614 return "<!-- Could not find image %s in %s -->" % (url, self.path)
2901b734 615
c78ad26e
MT
616 # Scale down the image if not already done
617 if not "s" in args:
9ce45afb 618 args["s"] = "920"
2901b734 619
fa8c5edd
MT
620 return """<figure class="figure"><img src="%s?%s" class="figure-img img-fluid rounded" alt="%s">
621 <figcaption class="figure-caption">%s</figcaption></figure>
622 """ % (url, urllib.parse.urlencode(args), caption, caption or "")
2901b734 623
c78ad26e
MT
624 def render(self, text):
625 logging.debug("Rendering %s" % self.path)
2901b734 626
9881e9ef
MT
627 # Borrow this from the blog
628 text = self.backend.blog._render_text(text, lang="markdown")
629
4ddad3e5
MT
630 # Postprocess links
631 text = self.links.sub(self._render_link, text)
632
9881e9ef 633 # Postprocess images to <figure>
c78ad26e
MT
634 text = self.images.sub(self._render_image, text)
635
9881e9ef 636 return text