]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/wiki.py
location: Create a page that explains how to report problems
[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 self.backend.messages.send_template("wiki/messages/page-changed",
600 account=watcher, page=self, priority=-10)
601
602 def restore(self, author, address, comment=None):
603 changes = "Restore to revision from %s" % self.timestamp.isoformat()
604
605 # Append comment
606 if comment:
607 changes = "%s: %s" % (changes, comment)
608
609 return self.backend.wiki.create_page(self.page,
610 author, self.markdown, changes=changes, address=address)
611
612
613 class File(misc.Object):
614 def init(self, id, data):
615 self.id = id
616 self.data = data
617
618 def __eq__(self, other):
619 if isinstance(other, self.__class__):
620 return self.id == other.id
621
622 return NotImplemented
623
624 @property
625 def url(self):
626 return "/docs%s" % os.path.join(self.path, self.filename)
627
628 @property
629 def path(self):
630 return self.data.path
631
632 @property
633 def filename(self):
634 return self.data.filename
635
636 @property
637 def mimetype(self):
638 return self.data.mimetype
639
640 @property
641 def size(self):
642 return self.data.size
643
644 @lazy_property
645 def author(self):
646 if self.data.author_uid:
647 return self.backend.accounts.get_by_uid(self.data.author_uid)
648
649 @property
650 def created_at(self):
651 return self.data.created_at
652
653 timestamp = created_at
654
655 def delete(self, author=None):
656 if not self.can_be_deleted():
657 raise RuntimeError("Cannot delete %s" % self)
658
659 self.db.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \
660 WHERE id = %s", author.uid if author else None, self.id)
661
662 def can_be_deleted(self):
663 # Cannot be deleted if still in use
664 if self.pages:
665 return False
666
667 # Can be deleted
668 return True
669
670 @property
671 def deleted_at(self):
672 return self.data.deleted_at
673
674 def get_latest_revision(self):
675 revisions = self.get_revisions()
676
677 # Return first object
678 for rev in revisions:
679 return rev
680
681 def get_revisions(self):
682 revisions = self.backend.wiki._get_files("SELECT * FROM wiki_files \
683 WHERE path = %s AND filename = %s ORDER BY created_at DESC", self.path, self.filename)
684
685 return list(revisions)
686
687 def is_pdf(self):
688 return self.mimetype in ("application/pdf", "application/x-pdf")
689
690 def is_image(self):
691 return self.mimetype.startswith("image/")
692
693 def is_vector_image(self):
694 return self.mimetype in ("image/svg+xml",)
695
696 def is_bitmap_image(self):
697 return self.is_image() and not self.is_vector_image()
698
699 @lazy_property
700 def blob(self):
701 res = self.db.get("SELECT data FROM wiki_blobs \
702 WHERE id = %s", self.data.blob_id)
703
704 if res:
705 return bytes(res.data)
706
707 async def get_thumbnail(self, size, format=None):
708 assert self.is_bitmap_image()
709
710 # Let thumbnails live in the cache for up to 24h
711 ttl = 24 * 3600
712
713 cache_key = ":".join((
714 "wiki",
715 "thumbnail",
716 self.path,
717 util.normalize(self.filename),
718 self.created_at.isoformat(),
719 format or "N/A",
720 "%spx" % size,
721 ))
722
723 # Try to fetch the data from the cache
724 async with await self.backend.cache.pipeline() as p:
725 # Fetch the key
726 await p.get(cache_key)
727
728 # Reset the TTL
729 await p.expire(cache_key, ttl)
730
731 # Execute the pipeline
732 thumbnail, _ = await p.execute()
733
734 # Return the cached value
735 if thumbnail:
736 return thumbnail
737
738 # Generate the thumbnail
739 thumbnail = util.generate_thumbnail(self.blob, size, format=format, quality=95)
740
741 # Put it into the cache for 24h
742 await self.backend.cache.set(cache_key, thumbnail, ttl)
743
744 return thumbnail
745
746 @property
747 def pages(self):
748 """
749 Returns a list of all pages this file is linked by
750 """
751 pages = self.backend.wiki._get_pages("""
752 SELECT
753 wiki.*
754 FROM
755 wiki_linked_files
756 JOIN
757 wiki_current ON wiki_linked_files.page_id = wiki_current.id
758 LEFT JOIN
759 wiki ON wiki_linked_files.page_id = wiki.id
760 WHERE
761 wiki_linked_files.path = %s
762 ORDER BY
763 wiki.page
764 """, os.path.join(self.path, self.filename),
765 )
766
767 return list(pages)
768
769
770 class WikiRenderer(misc.Object):
771 schemas = (
772 "ftp://",
773 "git://",
774 "http://",
775 "https://",
776 "rsync://",
777 "sftp://",
778 "ssh://",
779 "webcal://",
780 )
781
782 # Links
783 _links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
784
785 # Images
786 _images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
787
788 def init(self, path, text, revision=None):
789 self.path = path
790 self.text = text
791
792 # Optionally, the revision of the rendered page
793 self.revision = revision
794
795 # Markdown Renderer
796 self.renderer = Markdown(
797 self.backend,
798 extensions=[
799 LinkedFilesExtractorExtension(),
800 PrettyLinksExtension(),
801 "codehilite",
802 "fenced_code",
803 "footnotes",
804 "nl2br",
805 "sane_lists",
806 "tables",
807 "toc",
808 ],
809 )
810
811 # Render!
812 self.html = self._render()
813
814 def _render_link(self, m):
815 url, text = m.groups()
816
817 # Treat linkes starting with a double slash as absolute
818 if url.startswith("//"):
819 # Remove the double-lash
820 url = url.removeprefix("/")
821
822 # Return a link
823 return """<a href="%s">%s</a>""" % (url, text or url)
824
825 # External Links
826 for schema in self.schemas:
827 if url.startswith(schema):
828 return """<a class="link-external" href="%s">%s</a>""" % \
829 (url, text or url)
830
831 # Emails
832 if "@" in url:
833 # Strip mailto:
834 if url.startswith("mailto:"):
835 url = url[7:]
836
837 return """<a class="link-external" href="mailto:%s">%s</a>""" % \
838 (url, text or url)
839
840 # Everything else must be an internal link
841 path = self.backend.wiki.make_path(self.path, url)
842
843 return """<a href="/docs%s">%s</a>""" % \
844 (path, text or self.backend.wiki.get_page_title(path))
845
846 def _render_image(self, m):
847 alt_text, url, caption = m.groups()
848
849 # Compute a hash over the URL
850 h = hashlib.new("md5")
851 h.update(url.encode())
852 id = h.hexdigest()
853
854 html = """
855 <div class="columns is-centered">
856 <div class="column is-8">
857 <figure class="image modal-trigger" data-target="%(id)s">
858 <img src="/docs%(url)s?s=960&amp;%(args)s" alt="%(caption)s">
859
860 <figcaption class="figure-caption">%(caption)s</figcaption>
861 </figure>
862
863 <div class="modal is-large" id="%(id)s">
864 <div class="modal-background"></div>
865
866 <div class="modal-content">
867 <p class="image">
868 <img src="/docs%(url)s?s=2048&amp;%(args)s" alt="%(caption)s"
869 loading="lazy">
870 </p>
871
872 <a class="button is-small" href="/docs%(url)s?action=detail">
873 <span class="icon">
874 <i class="fa-solid fa-circle-info"></i>
875 </span>
876 </a>
877 </div>
878
879 <button class="modal-close is-large" aria-label="close"></button>
880 </div>
881 </div>
882 </div>
883 """
884
885 # Try to split query string
886 url, delimiter, qs = url.partition("?")
887
888 # Parse query arguments
889 args = urllib.parse.parse_qs(qs)
890
891 # Skip any absolute and external URLs
892 if url.startswith("https://") or url.startswith("http://"):
893 return html % {
894 "caption" : caption or "",
895 "id" : id,
896 "url" : url,
897 "args" : args,
898 }
899
900 # Build absolute path
901 url = self.backend.wiki.make_path(self.path, url)
902
903 # Find image
904 file = self.backend.wiki.get_file_by_path(url, revision=self.revision)
905 if not file or not file.is_image():
906 return "<!-- Could not find image %s in %s -->" % (url, self.path)
907
908 # Remove any requested size
909 if "s" in args:
910 del args["s"]
911
912 # Link the image that has been the current version at the time of the page edit
913 if file:
914 args["revision"] = file.timestamp
915
916 return html % {
917 "caption" : caption or "",
918 "id" : id,
919 "url" : url,
920 "args" : urllib.parse.urlencode(args),
921 }
922
923 def _render(self):
924 logging.debug("Rendering %s" % self.path)
925
926 # Render...
927 text = self.renderer.convert(self.text)
928
929 # Postprocess links
930 text = self._links.sub(self._render_link, text)
931
932 # Postprocess images to <figure>
933 text = self._images.sub(self._render_image, text)
934
935 return text
936
937 @lazy_property
938 def files(self):
939 """
940 A list of all linked files that have been part of the rendered markup
941 """
942 files = []
943
944 for url in self.renderer.files:
945 # Skip external images
946 if url.startswith("https://") or url.startswith("http://"):
947 continue
948
949 # Make the URL absolute
950 url = self.backend.wiki.make_path(self.path, url)
951
952 # Check if this is a file (it could also just be a page)
953 file = self.backend.wiki.get_file_by_path(url)
954 if file:
955 files.append(url)
956
957 return files
958
959
960 class Markdown(markdown.Markdown):
961 def __init__(self, backend, *args, **kwargs):
962 # Store the backend
963 self.backend = backend
964
965 # Call inherited setup routine
966 super().__init__(*args, **kwargs)
967
968
969 class PrettyLinksExtension(markdown.extensions.Extension):
970 def extendMarkdown(self, md):
971 # Create links to Bugzilla
972 md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10)
973
974 # Create links to CVE
975 md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10)
976
977 # Link mentioned users
978 md.preprocessors.register(UserMentionPreprocessor(md), "user-mention", 10)
979
980
981 class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor):
982 regex = re.compile(r"(?:#(\d{5,}))", re.I)
983
984 def run(self, lines):
985 for line in lines:
986 yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line)
987
988
989 class CVELinksPreprocessor(markdown.preprocessors.Preprocessor):
990 regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)")
991
992 def run(self, lines):
993 for line in lines:
994 yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line)
995
996
997 class UserMentionPreprocessor(markdown.preprocessors.Preprocessor):
998 regex = re.compile(r"@(\w+)")
999
1000 def run(self, lines):
1001 for line in lines:
1002 yield self.regex.sub(self._replace, line)
1003
1004 def _replace(self, m):
1005 # Fetch the user's handle
1006 uid, = m.groups()
1007
1008 # Fetch the user
1009 user = self.md.backend.accounts.get_by_uid(uid)
1010
1011 # If the user was not found, we put back the matched text
1012 if not user:
1013 return m.group(0)
1014
1015 # Link the user
1016 return "[%s](//users/%s)" % (user, user.uid)
1017
1018
1019 class LinkedFilesExtractor(markdown.treeprocessors.Treeprocessor):
1020 """
1021 Finds all Linked Files
1022 """
1023 def __init__(self, *args, **kwargs):
1024 super().__init__(*args, **kwargs)
1025
1026 self.md.files = []
1027
1028 def run(self, root):
1029 # Find all images and store the URLs
1030 for image in root.findall(".//img"):
1031 src = image.get("src")
1032
1033 self.md.files.append(src)
1034
1035 # Find all links
1036 for link in root.findall(".//a"):
1037 href = link.get("href")
1038
1039 self.md.files.append(href)
1040
1041
1042 class LinkedFilesExtractorExtension(markdown.extensions.Extension):
1043 def extendMarkdown(self, md):
1044 md.treeprocessors.register(LinkedFilesExtractor(md), "linked-files-extractor", 10)