]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/wiki.py
wiki: Remove superfluous slash when creating user links
[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 598 # Compose message
377acc73 599 watcher.send_message("wiki/messages/page-changed", page=self, priority=-10)
aba5e58a 600
9f1cfab7 601 def restore(self, author, address, comment=None):
d4c68c5c
MT
602 changes = "Restore to revision from %s" % self.timestamp.isoformat()
603
9f1cfab7
MT
604 # Append comment
605 if comment:
606 changes = "%s: %s" % (changes, comment)
607
d4c68c5c
MT
608 return self.backend.wiki.create_page(self.page,
609 author, self.markdown, changes=changes, address=address)
610
f2cfd873
MT
611
612class File(misc.Object):
613 def init(self, id, data):
614 self.id = id
615 self.data = data
616
ff14dea3
MT
617 def __eq__(self, other):
618 if isinstance(other, self.__class__):
619 return self.id == other.id
620
9406e5e2
MT
621 return NotImplemented
622
f2cfd873
MT
623 @property
624 def url(self):
a82de4b1 625 return "/docs%s" % os.path.join(self.path, self.filename)
f2cfd873
MT
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
8cb0bea4
MT
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
25e2dbe5
MT
652 timestamp = created_at
653
b26c705a 654 def delete(self, author=None):
9406e5e2
MT
655 if not self.can_be_deleted():
656 raise RuntimeError("Cannot delete %s" % self)
657
b26c705a
MT
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)
ff14dea3 660
9406e5e2
MT
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
ff14dea3
MT
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 \
2225edd9 682 WHERE path = %s AND filename = %s ORDER BY created_at DESC", self.path, self.filename)
ff14dea3
MT
683
684 return list(revisions)
685
8cb0bea4
MT
686 def is_pdf(self):
687 return self.mimetype in ("application/pdf", "application/x-pdf")
688
f2cfd873
MT
689 def is_image(self):
690 return self.mimetype.startswith("image/")
691
8a62e589
MT
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
f2cfd873
MT
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)
79dd9a0f 705
ccfb1584 706 async def get_thumbnail(self, size, format=None):
8a62e589
MT
707 assert self.is_bitmap_image()
708
531c4989
MT
709 # Let thumbnails live in the cache for up to 24h
710 ttl = 24 * 3600
711
ccfb1584
MT
712 cache_key = ":".join((
713 "wiki",
714 "thumbnail",
df4f5dfb
MT
715 self.path,
716 util.normalize(self.filename),
717 self.created_at.isoformat(),
ccfb1584 718 format or "N/A",
df4f5dfb
MT
719 "%spx" % size,
720 ))
75d9b3da
MT
721
722 # Try to fetch the data from the cache
531c4989
MT
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
75d9b3da
MT
734 if thumbnail:
735 return thumbnail
736
737 # Generate the thumbnail
60b2a858 738 thumbnail = util.generate_thumbnail(self.blob, size, format=format, quality=95)
75d9b3da 739
531c4989
MT
740 # Put it into the cache for 24h
741 await self.backend.cache.set(cache_key, thumbnail, ttl)
75d9b3da
MT
742
743 return thumbnail
2901b734 744
9406e5e2
MT
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
2901b734
MT
768
769class WikiRenderer(misc.Object):
4ddad3e5
MT
770 schemas = (
771 "ftp://",
772 "git://",
773 "http://",
774 "https://",
775 "rsync://",
776 "sftp://",
777 "ssh://",
778 "webcal://",
779 )
780
781 # Links
5ab70651 782 _links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
2901b734 783
c78ad26e 784 # Images
5ab70651 785 _images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
245a2e36 786
25e2dbe5 787 def init(self, path, text, revision=None):
2901b734 788 self.path = path
5ab70651
MT
789 self.text = text
790
25e2dbe5
MT
791 # Optionally, the revision of the rendered page
792 self.revision = revision
793
5ab70651 794 # Markdown Renderer
886d5f7b
MT
795 self.renderer = Markdown(
796 self.backend,
5ab70651 797 extensions=[
2ab2c753 798 LinkedFilesExtractorExtension(),
5ab70651
MT
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()
2901b734 812
4ddad3e5
MT
813 def _render_link(self, m):
814 url, text = m.groups()
2901b734 815
0a17d51e
MT
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
e50a437a
MT
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
4ddad3e5
MT
830 # Emails
831 if "@" in url:
832 # Strip mailto:
833 if url.startswith("mailto:"):
834 url = url[7:]
2901b734 835
4ddad3e5
MT
836 return """<a class="link-external" href="mailto:%s">%s</a>""" % \
837 (url, text or url)
2901b734 838
4ddad3e5
MT
839 # Everything else must be an internal link
840 path = self.backend.wiki.make_path(self.path, url)
2901b734 841
46b77977 842 return """<a href="/docs%s">%s</a>""" % \
4ddad3e5 843 (path, text or self.backend.wiki.get_page_title(path))
2901b734 844
c78ad26e 845 def _render_image(self, m):
e9c6d581 846 alt_text, url, caption = m.groups()
2901b734 847
ef963ecb
MT
848 # Compute a hash over the URL
849 h = hashlib.new("md5")
850 h.update(url.encode())
851 id = h.hexdigest()
852
4a1bfdd5 853 html = """
3ae53eac
MT
854 <div class="columns is-centered">
855 <div class="column is-8">
ef963ecb 856 <figure class="image modal-trigger" data-target="%(id)s">
9e1bc5f1 857 <img src="/docs%(url)s?s=960&amp;%(args)s" alt="%(caption)s">
ef963ecb
MT
858
859 <figcaption class="figure-caption">%(caption)s</figcaption>
3ae53eac 860 </figure>
ef963ecb
MT
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">
9e1bc5f1 867 <img src="/docs%(url)s?s=2048&amp;%(args)s" alt="%(caption)s"
ef963ecb
MT
868 loading="lazy">
869 </p>
aabfb733
MT
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>
ef963ecb
MT
876 </div>
877
878 <button class="modal-close is-large" aria-label="close"></button>
879 </div>
3ae53eac
MT
880 </div>
881 </div>
4a1bfdd5
MT
882 """
883
a1f5f64b
MT
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
c78ad26e 890 # Skip any absolute and external URLs
25e2dbe5 891 if url.startswith("https://") or url.startswith("http://"):
ef963ecb
MT
892 return html % {
893 "caption" : caption or "",
894 "id" : id,
ef963ecb 895 "url" : url,
fcfccf8a 896 "args" : args,
ef963ecb 897 }
2901b734 898
c78ad26e 899 # Build absolute path
25e2dbe5 900 url = self.backend.wiki.make_path(self.path, url)
2901b734 901
c78ad26e 902 # Find image
25e2dbe5 903 file = self.backend.wiki.get_file_by_path(url, revision=self.revision)
c78ad26e
MT
904 if not file or not file.is_image():
905 return "<!-- Could not find image %s in %s -->" % (url, self.path)
2901b734 906
6d8a51d9
MT
907 # Remove any requested size
908 if "s" in args:
909 del args["s"]
2901b734 910
25e2dbe5
MT
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
4a1bfdd5 914
ef963ecb
MT
915 return html % {
916 "caption" : caption or "",
917 "id" : id,
ef963ecb 918 "url" : url,
25e2dbe5 919 "args" : urllib.parse.urlencode(args),
ef963ecb 920 }
2901b734 921
5ab70651 922 def _render(self):
c78ad26e 923 logging.debug("Rendering %s" % self.path)
2901b734 924
245a2e36 925 # Render...
5ab70651 926 text = self.renderer.convert(self.text)
9881e9ef 927
4ddad3e5 928 # Postprocess links
5ab70651 929 text = self._links.sub(self._render_link, text)
4ddad3e5 930
9881e9ef 931 # Postprocess images to <figure>
5ab70651 932 text = self._images.sub(self._render_image, text)
c78ad26e 933
9881e9ef 934 return text
5ab70651
MT
935
936 @lazy_property
2ab2c753 937 def files(self):
5ab70651 938 """
2ab2c753 939 A list of all linked files that have been part of the rendered markup
5ab70651 940 """
2ab2c753 941 files = []
5ab70651 942
2ab2c753 943 for url in self.renderer.files:
5ab70651
MT
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
ca493e11
MT
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)
5ab70651 955
2ab2c753 956 return files
5ab70651
MT
957
958
886d5f7b
MT
959class 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
5ab70651
MT
968class 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
8d4a78e8
MT
976 # Link mentioned users
977 md.preprocessors.register(UserMentionPreprocessor(md), "user-mention", 10)
978
5ab70651
MT
979
980class 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
988class 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
8d4a78e8 996class UserMentionPreprocessor(markdown.preprocessors.Preprocessor):
04f3e833 997 regex = re.compile(r"\B@(\w+)")
8d4a78e8
MT
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
335ca71a 1015 return "[%s](/users/%s)" % (user, user.uid)
8d4a78e8
MT
1016
1017
2ab2c753 1018class LinkedFilesExtractor(markdown.treeprocessors.Treeprocessor):
5ab70651 1019 """
2ab2c753 1020 Finds all Linked Files
5ab70651 1021 """
67be8d58
MT
1022 def __init__(self, *args, **kwargs):
1023 super().__init__(*args, **kwargs)
1024
2ab2c753 1025 self.md.files = []
5ab70651 1026
67be8d58 1027 def run(self, root):
5ab70651
MT
1028 # Find all images and store the URLs
1029 for image in root.findall(".//img"):
1030 src = image.get("src")
1031
2ab2c753 1032 self.md.files.append(src)
5ab70651 1033
ca493e11
MT
1034 # Find all links
1035 for link in root.findall(".//a"):
1036 href = link.get("href")
1037
1038 self.md.files.append(href)
1039
5ab70651 1040
2ab2c753 1041class LinkedFilesExtractorExtension(markdown.extensions.Extension):
5ab70651 1042 def extendMarkdown(self, md):
2ab2c753 1043 md.treeprocessors.register(LinkedFilesExtractor(md), "linked-files-extractor", 10)