]>
git.ipfire.org Git - ipfire.org.git/blob - src/backend/wiki.py
c3ddfab02aacbcf20c68d8de9defb5500689a081
7 import markdown
.extensions
8 import markdown
.preprocessors
15 from .decorators
import *
17 class Wiki(misc
.Object
):
18 def _get_pages(self
, query
, *args
):
19 res
= self
.db
.query(query
, *args
)
22 yield Page(self
.backend
, row
.id, data
=row
)
24 def _get_page(self
, query
, *args
):
25 res
= self
.db
.get(query
, *args
)
28 return Page(self
.backend
, res
.id, data
=res
)
31 return self
._get
_pages
("""
37 wiki ON current.id = wiki.id
39 current.deleted IS FALSE
44 def make_path(self
, page
, path
):
45 # Nothing to do for absolute links
46 if path
.startswith("/"):
49 # Relative links (one-level down)
50 elif path
.startswith("./"):
51 path
= os
.path
.join(page
, path
)
53 # All other relative links
55 p
= os
.path
.dirname(page
)
56 path
= os
.path
.join(p
, path
)
59 return os
.path
.normpath(path
)
61 def _make_url(self
, path
):
63 Composes the URL out of the path
65 # Remove any leading slashes (if present)
66 path
= path
.removeprefix("/")
68 return os
.path
.join("/docs", path
)
70 def get_page_title(self
, page
, default
=None):
71 doc
= self
.get_page(page
)
75 title
= os
.path
.basename(page
)
79 def get_page(self
, page
, revision
=None):
80 page
= Page
.sanitise_page_name(page
)
82 # Split the path into parts
83 parts
= page
.split("/")
85 # Check if this is an action
86 if any((part
.startswith("_") for part
in parts
)):
90 return self
._get
_page
("SELECT * FROM wiki WHERE page = %s \
91 AND timestamp = %s", page
, revision
)
93 return self
._get
_page
("SELECT * FROM wiki WHERE page = %s \
94 ORDER BY timestamp DESC LIMIT 1", page
)
96 def get_recent_changes(self
, account
, limit
=None):
97 pages
= self
._get
_pages
("SELECT * FROM wiki \
98 ORDER BY timestamp DESC")
101 if not page
.check_acl(account
):
110 def create_page(self
, page
, author
, content
, changes
=None, address
=None):
111 page
= Page
.sanitise_page_name(page
)
113 # Write page to the database
114 page
= self
._get
_page
("""
127 """, page
, author
.uid
, content
or None, changes
, address
,
130 # Store any linked files
131 page
._store
_linked
_files
()
133 # Send email to all watchers
134 page
._send
_watcher
_emails
(excludes
=[author
])
138 def delete_page(self
, page
, author
, **kwargs
):
139 # Do nothing if the page does not exist
140 if not self
.get_page(page
):
143 # Just creates a blank last version of the page
144 self
.create_page(page
, author
=author
, content
=None, **kwargs
)
146 def make_breadcrumbs(self
, path
):
150 # Cut off everything after the last slash
151 path
, _
, _
= path
.rpartition("/")
153 # Do not include the root
158 page
= self
.get_page(path
)
160 # Append the URL and title to the output
162 page
.url
if page
else self
._make
_url
(path
),
163 page
.title
if page
else os
.path
.basename(path
),
166 # Return the breadcrumbs in order
169 def search(self
, query
, account
=None, limit
=None):
170 res
= self
._get
_pages
("""
174 wiki_search_index search_index
176 wiki ON search_index.wiki_id = wiki.id
178 search_index.document @@ websearch_to_tsquery('english', %s)
180 ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC
186 # Skip any pages the user doesn't have permission for
187 if not page
.check_acl(account
):
190 # Return any other pages
193 # Break when we have found enough pages
194 if limit
and len(pages
) >= limit
:
201 Needs to be called after a page has been changed
203 self
.db
.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY wiki_search_index")
205 def get_watchlist(self
, account
):
206 pages
= self
._get
_pages
("""
213 wiki ON wiki_current.id = wiki.id
219 wiki_watchlist watchlist
221 pages ON watchlist.page = pages.page
231 def check_acl(self
, page
, account
):
232 res
= self
.db
.query("""
238 %s ILIKE (path || '%%')
246 # Access not permitted when user is not logged in
250 # If user is in a matching group, we grant permission
251 for group
in row
.groups
:
252 if account
.is_member_of_group(group
):
255 # Otherwise access is not permitted
258 # If no ACLs are found, we permit access
263 def _get_files(self
, query
, *args
):
264 res
= self
.db
.query(query
, *args
)
267 yield File(self
.backend
, row
.id, data
=row
)
269 def _get_file(self
, query
, *args
):
270 res
= self
.db
.get(query
, *args
)
273 return File(self
.backend
, res
.id, data
=res
)
275 def get_files(self
, path
):
276 files
= self
._get
_files
("""
291 def get_file_by_path(self
, path
, revision
=None):
292 path
, filename
= os
.path
.dirname(path
), os
.path
.basename(path
)
295 # Fetch a specific revision
296 return self
._get
_file
("""
310 """, path
, filename
, revision
,
313 # Fetch latest version
314 return self
._get
_file
("""
328 def get_file_by_path_and_filename(self
, path
, filename
):
329 return self
._get
_file
("""
343 def upload(self
, path
, filename
, data
, mimetype
, author
, address
):
344 # Replace any existing files
345 file = self
.get_file_by_path_and_filename(path
, filename
)
349 # Upload the blob first
350 blob
= self
.db
.get("""
358 SET data = EXCLUDED.data
363 # Create entry for file
364 return self
._get
_file
("""
376 %s, %s, %s, %s, %s, %s, %s
379 """, path
, filename
, author
.uid
, address
, mimetype
, blob
.id, len(data
),
382 def render(self
, path
, text
, **kwargs
):
383 return WikiRenderer(self
.backend
, path
, text
, **kwargs
)
386 class Page(misc
.Object
):
387 def init(self
, id, data
=None):
392 return "<%s %s %s>" % (self
.__class
__.__name
__, self
.page
, self
.timestamp
)
394 def __eq__(self
, other
):
395 if isinstance(other
, self
.__class
__):
396 return self
.id == other
.id
398 return NotImplemented
400 def __lt__(self
, other
):
401 if isinstance(other
, self
.__class
__):
402 if self
.page
== other
.page
:
403 return self
.timestamp
< other
.timestamp
405 return self
.page
< other
.page
407 return NotImplemented
410 return hash(self
.page
)
413 def sanitise_page_name(page
):
417 # Make sure that the page name does NOT end with a /
418 if page
.endswith("/"):
421 # Make sure the page name starts with a /
422 if not page
.startswith("/"):
425 # Remove any double slashes
426 page
= page
.replace("//", "/")
432 return self
.backend
.wiki
._make
_url
(self
.page
)
436 return "https://www.ipfire.org%s" % self
.url
440 return self
.data
.page
444 return self
._title
or os
.path
.basename(self
.page
[1:])
448 if not self
.markdown
:
451 # Find first H1 headline in markdown
452 markdown
= self
.markdown
.splitlines()
454 m
= re
.match(r
"^#\s*(.*)( #)?$", markdown
[0])
460 if self
.data
.author_uid
:
461 return self
.backend
.accounts
.get_by_uid(self
.data
.author_uid
)
465 return self
.data
.markdown
or ""
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("#"):
478 renderer
= self
.backend
.wiki
.render(self
.page
, "\n".join(lines
), revision
=self
.timestamp
)
486 renderer
= self
.backend
.wiki
.render(self
.page
, self
.markdown
, revision
=self
.timestamp
)
488 return renderer
.files
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
))
496 return self
.data
.timestamp
498 def was_deleted(self
):
499 return not self
.markdown
502 def breadcrumbs(self
):
503 return self
.backend
.wiki
.make_breadcrumbs(self
.page
)
505 def is_latest_revision(self
):
506 return self
.get_latest_revision() == self
508 def get_latest_revision(self
):
509 revisions
= self
.get_revisions()
511 # Return first object
512 for rev
in revisions
:
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
)
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
)
527 return self
.data
.changes
531 def check_acl(self
, account
):
532 return self
.backend
.wiki
.check_acl(self
.page
, account
)
538 if self
.previous_revision
:
539 diff
= difflib
.unified_diff(
540 self
.previous_revision
.markdown
.splitlines(),
541 self
.markdown
.splitlines(),
544 return "\n".join(diff
)
548 res
= self
.db
.query("SELECT uid FROM wiki_watchlist \
549 WHERE page = %s", self
.page
)
552 # Search for account by UID and skip if none was found
553 account
= self
.backend
.accounts
.get_by_uid(row
.uid
)
560 def is_watched_by(self
, account
):
561 res
= self
.db
.get("SELECT 1 FROM wiki_watchlist \
562 WHERE page = %s AND uid = %s", self
.page
, account
.uid
)
569 def add_watcher(self
, account
):
570 if self
.is_watched_by(account
):
573 self
.db
.execute("INSERT INTO wiki_watchlist(page, uid) \
574 VALUES(%s, %s)", self
.page
, account
.uid
)
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
)
580 def _send_watcher_emails(self
, excludes
=[]):
581 # Nothing to do if there was no previous revision
582 if not self
.previous_revision
:
585 for watcher
in self
.watchers
:
586 # Skip everyone who is excluded
587 if watcher
in excludes
:
588 logging
.debug("Excluding %s" % watcher
)
592 if not self
.backend
.wiki
.check_acl(self
.page
, watcher
):
593 logging
.debug("Watcher %s does not have permissions" % watcher
)
596 logging
.debug("Sending watcher email to %s" % watcher
)
599 watcher
.send_message("wiki/messages/page-changed", page
=self
, priority
=-10)
601 def restore(self
, author
, address
, comment
=None):
602 changes
= "Restore to revision from %s" % self
.timestamp
.isoformat()
606 changes
= "%s: %s" % (changes
, comment
)
608 return self
.backend
.wiki
.create_page(self
.page
,
609 author
, self
.markdown
, changes
=changes
, address
=address
)
612 class File(misc
.Object
):
613 def init(self
, id, data
):
617 def __eq__(self
, other
):
618 if isinstance(other
, self
.__class
__):
619 return self
.id == other
.id
621 return NotImplemented
625 return "/docs%s" % os
.path
.join(self
.path
, self
.filename
)
629 return self
.data
.path
633 return self
.data
.filename
637 return self
.data
.mimetype
641 return self
.data
.size
645 if self
.data
.author_uid
:
646 return self
.backend
.accounts
.get_by_uid(self
.data
.author_uid
)
649 def created_at(self
):
650 return self
.data
.created_at
652 timestamp
= created_at
654 def delete(self
, author
=None):
655 if not self
.can_be_deleted():
656 raise RuntimeError("Cannot delete %s" % self
)
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)
661 def can_be_deleted(self
):
662 # Cannot be deleted if still in use
670 def deleted_at(self
):
671 return self
.data
.deleted_at
673 def get_latest_revision(self
):
674 revisions
= self
.get_revisions()
676 # Return first object
677 for rev
in revisions
:
680 def get_revisions(self
):
681 revisions
= self
.backend
.wiki
._get
_files
("SELECT * FROM wiki_files \
682 WHERE path = %s AND filename = %s ORDER BY created_at DESC", self
.path
, self
.filename
)
684 return list(revisions
)
687 return self
.mimetype
in ("application/pdf", "application/x-pdf")
690 return self
.mimetype
.startswith("image/")
692 def is_vector_image(self
):
693 return self
.mimetype
in ("image/svg+xml",)
695 def is_bitmap_image(self
):
696 return self
.is_image() and not self
.is_vector_image()
700 res
= self
.db
.get("SELECT data FROM wiki_blobs \
701 WHERE id = %s", self
.data
.blob_id
)
704 return bytes(res
.data
)
706 async def get_thumbnail(self
, size
, format
=None):
707 assert self
.is_bitmap_image()
709 # Let thumbnails live in the cache for up to 24h
712 cache_key
= ":".join((
716 util
.normalize(self
.filename
),
717 self
.created_at
.isoformat(),
722 # Try to fetch the data from the cache
723 async with
await self
.backend
.cache
.pipeline() as p
:
725 await p
.get(cache_key
)
728 await p
.expire(cache_key
, ttl
)
730 # Execute the pipeline
731 thumbnail
, _
= await p
.execute()
733 # Return the cached value
737 # Generate the thumbnail
738 thumbnail
= util
.generate_thumbnail(self
.blob
, size
, format
=format
, quality
=95)
740 # Put it into the cache for 24h
741 await self
.backend
.cache
.set(cache_key
, thumbnail
, ttl
)
748 Returns a list of all pages this file is linked by
750 pages
= self
.backend
.wiki
._get
_pages
("""
756 wiki_current ON wiki_linked_files.page_id = wiki_current.id
758 wiki ON wiki_linked_files.page_id = wiki.id
760 wiki_linked_files.path = %s
763 """, os
.path
.join(self
.path
, self
.filename
),
769 class WikiRenderer(misc
.Object
):
782 _links
= re
.compile(r
"<a href=\"(.*?
)\">(.*?
)</a
>")
785 _images = re.compile(r"<img
alt(?
:=\"(.*?
)\")? src
=\"(.*?
)\" (?
:title
=\"(.*?
)\" )?
/>")
787 def init(self, path, text, revision=None):
791 # Optionally, the revision of the rendered page
792 self.revision = revision
795 self.renderer = Markdown(
798 LinkedFilesExtractorExtension(),
799 PrettyLinksExtension(),
811 self.html = self._render()
813 def _render_link(self, m):
814 url, text = m.groups()
816 # Treat linkes starting with a double slash as absolute
817 if url.startswith("//"):
818 # Remove the double-lash
819 url = url.removeprefix("/")
822 return """<a href="%s">%s</a>""" % (url, text or url)
825 for schema in self.schemas:
826 if url.startswith(schema):
827 return """<a class="link
-external
" href="%s">%s</a>""" % \
833 if url.startswith("mailto
:"):
836 return """<a class="link
-external
" href="mailto
:%s">%s</a>""" % \
839 # Everything else must be an internal link
840 path = self.backend.wiki.make_path(self.path, url)
842 return """<a href="/docs
%s">%s</a>""" % \
843 (path, text or self.backend.wiki.get_page_title(path))
845 def _render_image(self, m):
846 alt_text, url, caption = m.groups()
848 # Compute a hash over the URL
849 h = hashlib.new("md5
")
850 h.update(url.encode())
854 <div class="columns
is-centered
">
855 <div class="column
is-8">
856 <figure class="image modal
-trigger
" data-target="%(id)s">
857 <img src="/docs
%(url)s?s
=960&
;%(args)s" alt="%(caption)s">
859 <figcaption class="figure
-caption
">%(caption)s</figcaption>
862 <div class="modal
is-large
" id="%(id)s">
863 <div class="modal
-background
"></div>
865 <div class="modal
-content
">
867 <img src="/docs
%(url)s?s
=2048&
;%(args)s" alt="%(caption)s"
871 <a class="button
is-small
" href="/docs
%(url)s?action
=detail
">
873 <i class="fa
-solid fa
-circle
-info
"></i>
878 <button class="modal
-close
is-large
" aria-label="close
"></button>
884 # Try to split query string
885 url, delimiter, qs = url.partition("?
")
887 # Parse query arguments
888 args = urllib.parse.parse_qs(qs)
890 # Skip any absolute and external URLs
891 if url.startswith("https
://") or url.startswith("http
://"):
893 "caption
" : caption or "",
899 # Build absolute path
900 url = self.backend.wiki.make_path(self.path, url)
903 file = self.backend.wiki.get_file_by_path(url, revision=self.revision)
904 if not file or not file.is_image():
905 return "<!-- Could
not find image
%s in %s -->" % (url, self.path)
907 # Remove any requested size
911 # Link the image that has been the current version at the time of the page edit
913 args["revision
"] = file.timestamp
916 "caption
" : caption or "",
919 "args
" : urllib.parse.urlencode(args),
923 logging.debug("Rendering
%s" % self.path)
926 text = self.renderer.convert(self.text)
929 text = self._links.sub(self._render_link, text)
931 # Postprocess images to <figure>
932 text = self._images.sub(self._render_image, text)
939 A list of all linked files that have been part of the rendered markup
943 for url in self.renderer.files:
944 # Skip external images
945 if url.startswith("https
://") or url.startswith("http
://"):
948 # Make the URL absolute
949 url = self.backend.wiki.make_path(self.path, url)
951 # Check if this is a file (it could also just be a page)
952 file = self.backend.wiki.get_file_by_path(url)
959 class Markdown(markdown.Markdown):
960 def __init__(self, backend, *args, **kwargs):
962 self.backend = backend
964 # Call inherited setup routine
965 super().__init__(*args, **kwargs)
968 class PrettyLinksExtension(markdown.extensions.Extension):
969 def extendMarkdown(self, md):
970 # Create links to Bugzilla
971 md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla
", 10)
973 # Create links to CVE
974 md.preprocessors.register(CVELinksPreprocessor(md), "cve
", 10)
976 # Link mentioned users
977 md.preprocessors.register(UserMentionPreprocessor(md), "user
-mention
", 10)
980 class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor):
981 regex = re.compile(r"(?
:#(\d{5,}))", re.I)
983 def run(self
, lines
):
985 yield self
.regex
.sub(r
"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line
)
988 class CVELinksPreprocessor(markdown
.preprocessors
.Preprocessor
):
989 regex
= re
.compile(r
"(?:CVE)[\s\-](\d{4}\-\d+)")
991 def run(self
, lines
):
993 yield self
.regex
.sub(r
"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line
)
996 class UserMentionPreprocessor(markdown
.preprocessors
.Preprocessor
):
997 regex
= re
.compile(r
"\B@(\w+)")
999 def run(self
, lines
):
1001 yield self
.regex
.sub(self
._replace
, line
)
1003 def _replace(self
, m
):
1004 # Fetch the user's handle
1008 user
= self
.md
.backend
.accounts
.get_by_uid(uid
)
1010 # If the user was not found, we put back the matched text
1015 return "[%s](/users/%s)" % (user
, user
.uid
)
1018 class LinkedFilesExtractor(markdown
.treeprocessors
.Treeprocessor
):
1020 Finds all Linked Files
1022 def __init__(self
, *args
, **kwargs
):
1023 super().__init
__(*args
, **kwargs
)
1027 def run(self
, root
):
1028 # Find all images and store the URLs
1029 for image
in root
.findall(".//img"):
1030 src
= image
.get("src")
1032 self
.md
.files
.append(src
)
1035 for link
in root
.findall(".//a"):
1036 href
= link
.get("href")
1038 self
.md
.files
.append(href
)
1041 class LinkedFilesExtractorExtension(markdown
.extensions
.Extension
):
1042 def extendMarkdown(self
, md
):
1043 md
.treeprocessors
.register(LinkedFilesExtractor(md
), "linked-files-extractor", 10)