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