]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/wiki.py
location: Create a page that explains how to report problems
[ipfire.org.git] / src / backend / wiki.py
CommitLineData
181d08f3
MT
1#!/usr/bin/python3
2
4ed1dadb 3import difflib
ef963ecb 4import hashlib
181d08f3 5import logging
245a2e36
MT
6import markdown
7import markdown.extensions
8import markdown.preprocessors
6ac7e934 9import os.path
181d08f3 10import re
9e90e800 11import urllib.parse
181d08f3
MT
12
13from . import misc
9523790a 14from . import util
181d08f3
MT
15from .decorators import *
16
181d08f3
MT
17class 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
d398ca08
MT
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
86368c12 30 def __iter__(self):
53333caa
MT
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 """,
86368c12
MT
42 )
43
c78ad26e
MT
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
0c1186ef
MT
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
6ac7e934
MT
70 def get_page_title(self, page, default=None):
71 doc = self.get_page(page)
72 if doc:
50c8dc11
MT
73 title = doc.title
74 else:
75 title = os.path.basename(page)
6ac7e934 76
50c8dc11 77 return title
6ac7e934 78
181d08f3
MT
79 def get_page(self, page, revision=None):
80 page = Page.sanitise_page_name(page)
947224b4
MT
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
181d08f3
MT
88
89 if revision:
d398ca08 90 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
91 AND timestamp = %s", page, revision)
92 else:
d398ca08 93 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
94 ORDER BY timestamp DESC LIMIT 1", page)
95
11afe905
MT
96 def get_recent_changes(self, account, limit=None):
97 pages = self._get_pages("SELECT * FROM wiki \
11afe905
MT
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
181d08f3 109
495e9dc4 110 def create_page(self, page, author, content, changes=None, address=None):
181d08f3
MT
111 page = Page.sanitise_page_name(page)
112
aba5e58a 113 # Write page to the database
53333caa
MT
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 )
181d08f3 129
2ab2c753
MT
130 # Store any linked files
131 page._store_linked_files()
132
aba5e58a
MT
133 # Send email to all watchers
134 page._send_watcher_emails(excludes=[author])
135
136 return page
137
495e9dc4 138 def delete_page(self, page, author, **kwargs):
181d08f3
MT
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
495e9dc4 144 self.create_page(page, author=author, content=None, **kwargs)
181d08f3 145
0c1186ef 146 def make_breadcrumbs(self, path):
3168788e 147 ret = []
181d08f3 148
0c1186ef
MT
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)
181d08f3 168
11afe905 169 def search(self, query, account=None, limit=None):
53333caa
MT
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 )
9523790a 183
df80be2c 184 pages = []
11afe905
MT
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
df80be2c 191 pages.append(page)
11afe905 192
df80be2c
MT
193 # Break when we have found enough pages
194 if limit and len(pages) >= limit:
11afe905 195 break
9523790a 196
df80be2c
MT
197 return pages
198
9523790a
MT
199 def refresh(self):
200 """
201 Needs to be called after a page has been changed
202 """
86e057ac 203 self.db.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY wiki_search_index")
9523790a 204
2f23c558 205 def get_watchlist(self, account):
e1d2efef
MT
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,
2f23c558
MT
225 )
226
227 return sorted(pages)
228
11afe905
MT
229 # ACL
230
231 def check_acl(self, page, account):
53333caa
MT
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 )
11afe905
MT
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:
93402e56 252 if account.is_member_of_group(group):
11afe905
MT
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
f2cfd873
MT
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):
53333caa
MT
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 )
f2cfd873
MT
288
289 return list(files)
290
ff14dea3 291 def get_file_by_path(self, path, revision=None):
f2cfd873
MT
292 path, filename = os.path.dirname(path), os.path.basename(path)
293
ff14dea3
MT
294 if revision:
295 # Fetch a specific revision
53333caa
MT
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 )
ff14dea3
MT
312
313 # Fetch latest version
53333caa
MT
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 )
ff14dea3
MT
327
328 def get_file_by_path_and_filename(self, path, filename):
53333caa
MT
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 )
f2cfd873
MT
342
343 def upload(self, path, filename, data, mimetype, author, address):
ff14dea3
MT
344 # Replace any existing files
345 file = self.get_file_by_path_and_filename(path, filename)
346 if file:
347 file.delete(author)
348
f2cfd873 349 # Upload the blob first
53333caa
MT
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 )
f2cfd873
MT
362
363 # Create entry for file
53333caa
MT
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 )
f2cfd873 381
25e2dbe5
MT
382 def render(self, path, text, **kwargs):
383 return WikiRenderer(self.backend, path, text, **kwargs)
e2205cff 384
154f6179 385
2901b734 386class Page(misc.Object):
181d08f3
MT
387 def init(self, id, data=None):
388 self.id = id
389 self.data = data
390
dc847af5
MT
391 def __repr__(self):
392 return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp)
393
c21ffadb
MT
394 def __eq__(self, other):
395 if isinstance(other, self.__class__):
396 return self.id == other.id
397
0713d9ae
MT
398 return NotImplemented
399
181d08f3
MT
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
0713d9ae
MT
407 return NotImplemented
408
55ed268d
MT
409 def __hash__(self):
410 return hash(self.page)
411
181d08f3
MT
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):
0c1186ef 432 return self.backend.wiki._make_url(self.page)
181d08f3 433
4ed1dadb
MT
434 @property
435 def full_url(self):
0805ae90 436 return "https://www.ipfire.org%s" % self.url
4ed1dadb 437
181d08f3
MT
438 @property
439 def page(self):
440 return self.data.page
441
442 @property
443 def title(self):
51e7a876 444 return self._title or os.path.basename(self.page[1:])
181d08f3
MT
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
0074e919 454 m = re.match(r"^#\s*(.*)( #)?$", markdown[0])
181d08f3
MT
455 if m:
456 return m.group(1)
457
3b05ef6e
MT
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
181d08f3
MT
463 @property
464 def markdown(self):
c21ffadb 465 return self.data.markdown or ""
181d08f3
MT
466
467 @property
468 def html(self):
f9e077ed
MT
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
25e2dbe5 478 renderer = self.backend.wiki.render(self.page, "\n".join(lines), revision=self.timestamp)
5ab70651
MT
479
480 return renderer.html
481
2ab2c753
MT
482 # Linked Files
483
5ab70651 484 @property
2ab2c753 485 def files(self):
25e2dbe5 486 renderer = self.backend.wiki.render(self.page, self.markdown, revision=self.timestamp)
5ab70651 487
2ab2c753
MT
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))
addc18d5 493
181d08f3
MT
494 @property
495 def timestamp(self):
496 return self.data.timestamp
497
498 def was_deleted(self):
4c13230c 499 return not self.markdown
181d08f3
MT
500
501 @lazy_property
502 def breadcrumbs(self):
503 return self.backend.wiki.make_breadcrumbs(self.page)
504
d4c68c5c
MT
505 def is_latest_revision(self):
506 return self.get_latest_revision() == self
507
181d08f3 508 def get_latest_revision(self):
7d699684
MT
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)
091ac36b 518
c21ffadb
MT
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
d398ca08
MT
525 @property
526 def changes(self):
527 return self.data.changes
528
11afe905
MT
529 # ACL
530
531 def check_acl(self, account):
532 return self.backend.wiki.check_acl(self.page, account)
533
d64a1e35
MT
534 # Watchers
535
4ed1dadb
MT
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
aba5e58a
MT
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
f2e25ded 560 def is_watched_by(self, account):
d64a1e35
MT
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):
f2e25ded 570 if self.is_watched_by(account):
d64a1e35
MT
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
aba5e58a
MT
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
516da0a9
MT
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
aba5e58a
MT
596 logging.debug("Sending watcher email to %s" % watcher)
597
4ed1dadb
MT
598 # Compose message
599 self.backend.messages.send_template("wiki/messages/page-changed",
ba14044c 600 account=watcher, page=self, priority=-10)
aba5e58a 601
9f1cfab7 602 def restore(self, author, address, comment=None):
d4c68c5c
MT
603 changes = "Restore to revision from %s" % self.timestamp.isoformat()
604
9f1cfab7
MT
605 # Append comment
606 if comment:
607 changes = "%s: %s" % (changes, comment)
608
d4c68c5c
MT
609 return self.backend.wiki.create_page(self.page,
610 author, self.markdown, changes=changes, address=address)
611
f2cfd873
MT
612
613class File(misc.Object):
614 def init(self, id, data):
615 self.id = id
616 self.data = data
617
ff14dea3
MT
618 def __eq__(self, other):
619 if isinstance(other, self.__class__):
620 return self.id == other.id
621
9406e5e2
MT
622 return NotImplemented
623
f2cfd873
MT
624 @property
625 def url(self):
a82de4b1 626 return "/docs%s" % os.path.join(self.path, self.filename)
f2cfd873
MT
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
8cb0bea4
MT
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
25e2dbe5
MT
653 timestamp = created_at
654
b26c705a 655 def delete(self, author=None):
9406e5e2
MT
656 if not self.can_be_deleted():
657 raise RuntimeError("Cannot delete %s" % self)
658
b26c705a
MT
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)
ff14dea3 661
9406e5e2
MT
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
ff14dea3
MT
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 \
2225edd9 683 WHERE path = %s AND filename = %s ORDER BY created_at DESC", self.path, self.filename)
ff14dea3
MT
684
685 return list(revisions)
686
8cb0bea4
MT
687 def is_pdf(self):
688 return self.mimetype in ("application/pdf", "application/x-pdf")
689
f2cfd873
MT
690 def is_image(self):
691 return self.mimetype.startswith("image/")
692
8a62e589
MT
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
f2cfd873
MT
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)
79dd9a0f 706
ccfb1584 707 async def get_thumbnail(self, size, format=None):
8a62e589
MT
708 assert self.is_bitmap_image()
709
531c4989
MT
710 # Let thumbnails live in the cache for up to 24h
711 ttl = 24 * 3600
712
ccfb1584
MT
713 cache_key = ":".join((
714 "wiki",
715 "thumbnail",
df4f5dfb
MT
716 self.path,
717 util.normalize(self.filename),
718 self.created_at.isoformat(),
ccfb1584 719 format or "N/A",
df4f5dfb
MT
720 "%spx" % size,
721 ))
75d9b3da
MT
722
723 # Try to fetch the data from the cache
531c4989
MT
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
75d9b3da
MT
735 if thumbnail:
736 return thumbnail
737
738 # Generate the thumbnail
60b2a858 739 thumbnail = util.generate_thumbnail(self.blob, size, format=format, quality=95)
75d9b3da 740
531c4989
MT
741 # Put it into the cache for 24h
742 await self.backend.cache.set(cache_key, thumbnail, ttl)
75d9b3da
MT
743
744 return thumbnail
2901b734 745
9406e5e2
MT
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
2901b734
MT
769
770class WikiRenderer(misc.Object):
4ddad3e5
MT
771 schemas = (
772 "ftp://",
773 "git://",
774 "http://",
775 "https://",
776 "rsync://",
777 "sftp://",
778 "ssh://",
779 "webcal://",
780 )
781
782 # Links
5ab70651 783 _links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
2901b734 784
c78ad26e 785 # Images
5ab70651 786 _images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
245a2e36 787
25e2dbe5 788 def init(self, path, text, revision=None):
2901b734 789 self.path = path
5ab70651
MT
790 self.text = text
791
25e2dbe5
MT
792 # Optionally, the revision of the rendered page
793 self.revision = revision
794
5ab70651 795 # Markdown Renderer
886d5f7b
MT
796 self.renderer = Markdown(
797 self.backend,
5ab70651 798 extensions=[
2ab2c753 799 LinkedFilesExtractorExtension(),
5ab70651
MT
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()
2901b734 813
4ddad3e5
MT
814 def _render_link(self, m):
815 url, text = m.groups()
2901b734 816
0a17d51e
MT
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
e50a437a
MT
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
4ddad3e5
MT
831 # Emails
832 if "@" in url:
833 # Strip mailto:
834 if url.startswith("mailto:"):
835 url = url[7:]
2901b734 836
4ddad3e5
MT
837 return """<a class="link-external" href="mailto:%s">%s</a>""" % \
838 (url, text or url)
2901b734 839
4ddad3e5
MT
840 # Everything else must be an internal link
841 path = self.backend.wiki.make_path(self.path, url)
2901b734 842
46b77977 843 return """<a href="/docs%s">%s</a>""" % \
4ddad3e5 844 (path, text or self.backend.wiki.get_page_title(path))
2901b734 845
c78ad26e 846 def _render_image(self, m):
e9c6d581 847 alt_text, url, caption = m.groups()
2901b734 848
ef963ecb
MT
849 # Compute a hash over the URL
850 h = hashlib.new("md5")
851 h.update(url.encode())
852 id = h.hexdigest()
853
4a1bfdd5 854 html = """
3ae53eac
MT
855 <div class="columns is-centered">
856 <div class="column is-8">
ef963ecb 857 <figure class="image modal-trigger" data-target="%(id)s">
9e1bc5f1 858 <img src="/docs%(url)s?s=960&amp;%(args)s" alt="%(caption)s">
ef963ecb
MT
859
860 <figcaption class="figure-caption">%(caption)s</figcaption>
3ae53eac 861 </figure>
ef963ecb
MT
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">
9e1bc5f1 868 <img src="/docs%(url)s?s=2048&amp;%(args)s" alt="%(caption)s"
ef963ecb
MT
869 loading="lazy">
870 </p>
aabfb733
MT
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>
ef963ecb
MT
877 </div>
878
879 <button class="modal-close is-large" aria-label="close"></button>
880 </div>
3ae53eac
MT
881 </div>
882 </div>
4a1bfdd5
MT
883 """
884
a1f5f64b
MT
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
c78ad26e 891 # Skip any absolute and external URLs
25e2dbe5 892 if url.startswith("https://") or url.startswith("http://"):
ef963ecb
MT
893 return html % {
894 "caption" : caption or "",
895 "id" : id,
ef963ecb 896 "url" : url,
fcfccf8a 897 "args" : args,
ef963ecb 898 }
2901b734 899
c78ad26e 900 # Build absolute path
25e2dbe5 901 url = self.backend.wiki.make_path(self.path, url)
2901b734 902
c78ad26e 903 # Find image
25e2dbe5 904 file = self.backend.wiki.get_file_by_path(url, revision=self.revision)
c78ad26e
MT
905 if not file or not file.is_image():
906 return "<!-- Could not find image %s in %s -->" % (url, self.path)
2901b734 907
6d8a51d9
MT
908 # Remove any requested size
909 if "s" in args:
910 del args["s"]
2901b734 911
25e2dbe5
MT
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
4a1bfdd5 915
ef963ecb
MT
916 return html % {
917 "caption" : caption or "",
918 "id" : id,
ef963ecb 919 "url" : url,
25e2dbe5 920 "args" : urllib.parse.urlencode(args),
ef963ecb 921 }
2901b734 922
5ab70651 923 def _render(self):
c78ad26e 924 logging.debug("Rendering %s" % self.path)
2901b734 925
245a2e36 926 # Render...
5ab70651 927 text = self.renderer.convert(self.text)
9881e9ef 928
4ddad3e5 929 # Postprocess links
5ab70651 930 text = self._links.sub(self._render_link, text)
4ddad3e5 931
9881e9ef 932 # Postprocess images to <figure>
5ab70651 933 text = self._images.sub(self._render_image, text)
c78ad26e 934
9881e9ef 935 return text
5ab70651
MT
936
937 @lazy_property
2ab2c753 938 def files(self):
5ab70651 939 """
2ab2c753 940 A list of all linked files that have been part of the rendered markup
5ab70651 941 """
2ab2c753 942 files = []
5ab70651 943
2ab2c753 944 for url in self.renderer.files:
5ab70651
MT
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
ca493e11
MT
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)
5ab70651 956
2ab2c753 957 return files
5ab70651
MT
958
959
886d5f7b
MT
960class 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
5ab70651
MT
969class 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
8d4a78e8
MT
977 # Link mentioned users
978 md.preprocessors.register(UserMentionPreprocessor(md), "user-mention", 10)
979
5ab70651
MT
980
981class 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
989class 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
8d4a78e8
MT
997class 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
2ab2c753 1019class LinkedFilesExtractor(markdown.treeprocessors.Treeprocessor):
5ab70651 1020 """
2ab2c753 1021 Finds all Linked Files
5ab70651 1022 """
67be8d58
MT
1023 def __init__(self, *args, **kwargs):
1024 super().__init__(*args, **kwargs)
1025
2ab2c753 1026 self.md.files = []
5ab70651 1027
67be8d58 1028 def run(self, root):
5ab70651
MT
1029 # Find all images and store the URLs
1030 for image in root.findall(".//img"):
1031 src = image.get("src")
1032
2ab2c753 1033 self.md.files.append(src)
5ab70651 1034
ca493e11
MT
1035 # Find all links
1036 for link in root.findall(".//a"):
1037 href = link.get("href")
1038
1039 self.md.files.append(href)
1040
5ab70651 1041
2ab2c753 1042class LinkedFilesExtractorExtension(markdown.extensions.Extension):
5ab70651 1043 def extendMarkdown(self, md):
2ab2c753 1044 md.treeprocessors.register(LinkedFilesExtractor(md), "linked-files-extractor", 10)