]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/wiki.py
wiki: Allow uploading newer revisions of a file
[ipfire.org.git] / src / backend / wiki.py
1 #!/usr/bin/python3
2
3 import difflib
4 import logging
5 import os.path
6 import re
7 import tornado.gen
8 import urllib.parse
9
10 from . import misc
11 from . import util
12 from .decorators import *
13
14 class 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
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
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
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
50 def get_page_title(self, page, default=None):
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
58 doc = self.get_page(page)
59 if doc:
60 title = doc.title
61 else:
62 title = os.path.basename(page)
63
64 # Save in cache for forever
65 self.memcache.set("wiki:title:%s" % page, title)
66
67 return title
68
69 def get_page(self, page, revision=None):
70 page = Page.sanitise_page_name(page)
71 assert page
72
73 if revision:
74 return self._get_page("SELECT * FROM wiki WHERE page = %s \
75 AND timestamp = %s", page, revision)
76 else:
77 return self._get_page("SELECT * FROM wiki WHERE page = %s \
78 ORDER BY timestamp DESC LIMIT 1", page)
79
80 def get_recent_changes(self, account, limit=None):
81 pages = self._get_pages("SELECT * FROM wiki \
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
93
94 def create_page(self, page, author, content, changes=None, address=None):
95 page = Page.sanitise_page_name(page)
96
97 # Write page to the database
98 page = self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
99 VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
100
101 # Update cache
102 self.memcache.set("wiki:title:%s" % page.page, page.title)
103
104 # Send email to all watchers
105 page._send_watcher_emails(excludes=[author])
106
107 return page
108
109 def delete_page(self, page, author, **kwargs):
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
115 self.create_page(page, author=author, content=None, **kwargs)
116
117 def make_breadcrumbs(self, url):
118 # Split and strip all empty elements (double slashes)
119 parts = list(e for e in url.split("/") if e)
120
121 ret = []
122 for part in ("/".join(parts[:i]) for i in range(1, len(parts))):
123 ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part))))
124
125 return ret
126
127 def search(self, query, account=None, limit=None):
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) \
133 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC",
134 query, query)
135
136 pages = []
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
143 pages.append(page)
144
145 # Break when we have found enough pages
146 if limit and len(pages) >= limit:
147 break
148
149 return pages
150
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
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
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
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
211 def get_file_by_path(self, path, revision=None):
212 path, filename = os.path.dirname(path), os.path.basename(path)
213
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):
226 return self._get_file("SELECT * FROM wiki_files \
227 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
228 path, filename)
229
230 def upload(self, path, filename, data, mimetype, author, address):
231 # Replace any existing files
232 file = self.get_file_by_path_and_filename(path, filename)
233 if file:
234 file.delete(author)
235
236 # Upload the blob first
237 blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) RETURNING id", data)
238
239 # Create entry for file
240 return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \
241 mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path,
242 filename, author.uid, address, mimetype, blob.id, len(data))
243
244 def render(self, path, text):
245 r = WikiRenderer(self.backend, path)
246
247 return r.render(text)
248
249
250 class Page(misc.Object):
251 def init(self, id, data=None):
252 self.id = id
253 self.data = data
254
255 def __repr__(self):
256 return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp)
257
258 def __eq__(self, other):
259 if isinstance(other, self.__class__):
260 return self.id == other.id
261
262 def __lt__(self, other):
263 if isinstance(other, self.__class__):
264 if self.page == other.page:
265 return self.timestamp < other.timestamp
266
267 return self.page < other.page
268
269 @staticmethod
270 def sanitise_page_name(page):
271 if not page:
272 return "/"
273
274 # Make sure that the page name does NOT end with a /
275 if page.endswith("/"):
276 page = page[:-1]
277
278 # Make sure the page name starts with a /
279 if not page.startswith("/"):
280 page = "/%s" % page
281
282 # Remove any double slashes
283 page = page.replace("//", "/")
284
285 return page
286
287 @property
288 def url(self):
289 return self.page
290
291 @property
292 def full_url(self):
293 return "https://wiki.ipfire.org%s" % self.url
294
295 @property
296 def page(self):
297 return self.data.page
298
299 @property
300 def title(self):
301 return self._title or os.path.basename(self.page[1:])
302
303 @property
304 def _title(self):
305 if not self.markdown:
306 return
307
308 # Find first H1 headline in markdown
309 markdown = self.markdown.splitlines()
310
311 m = re.match(r"^#\s*(.*)( #)?$", markdown[0])
312 if m:
313 return m.group(1)
314
315 @lazy_property
316 def author(self):
317 if self.data.author_uid:
318 return self.backend.accounts.get_by_uid(self.data.author_uid)
319
320 @property
321 def markdown(self):
322 return self.data.markdown or ""
323
324 @property
325 def html(self):
326 return self.backend.wiki.render(self.page, self.markdown)
327
328 @property
329 def timestamp(self):
330 return self.data.timestamp
331
332 def was_deleted(self):
333 return self.markdown is None
334
335 @lazy_property
336 def breadcrumbs(self):
337 return self.backend.wiki.make_breadcrumbs(self.page)
338
339 def get_latest_revision(self):
340 revisions = self.get_revisions()
341
342 # Return first object
343 for rev in revisions:
344 return rev
345
346 def get_revisions(self):
347 return self.backend.wiki._get_pages("SELECT * FROM wiki \
348 WHERE page = %s ORDER BY timestamp DESC", self.page)
349
350 @lazy_property
351 def previous_revision(self):
352 return self.backend.wiki._get_page("SELECT * FROM wiki \
353 WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \
354 LIMIT 1", self.page, self.timestamp)
355
356 @property
357 def changes(self):
358 return self.data.changes
359
360 # ACL
361
362 def check_acl(self, account):
363 return self.backend.wiki.check_acl(self.page, account)
364
365 # Sidebar
366
367 @lazy_property
368 def sidebar(self):
369 parts = self.page.split("/")
370
371 while parts:
372 sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts))
373 if sidebar:
374 return sidebar
375
376 parts.pop()
377
378 # Watchers
379
380 @lazy_property
381 def diff(self):
382 if self.previous_revision:
383 diff = difflib.unified_diff(
384 self.previous_revision.markdown.splitlines(),
385 self.markdown.splitlines(),
386 )
387
388 return "\n".join(diff)
389
390 @property
391 def watchers(self):
392 res = self.db.query("SELECT uid FROM wiki_watchlist \
393 WHERE page = %s", self.page)
394
395 for row in res:
396 # Search for account by UID and skip if none was found
397 account = self.backend.accounts.get_by_uid(row.uid)
398 if not account:
399 continue
400
401 # Return the account
402 yield account
403
404 def is_watched_by(self, account):
405 res = self.db.get("SELECT 1 FROM wiki_watchlist \
406 WHERE page = %s AND uid = %s", self.page, account.uid)
407
408 if res:
409 return True
410
411 return False
412
413 def add_watcher(self, account):
414 if self.is_watched_by(account):
415 return
416
417 self.db.execute("INSERT INTO wiki_watchlist(page, uid) \
418 VALUES(%s, %s)", self.page, account.uid)
419
420 def remove_watcher(self, account):
421 self.db.execute("DELETE FROM wiki_watchlist \
422 WHERE page = %s AND uid = %s", self.page, account.uid)
423
424 def _send_watcher_emails(self, excludes=[]):
425 # Nothing to do if there was no previous revision
426 if not self.previous_revision:
427 return
428
429 for watcher in self.watchers:
430 # Skip everyone who is excluded
431 if watcher in excludes:
432 logging.debug("Excluding %s" % watcher)
433 continue
434
435 # Check permissions
436 if not self.backend.wiki.check_acl(self.page, watcher):
437 logging.debug("Watcher %s does not have permissions" % watcher)
438 continue
439
440 logging.debug("Sending watcher email to %s" % watcher)
441
442 # Compose message
443 self.backend.messages.send_template("wiki/messages/page-changed",
444 sender="IPFire Wiki <wiki@ipfire.org>", recipients=[watcher],
445 page=self, priority=-10)
446
447
448 class File(misc.Object):
449 def init(self, id, data):
450 self.id = id
451 self.data = data
452
453 def __eq__(self, other):
454 if isinstance(other, self.__class__):
455 return self.id == other.id
456
457 @property
458 def url(self):
459 return os.path.join(self.path, self.filename)
460
461 @property
462 def path(self):
463 return self.data.path
464
465 @property
466 def filename(self):
467 return self.data.filename
468
469 @property
470 def mimetype(self):
471 return self.data.mimetype
472
473 @property
474 def size(self):
475 return self.data.size
476
477 @lazy_property
478 def author(self):
479 if self.data.author_uid:
480 return self.backend.accounts.get_by_uid(self.data.author_uid)
481
482 @property
483 def created_at(self):
484 return self.data.created_at
485
486 def delete(self, author):
487 # XXX handle author
488 self.db.execute("UPDATE wiki_files SET deleted_at = NOW() \
489 WHERE id = %s", self.id)
490
491 @property
492 def deleted_at(self):
493 return self.data.deleted_at
494
495 def get_latest_revision(self):
496 revisions = self.get_revisions()
497
498 # Return first object
499 for rev in revisions:
500 return rev
501
502 def get_revisions(self):
503 revisions = self.backend.wiki._get_files("SELECT * FROM wiki_files \
504 WHERE path = %s ORDER BY created_at DESC", self.path)
505
506 return list(revisions)
507
508 def is_pdf(self):
509 return self.mimetype in ("application/pdf", "application/x-pdf")
510
511 def is_image(self):
512 return self.mimetype.startswith("image/")
513
514 @lazy_property
515 def blob(self):
516 res = self.db.get("SELECT data FROM wiki_blobs \
517 WHERE id = %s", self.data.blob_id)
518
519 if res:
520 return bytes(res.data)
521
522 def get_thumbnail(self, size):
523 cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size))
524
525 # Try to fetch the data from the cache
526 thumbnail = self.memcache.get(cache_key)
527 if thumbnail:
528 return thumbnail
529
530 # Generate the thumbnail
531 thumbnail = util.generate_thumbnail(self.blob, size)
532
533 # Put it into the cache for forever
534 self.memcache.set(cache_key, thumbnail)
535
536 return thumbnail
537
538
539 class WikiRenderer(misc.Object):
540 schemas = (
541 "ftp://",
542 "git://",
543 "http://",
544 "https://",
545 "rsync://",
546 "sftp://",
547 "ssh://",
548 "webcal://",
549 )
550
551 # Links
552 links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
553
554 # Images
555 images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
556
557 def init(self, path):
558 self.path = path
559
560 def _render_link(self, m):
561 url, text = m.groups()
562
563 # Emails
564 if "@" in url:
565 # Strip mailto:
566 if url.startswith("mailto:"):
567 url = url[7:]
568
569 return """<a class="link-external" href="mailto:%s">%s</a>""" % \
570 (url, text or url)
571
572 # External Links
573 for schema in self.schemas:
574 if url.startswith(schema):
575 return """<a class="link-external" href="%s">%s</a>""" % \
576 (url, text or url)
577
578 # Everything else must be an internal link
579 path = self.backend.wiki.make_path(self.path, url)
580
581 return """<a href="%s">%s</a>""" % \
582 (path, text or self.backend.wiki.get_page_title(path))
583
584 def _render_image(self, m):
585 alt_text, url, caption = m.groups()
586
587 # Skip any absolute and external URLs
588 if url.startswith("/") or url.startswith("https://") or url.startswith("http://"):
589 return """<figure class="figure"><img src="%s" class="figure-img img-fluid rounded" alt="%s">
590 <figcaption class="figure-caption">%s</figcaption></figure>
591 """ % (url, alt_text, caption or "")
592
593 # Try to split query string
594 url, delimiter, qs = url.partition("?")
595
596 # Parse query arguments
597 args = urllib.parse.parse_qs(qs)
598
599 # Build absolute path
600 url = self.backend.wiki.make_path(self.path, url)
601
602 # Find image
603 file = self.backend.wiki.get_file_by_path(url)
604 if not file or not file.is_image():
605 return "<!-- Could not find image %s in %s -->" % (url, self.path)
606
607 # Scale down the image if not already done
608 if not "s" in args:
609 args["s"] = "920"
610
611 return """<figure class="figure"><img src="%s?%s" class="figure-img img-fluid rounded" alt="%s">
612 <figcaption class="figure-caption">%s</figcaption></figure>
613 """ % (url, urllib.parse.urlencode(args), caption, caption or "")
614
615 def render(self, text):
616 logging.debug("Rendering %s" % self.path)
617
618 # Borrow this from the blog
619 text = self.backend.blog._render_text(text, lang="markdown")
620
621 # Postprocess links
622 text = self.links.sub(self._render_link, text)
623
624 # Postprocess images to <figure>
625 text = self.images.sub(self._render_image, text)
626
627 return text