]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/wiki.py
c3ddfab02aacbcf20c68d8de9defb5500689a081
[ipfire.org.git] / src / backend / wiki.py
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&amp;%(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&amp;%(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)