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