]>
Commit | Line | Data |
---|---|---|
1 | #!/usr/bin/python3 | |
2 | ||
3 | import difflib | |
4 | import hashlib | |
5 | import logging | |
6 | import markdown | |
7 | import markdown.extensions | |
8 | import markdown.preprocessors | |
9 | import os.path | |
10 | import re | |
11 | import urllib.parse | |
12 | ||
13 | from . import misc | |
14 | from . import util | |
15 | from .decorators import * | |
16 | ||
17 | class Wiki(misc.Object): | |
18 | def _get_pages(self, query, *args): | |
19 | res = self.db.query(query, *args) | |
20 | ||
21 | for row in res: | |
22 | yield Page(self.backend, row.id, data=row) | |
23 | ||
24 | def _get_page(self, query, *args): | |
25 | res = self.db.get(query, *args) | |
26 | ||
27 | if res: | |
28 | return Page(self.backend, res.id, data=res) | |
29 | ||
30 | def __iter__(self): | |
31 | return self._get_pages(""" | |
32 | SELECT | |
33 | wiki.* | |
34 | FROM | |
35 | wiki_current current | |
36 | LEFT JOIN | |
37 | wiki ON current.id = wiki.id | |
38 | WHERE | |
39 | current.deleted IS FALSE | |
40 | ORDER BY page | |
41 | """, | |
42 | ) | |
43 | ||
44 | def make_path(self, page, path): | |
45 | # Nothing to do for absolute links | |
46 | if path.startswith("/"): | |
47 | pass | |
48 | ||
49 | # Relative links (one-level down) | |
50 | elif path.startswith("./"): | |
51 | path = os.path.join(page, path) | |
52 | ||
53 | # All other relative links | |
54 | else: | |
55 | p = os.path.dirname(page) | |
56 | path = os.path.join(p, path) | |
57 | ||
58 | # Normalise links | |
59 | return os.path.normpath(path) | |
60 | ||
61 | def _make_url(self, path): | |
62 | """ | |
63 | Composes the URL out of the path | |
64 | """ | |
65 | # Remove any leading slashes (if present) | |
66 | path = path.removeprefix("/") | |
67 | ||
68 | return os.path.join("/docs", path) | |
69 | ||
70 | def get_page_title(self, page, default=None): | |
71 | doc = self.get_page(page) | |
72 | if doc: | |
73 | title = doc.title | |
74 | else: | |
75 | title = os.path.basename(page) | |
76 | ||
77 | return title | |
78 | ||
79 | def get_page(self, page, revision=None): | |
80 | page = Page.sanitise_page_name(page) | |
81 | ||
82 | # Split the path into parts | |
83 | parts = page.split("/") | |
84 | ||
85 | # Check if this is an action | |
86 | if any((part.startswith("_") for part in parts)): | |
87 | return | |
88 | ||
89 | if revision: | |
90 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ | |
91 | AND timestamp = %s", page, revision) | |
92 | else: | |
93 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ | |
94 | ORDER BY timestamp DESC LIMIT 1", page) | |
95 | ||
96 | def get_recent_changes(self, account, limit=None): | |
97 | pages = self._get_pages("SELECT * FROM wiki \ | |
98 | ORDER BY timestamp DESC") | |
99 | ||
100 | for page in pages: | |
101 | if not page.check_acl(account): | |
102 | continue | |
103 | ||
104 | yield page | |
105 | ||
106 | limit -= 1 | |
107 | if not limit: | |
108 | break | |
109 | ||
110 | def create_page(self, page, author, content, changes=None, address=None): | |
111 | page = Page.sanitise_page_name(page) | |
112 | ||
113 | # Write page to the database | |
114 | page = self._get_page(""" | |
115 | INSERT INTO | |
116 | wiki | |
117 | ( | |
118 | page, | |
119 | author_uid, | |
120 | markdown, | |
121 | changes, | |
122 | address | |
123 | ) VALUES ( | |
124 | %s, %s, %s, %s, %s | |
125 | ) | |
126 | RETURNING * | |
127 | """, page, author.uid, content or None, changes, address, | |
128 | ) | |
129 | ||
130 | # Store any linked files | |
131 | page._store_linked_files() | |
132 | ||
133 | # Send email to all watchers | |
134 | page._send_watcher_emails(excludes=[author]) | |
135 | ||
136 | return page | |
137 | ||
138 | def delete_page(self, page, author, **kwargs): | |
139 | # Do nothing if the page does not exist | |
140 | if not self.get_page(page): | |
141 | return | |
142 | ||
143 | # Just creates a blank last version of the page | |
144 | self.create_page(page, author=author, content=None, **kwargs) | |
145 | ||
146 | def make_breadcrumbs(self, path): | |
147 | ret = [] | |
148 | ||
149 | while path: | |
150 | # Cut off everything after the last slash | |
151 | path, _, _ = path.rpartition("/") | |
152 | ||
153 | # Do not include the root | |
154 | if not path: | |
155 | break | |
156 | ||
157 | # Find the page | |
158 | page = self.get_page(path) | |
159 | ||
160 | # Append the URL and title to the output | |
161 | ret.append(( | |
162 | page.url if page else self._make_url(path), | |
163 | page.title if page else os.path.basename(path), | |
164 | )) | |
165 | ||
166 | # Return the breadcrumbs in order | |
167 | return reversed(ret) | |
168 | ||
169 | def search(self, query, account=None, limit=None): | |
170 | res = self._get_pages(""" | |
171 | SELECT | |
172 | wiki.* | |
173 | FROM | |
174 | wiki_search_index search_index | |
175 | LEFT JOIN | |
176 | wiki ON search_index.wiki_id = wiki.id | |
177 | WHERE | |
178 | search_index.document @@ websearch_to_tsquery('english', %s) | |
179 | ORDER BY | |
180 | ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC | |
181 | """, query, query, | |
182 | ) | |
183 | ||
184 | pages = [] | |
185 | for page in res: | |
186 | # Skip any pages the user doesn't have permission for | |
187 | if not page.check_acl(account): | |
188 | continue | |
189 | ||
190 | # Return any other pages | |
191 | pages.append(page) | |
192 | ||
193 | # Break when we have found enough pages | |
194 | if limit and len(pages) >= limit: | |
195 | break | |
196 | ||
197 | return pages | |
198 | ||
199 | def refresh(self): | |
200 | """ | |
201 | Needs to be called after a page has been changed | |
202 | """ | |
203 | self.db.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY wiki_search_index") | |
204 | ||
205 | def get_watchlist(self, account): | |
206 | pages = self._get_pages(""" | |
207 | WITH pages AS ( | |
208 | SELECT | |
209 | * | |
210 | FROM | |
211 | wiki_current | |
212 | LEFT JOIN | |
213 | wiki ON wiki_current.id = wiki.id | |
214 | ) | |
215 | ||
216 | SELECT | |
217 | * | |
218 | FROM | |
219 | wiki_watchlist watchlist | |
220 | JOIN | |
221 | pages ON watchlist.page = pages.page | |
222 | WHERE | |
223 | watchlist.uid = %s | |
224 | """, account.uid, | |
225 | ) | |
226 | ||
227 | return sorted(pages) | |
228 | ||
229 | # ACL | |
230 | ||
231 | def check_acl(self, page, account): | |
232 | res = self.db.query(""" | |
233 | SELECT | |
234 | * | |
235 | FROM | |
236 | wiki_acls | |
237 | WHERE | |
238 | %s ILIKE (path || '%%') | |
239 | ORDER BY | |
240 | LENGTH(path) DESC | |
241 | LIMIT 1 | |
242 | """, page, | |
243 | ) | |
244 | ||
245 | for row in res: | |
246 | # Access not permitted when user is not logged in | |
247 | if not account: | |
248 | return False | |
249 | ||
250 | # If user is in a matching group, we grant permission | |
251 | for group in row.groups: | |
252 | if account.is_member_of_group(group): | |
253 | return True | |
254 | ||
255 | # Otherwise access is not permitted | |
256 | return False | |
257 | ||
258 | # If no ACLs are found, we permit access | |
259 | return True | |
260 | ||
261 | # Files | |
262 | ||
263 | def _get_files(self, query, *args): | |
264 | res = self.db.query(query, *args) | |
265 | ||
266 | for row in res: | |
267 | yield File(self.backend, row.id, data=row) | |
268 | ||
269 | def _get_file(self, query, *args): | |
270 | res = self.db.get(query, *args) | |
271 | ||
272 | if res: | |
273 | return File(self.backend, res.id, data=res) | |
274 | ||
275 | def get_files(self, path): | |
276 | files = self._get_files(""" | |
277 | SELECT | |
278 | * | |
279 | FROM | |
280 | wiki_files | |
281 | WHERE | |
282 | path = %s | |
283 | AND | |
284 | deleted_at IS NULL | |
285 | ORDER BY filename | |
286 | """, path, | |
287 | ) | |
288 | ||
289 | return list(files) | |
290 | ||
291 | def get_file_by_path(self, path, revision=None): | |
292 | path, filename = os.path.dirname(path), os.path.basename(path) | |
293 | ||
294 | if revision: | |
295 | # Fetch a specific revision | |
296 | return self._get_file(""" | |
297 | SELECT | |
298 | * | |
299 | FROM | |
300 | wiki_files | |
301 | WHERE | |
302 | path = %s | |
303 | AND | |
304 | filename = %s | |
305 | AND | |
306 | created_at <= %s | |
307 | ORDER BY | |
308 | created_at DESC | |
309 | LIMIT 1 | |
310 | """, path, filename, revision, | |
311 | ) | |
312 | ||
313 | # Fetch latest version | |
314 | return self._get_file(""" | |
315 | SELECT | |
316 | * | |
317 | FROM | |
318 | wiki_files | |
319 | WHERE | |
320 | path = %s | |
321 | AND | |
322 | filename = %s | |
323 | AND | |
324 | deleted_at IS NULL | |
325 | """, path, filename, | |
326 | ) | |
327 | ||
328 | def get_file_by_path_and_filename(self, path, filename): | |
329 | return self._get_file(""" | |
330 | SELECT | |
331 | * | |
332 | FROM | |
333 | wiki_files | |
334 | WHERE | |
335 | path = %s | |
336 | AND | |
337 | filename = %s | |
338 | AND | |
339 | deleted_at IS NULL | |
340 | """, path, filename, | |
341 | ) | |
342 | ||
343 | def upload(self, path, filename, data, mimetype, author, address): | |
344 | # Replace any existing files | |
345 | file = self.get_file_by_path_and_filename(path, filename) | |
346 | if file: | |
347 | file.delete(author) | |
348 | ||
349 | # Upload the blob first | |
350 | blob = self.db.get(""" | |
351 | INSERT INTO | |
352 | wiki_blobs(data) | |
353 | VALUES | |
354 | (%s) | |
355 | ON CONFLICT | |
356 | (digest(data, %s)) | |
357 | DO UPDATE | |
358 | SET data = EXCLUDED.data | |
359 | RETURNING id | |
360 | """, data, "MD5", | |
361 | ) | |
362 | ||
363 | # Create entry for file | |
364 | return self._get_file(""" | |
365 | INSERT INTO | |
366 | wiki_files | |
367 | ( | |
368 | path, | |
369 | filename, | |
370 | author_uid, | |
371 | address, | |
372 | mimetype, | |
373 | blob_id, | |
374 | size | |
375 | ) VALUES ( | |
376 | %s, %s, %s, %s, %s, %s, %s | |
377 | ) | |
378 | RETURNING * | |
379 | """, path, filename, author.uid, address, mimetype, blob.id, len(data), | |
380 | ) | |
381 | ||
382 | def render(self, path, text, **kwargs): | |
383 | return WikiRenderer(self.backend, path, text, **kwargs) | |
384 | ||
385 | ||
386 | class Page(misc.Object): | |
387 | def init(self, id, data=None): | |
388 | self.id = id | |
389 | self.data = data | |
390 | ||
391 | def __repr__(self): | |
392 | return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp) | |
393 | ||
394 | def __eq__(self, other): | |
395 | if isinstance(other, self.__class__): | |
396 | return self.id == other.id | |
397 | ||
398 | return NotImplemented | |
399 | ||
400 | def __lt__(self, other): | |
401 | if isinstance(other, self.__class__): | |
402 | if self.page == other.page: | |
403 | return self.timestamp < other.timestamp | |
404 | ||
405 | return self.page < other.page | |
406 | ||
407 | return NotImplemented | |
408 | ||
409 | def __hash__(self): | |
410 | return hash(self.page) | |
411 | ||
412 | @staticmethod | |
413 | def sanitise_page_name(page): | |
414 | if not page: | |
415 | return "/" | |
416 | ||
417 | # Make sure that the page name does NOT end with a / | |
418 | if page.endswith("/"): | |
419 | page = page[:-1] | |
420 | ||
421 | # Make sure the page name starts with a / | |
422 | if not page.startswith("/"): | |
423 | page = "/%s" % page | |
424 | ||
425 | # Remove any double slashes | |
426 | page = page.replace("//", "/") | |
427 | ||
428 | return page | |
429 | ||
430 | @property | |
431 | def url(self): | |
432 | return self.backend.wiki._make_url(self.page) | |
433 | ||
434 | @property | |
435 | def full_url(self): | |
436 | return "https://www.ipfire.org%s" % self.url | |
437 | ||
438 | @property | |
439 | def page(self): | |
440 | return self.data.page | |
441 | ||
442 | @property | |
443 | def title(self): | |
444 | return self._title or os.path.basename(self.page[1:]) | |
445 | ||
446 | @property | |
447 | def _title(self): | |
448 | if not self.markdown: | |
449 | return | |
450 | ||
451 | # Find first H1 headline in markdown | |
452 | markdown = self.markdown.splitlines() | |
453 | ||
454 | m = re.match(r"^#\s*(.*)( #)?$", markdown[0]) | |
455 | if m: | |
456 | return m.group(1) | |
457 | ||
458 | @lazy_property | |
459 | def author(self): | |
460 | if self.data.author_uid: | |
461 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
462 | ||
463 | @property | |
464 | def markdown(self): | |
465 | return self.data.markdown or "" | |
466 | ||
467 | @property | |
468 | def html(self): | |
469 | lines = [] | |
470 | ||
471 | # Strip off the first line if it contains a heading (as it will be shown separately) | |
472 | for i, line in enumerate(self.markdown.splitlines()): | |
473 | if i == 0 and line.startswith("#"): | |
474 | continue | |
475 | ||
476 | lines.append(line) | |
477 | ||
478 | renderer = self.backend.wiki.render(self.page, "\n".join(lines), revision=self.timestamp) | |
479 | ||
480 | return renderer.html | |
481 | ||
482 | # Linked Files | |
483 | ||
484 | @property | |
485 | def files(self): | |
486 | renderer = self.backend.wiki.render(self.page, self.markdown, revision=self.timestamp) | |
487 | ||
488 | return renderer.files | |
489 | ||
490 | def _store_linked_files(self): | |
491 | self.db.executemany("INSERT INTO wiki_linked_files(page_id, path) \ | |
492 | VALUES(%s, %s)", ((self.id, file) for file in self.files)) | |
493 | ||
494 | @property | |
495 | def timestamp(self): | |
496 | return self.data.timestamp | |
497 | ||
498 | def was_deleted(self): | |
499 | return not self.markdown | |
500 | ||
501 | @lazy_property | |
502 | def breadcrumbs(self): | |
503 | return self.backend.wiki.make_breadcrumbs(self.page) | |
504 | ||
505 | def is_latest_revision(self): | |
506 | return self.get_latest_revision() == self | |
507 | ||
508 | def get_latest_revision(self): | |
509 | revisions = self.get_revisions() | |
510 | ||
511 | # Return first object | |
512 | for rev in revisions: | |
513 | return rev | |
514 | ||
515 | def get_revisions(self): | |
516 | return self.backend.wiki._get_pages("SELECT * FROM wiki \ | |
517 | WHERE page = %s ORDER BY timestamp DESC", self.page) | |
518 | ||
519 | @lazy_property | |
520 | def previous_revision(self): | |
521 | return self.backend.wiki._get_page("SELECT * FROM wiki \ | |
522 | WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \ | |
523 | LIMIT 1", self.page, self.timestamp) | |
524 | ||
525 | @property | |
526 | def changes(self): | |
527 | return self.data.changes | |
528 | ||
529 | # ACL | |
530 | ||
531 | def check_acl(self, account): | |
532 | return self.backend.wiki.check_acl(self.page, account) | |
533 | ||
534 | # Watchers | |
535 | ||
536 | @lazy_property | |
537 | def diff(self): | |
538 | if self.previous_revision: | |
539 | diff = difflib.unified_diff( | |
540 | self.previous_revision.markdown.splitlines(), | |
541 | self.markdown.splitlines(), | |
542 | ) | |
543 | ||
544 | return "\n".join(diff) | |
545 | ||
546 | @property | |
547 | def watchers(self): | |
548 | res = self.db.query("SELECT uid FROM wiki_watchlist \ | |
549 | WHERE page = %s", self.page) | |
550 | ||
551 | for row in res: | |
552 | # Search for account by UID and skip if none was found | |
553 | account = self.backend.accounts.get_by_uid(row.uid) | |
554 | if not account: | |
555 | continue | |
556 | ||
557 | # Return the account | |
558 | yield account | |
559 | ||
560 | def is_watched_by(self, account): | |
561 | res = self.db.get("SELECT 1 FROM wiki_watchlist \ | |
562 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
563 | ||
564 | if res: | |
565 | return True | |
566 | ||
567 | return False | |
568 | ||
569 | def add_watcher(self, account): | |
570 | if self.is_watched_by(account): | |
571 | return | |
572 | ||
573 | self.db.execute("INSERT INTO wiki_watchlist(page, uid) \ | |
574 | VALUES(%s, %s)", self.page, account.uid) | |
575 | ||
576 | def remove_watcher(self, account): | |
577 | self.db.execute("DELETE FROM wiki_watchlist \ | |
578 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
579 | ||
580 | def _send_watcher_emails(self, excludes=[]): | |
581 | # Nothing to do if there was no previous revision | |
582 | if not self.previous_revision: | |
583 | return | |
584 | ||
585 | for watcher in self.watchers: | |
586 | # Skip everyone who is excluded | |
587 | if watcher in excludes: | |
588 | logging.debug("Excluding %s" % watcher) | |
589 | continue | |
590 | ||
591 | # Check permissions | |
592 | if not self.backend.wiki.check_acl(self.page, watcher): | |
593 | logging.debug("Watcher %s does not have permissions" % watcher) | |
594 | continue | |
595 | ||
596 | logging.debug("Sending watcher email to %s" % watcher) | |
597 | ||
598 | # Compose message | |
599 | watcher.send_message("wiki/messages/page-changed", page=self, priority=-10) | |
600 | ||
601 | def restore(self, author, address, comment=None): | |
602 | changes = "Restore to revision from %s" % self.timestamp.isoformat() | |
603 | ||
604 | # Append comment | |
605 | if comment: | |
606 | changes = "%s: %s" % (changes, comment) | |
607 | ||
608 | return self.backend.wiki.create_page(self.page, | |
609 | author, self.markdown, changes=changes, address=address) | |
610 | ||
611 | ||
612 | class File(misc.Object): | |
613 | def init(self, id, data): | |
614 | self.id = id | |
615 | self.data = data | |
616 | ||
617 | def __eq__(self, other): | |
618 | if isinstance(other, self.__class__): | |
619 | return self.id == other.id | |
620 | ||
621 | return NotImplemented | |
622 | ||
623 | @property | |
624 | def url(self): | |
625 | return "/docs%s" % os.path.join(self.path, self.filename) | |
626 | ||
627 | @property | |
628 | def path(self): | |
629 | return self.data.path | |
630 | ||
631 | @property | |
632 | def filename(self): | |
633 | return self.data.filename | |
634 | ||
635 | @property | |
636 | def mimetype(self): | |
637 | return self.data.mimetype | |
638 | ||
639 | @property | |
640 | def size(self): | |
641 | return self.data.size | |
642 | ||
643 | @lazy_property | |
644 | def author(self): | |
645 | if self.data.author_uid: | |
646 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
647 | ||
648 | @property | |
649 | def created_at(self): | |
650 | return self.data.created_at | |
651 | ||
652 | timestamp = created_at | |
653 | ||
654 | def delete(self, author=None): | |
655 | if not self.can_be_deleted(): | |
656 | raise RuntimeError("Cannot delete %s" % self) | |
657 | ||
658 | self.db.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \ | |
659 | WHERE id = %s", author.uid if author else None, self.id) | |
660 | ||
661 | def can_be_deleted(self): | |
662 | # Cannot be deleted if still in use | |
663 | if self.pages: | |
664 | return False | |
665 | ||
666 | # Can be deleted | |
667 | return True | |
668 | ||
669 | @property | |
670 | def deleted_at(self): | |
671 | return self.data.deleted_at | |
672 | ||
673 | def get_latest_revision(self): | |
674 | revisions = self.get_revisions() | |
675 | ||
676 | # Return first object | |
677 | for rev in revisions: | |
678 | return rev | |
679 | ||
680 | def get_revisions(self): | |
681 | revisions = self.backend.wiki._get_files("SELECT * FROM wiki_files \ | |
682 | WHERE path = %s AND filename = %s ORDER BY created_at DESC", self.path, self.filename) | |
683 | ||
684 | return list(revisions) | |
685 | ||
686 | def is_pdf(self): | |
687 | return self.mimetype in ("application/pdf", "application/x-pdf") | |
688 | ||
689 | def is_image(self): | |
690 | return self.mimetype.startswith("image/") | |
691 | ||
692 | def is_vector_image(self): | |
693 | return self.mimetype in ("image/svg+xml",) | |
694 | ||
695 | def is_bitmap_image(self): | |
696 | return self.is_image() and not self.is_vector_image() | |
697 | ||
698 | @lazy_property | |
699 | def blob(self): | |
700 | res = self.db.get("SELECT data FROM wiki_blobs \ | |
701 | WHERE id = %s", self.data.blob_id) | |
702 | ||
703 | if res: | |
704 | return bytes(res.data) | |
705 | ||
706 | async def get_thumbnail(self, size, format=None): | |
707 | assert self.is_bitmap_image() | |
708 | ||
709 | # Let thumbnails live in the cache for up to 24h | |
710 | ttl = 24 * 3600 | |
711 | ||
712 | cache_key = ":".join(( | |
713 | "wiki", | |
714 | "thumbnail", | |
715 | self.path, | |
716 | util.normalize(self.filename), | |
717 | self.created_at.isoformat(), | |
718 | format or "N/A", | |
719 | "%spx" % size, | |
720 | )) | |
721 | ||
722 | # Try to fetch the data from the cache | |
723 | async with await self.backend.cache.pipeline() as p: | |
724 | # Fetch the key | |
725 | await p.get(cache_key) | |
726 | ||
727 | # Reset the TTL | |
728 | await p.expire(cache_key, ttl) | |
729 | ||
730 | # Execute the pipeline | |
731 | thumbnail, _ = await p.execute() | |
732 | ||
733 | # Return the cached value | |
734 | if thumbnail: | |
735 | return thumbnail | |
736 | ||
737 | # Generate the thumbnail | |
738 | thumbnail = util.generate_thumbnail(self.blob, size, format=format, quality=95) | |
739 | ||
740 | # Put it into the cache for 24h | |
741 | await self.backend.cache.set(cache_key, thumbnail, ttl) | |
742 | ||
743 | return thumbnail | |
744 | ||
745 | @property | |
746 | def pages(self): | |
747 | """ | |
748 | Returns a list of all pages this file is linked by | |
749 | """ | |
750 | pages = self.backend.wiki._get_pages(""" | |
751 | SELECT | |
752 | wiki.* | |
753 | FROM | |
754 | wiki_linked_files | |
755 | JOIN | |
756 | wiki_current ON wiki_linked_files.page_id = wiki_current.id | |
757 | LEFT JOIN | |
758 | wiki ON wiki_linked_files.page_id = wiki.id | |
759 | WHERE | |
760 | wiki_linked_files.path = %s | |
761 | ORDER BY | |
762 | wiki.page | |
763 | """, os.path.join(self.path, self.filename), | |
764 | ) | |
765 | ||
766 | return list(pages) | |
767 | ||
768 | ||
769 | class WikiRenderer(misc.Object): | |
770 | schemas = ( | |
771 | "ftp://", | |
772 | "git://", | |
773 | "http://", | |
774 | "https://", | |
775 | "rsync://", | |
776 | "sftp://", | |
777 | "ssh://", | |
778 | "webcal://", | |
779 | ) | |
780 | ||
781 | # Links | |
782 | _links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>") | |
783 | ||
784 | # Images | |
785 | _images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>") | |
786 | ||
787 | def init(self, path, text, revision=None): | |
788 | self.path = path | |
789 | self.text = text | |
790 | ||
791 | # Optionally, the revision of the rendered page | |
792 | self.revision = revision | |
793 | ||
794 | # Markdown Renderer | |
795 | self.renderer = Markdown( | |
796 | self.backend, | |
797 | extensions=[ | |
798 | LinkedFilesExtractorExtension(), | |
799 | PrettyLinksExtension(), | |
800 | "codehilite", | |
801 | "fenced_code", | |
802 | "footnotes", | |
803 | "nl2br", | |
804 | "sane_lists", | |
805 | "tables", | |
806 | "toc", | |
807 | ], | |
808 | ) | |
809 | ||
810 | # Render! | |
811 | self.html = self._render() | |
812 | ||
813 | def _render_link(self, m): | |
814 | url, text = m.groups() | |
815 | ||
816 | # Treat linkes starting with a double slash as absolute | |
817 | if url.startswith("//"): | |
818 | # Remove the double-lash | |
819 | url = url.removeprefix("/") | |
820 | ||
821 | # Return a link | |
822 | return """<a href="%s">%s</a>""" % (url, text or url) | |
823 | ||
824 | # External Links | |
825 | for schema in self.schemas: | |
826 | if url.startswith(schema): | |
827 | return """<a class="link-external" href="%s">%s</a>""" % \ | |
828 | (url, text or url) | |
829 | ||
830 | # Emails | |
831 | if "@" in url: | |
832 | # Strip mailto: | |
833 | if url.startswith("mailto:"): | |
834 | url = url[7:] | |
835 | ||
836 | return """<a class="link-external" href="mailto:%s">%s</a>""" % \ | |
837 | (url, text or url) | |
838 | ||
839 | # Everything else must be an internal link | |
840 | path = self.backend.wiki.make_path(self.path, url) | |
841 | ||
842 | return """<a href="/docs%s">%s</a>""" % \ | |
843 | (path, text or self.backend.wiki.get_page_title(path)) | |
844 | ||
845 | def _render_image(self, m): | |
846 | alt_text, url, caption = m.groups() | |
847 | ||
848 | # Compute a hash over the URL | |
849 | h = hashlib.new("md5") | |
850 | h.update(url.encode()) | |
851 | id = h.hexdigest() | |
852 | ||
853 | html = """ | |
854 | <div class="columns is-centered"> | |
855 | <div class="column is-8"> | |
856 | <figure class="image modal-trigger" data-target="%(id)s"> | |
857 | <img src="/docs%(url)s?s=960&%(args)s" alt="%(caption)s"> | |
858 | ||
859 | <figcaption class="figure-caption">%(caption)s</figcaption> | |
860 | </figure> | |
861 | ||
862 | <div class="modal is-large" id="%(id)s"> | |
863 | <div class="modal-background"></div> | |
864 | ||
865 | <div class="modal-content"> | |
866 | <p class="image"> | |
867 | <img src="/docs%(url)s?s=2048&%(args)s" alt="%(caption)s" | |
868 | loading="lazy"> | |
869 | </p> | |
870 | ||
871 | <a class="button is-small" href="/docs%(url)s?action=detail"> | |
872 | <span class="icon"> | |
873 | <i class="fa-solid fa-circle-info"></i> | |
874 | </span> | |
875 | </a> | |
876 | </div> | |
877 | ||
878 | <button class="modal-close is-large" aria-label="close"></button> | |
879 | </div> | |
880 | </div> | |
881 | </div> | |
882 | """ | |
883 | ||
884 | # Try to split query string | |
885 | url, delimiter, qs = url.partition("?") | |
886 | ||
887 | # Parse query arguments | |
888 | args = urllib.parse.parse_qs(qs) | |
889 | ||
890 | # Skip any absolute and external URLs | |
891 | if url.startswith("https://") or url.startswith("http://"): | |
892 | return html % { | |
893 | "caption" : caption or "", | |
894 | "id" : id, | |
895 | "url" : url, | |
896 | "args" : args, | |
897 | } | |
898 | ||
899 | # Build absolute path | |
900 | url = self.backend.wiki.make_path(self.path, url) | |
901 | ||
902 | # Find image | |
903 | file = self.backend.wiki.get_file_by_path(url, revision=self.revision) | |
904 | if not file or not file.is_image(): | |
905 | return "<!-- Could not find image %s in %s -->" % (url, self.path) | |
906 | ||
907 | # Remove any requested size | |
908 | if "s" in args: | |
909 | del args["s"] | |
910 | ||
911 | # Link the image that has been the current version at the time of the page edit | |
912 | if file: | |
913 | args["revision"] = file.timestamp | |
914 | ||
915 | return html % { | |
916 | "caption" : caption or "", | |
917 | "id" : id, | |
918 | "url" : url, | |
919 | "args" : urllib.parse.urlencode(args), | |
920 | } | |
921 | ||
922 | def _render(self): | |
923 | logging.debug("Rendering %s" % self.path) | |
924 | ||
925 | # Render... | |
926 | text = self.renderer.convert(self.text) | |
927 | ||
928 | # Postprocess links | |
929 | text = self._links.sub(self._render_link, text) | |
930 | ||
931 | # Postprocess images to <figure> | |
932 | text = self._images.sub(self._render_image, text) | |
933 | ||
934 | return text | |
935 | ||
936 | @lazy_property | |
937 | def files(self): | |
938 | """ | |
939 | A list of all linked files that have been part of the rendered markup | |
940 | """ | |
941 | files = [] | |
942 | ||
943 | for url in self.renderer.files: | |
944 | # Skip external images | |
945 | if url.startswith("https://") or url.startswith("http://"): | |
946 | continue | |
947 | ||
948 | # Make the URL absolute | |
949 | url = self.backend.wiki.make_path(self.path, url) | |
950 | ||
951 | # Check if this is a file (it could also just be a page) | |
952 | file = self.backend.wiki.get_file_by_path(url) | |
953 | if file: | |
954 | files.append(url) | |
955 | ||
956 | return files | |
957 | ||
958 | ||
959 | class Markdown(markdown.Markdown): | |
960 | def __init__(self, backend, *args, **kwargs): | |
961 | # Store the backend | |
962 | self.backend = backend | |
963 | ||
964 | # Call inherited setup routine | |
965 | super().__init__(*args, **kwargs) | |
966 | ||
967 | ||
968 | class PrettyLinksExtension(markdown.extensions.Extension): | |
969 | def extendMarkdown(self, md): | |
970 | # Create links to Bugzilla | |
971 | md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10) | |
972 | ||
973 | # Create links to CVE | |
974 | md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10) | |
975 | ||
976 | # Link mentioned users | |
977 | md.preprocessors.register(UserMentionPreprocessor(md), "user-mention", 10) | |
978 | ||
979 | ||
980 | class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor): | |
981 | regex = re.compile(r"(?:#(\d{5,}))", re.I) | |
982 | ||
983 | def run(self, lines): | |
984 | for line in lines: | |
985 | yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line) | |
986 | ||
987 | ||
988 | class CVELinksPreprocessor(markdown.preprocessors.Preprocessor): | |
989 | regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)") | |
990 | ||
991 | def run(self, lines): | |
992 | for line in lines: | |
993 | yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line) | |
994 | ||
995 | ||
996 | class UserMentionPreprocessor(markdown.preprocessors.Preprocessor): | |
997 | regex = re.compile(r"\B@(\w+)") | |
998 | ||
999 | def run(self, lines): | |
1000 | for line in lines: | |
1001 | yield self.regex.sub(self._replace, line) | |
1002 | ||
1003 | def _replace(self, m): | |
1004 | # Fetch the user's handle | |
1005 | uid, = m.groups() | |
1006 | ||
1007 | # Fetch the user | |
1008 | user = self.md.backend.accounts.get_by_uid(uid) | |
1009 | ||
1010 | # If the user was not found, we put back the matched text | |
1011 | if not user: | |
1012 | return m.group(0) | |
1013 | ||
1014 | # Link the user | |
1015 | return "[%s](/users/%s)" % (user, user.uid) | |
1016 | ||
1017 | ||
1018 | class LinkedFilesExtractor(markdown.treeprocessors.Treeprocessor): | |
1019 | """ | |
1020 | Finds all Linked Files | |
1021 | """ | |
1022 | def __init__(self, *args, **kwargs): | |
1023 | super().__init__(*args, **kwargs) | |
1024 | ||
1025 | self.md.files = [] | |
1026 | ||
1027 | def run(self, root): | |
1028 | # Find all images and store the URLs | |
1029 | for image in root.findall(".//img"): | |
1030 | src = image.get("src") | |
1031 | ||
1032 | self.md.files.append(src) | |
1033 | ||
1034 | # Find all links | |
1035 | for link in root.findall(".//a"): | |
1036 | href = link.get("href") | |
1037 | ||
1038 | self.md.files.append(href) | |
1039 | ||
1040 | ||
1041 | class LinkedFilesExtractorExtension(markdown.extensions.Extension): | |
1042 | def extendMarkdown(self, md): | |
1043 | md.treeprocessors.register(LinkedFilesExtractor(md), "linked-files-extractor", 10) |