]>
git.ipfire.org Git - ipfire.org.git/blob - src/backend/wiki.py
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
(
32 "SELECT wiki.* FROM wiki_current current \
33 LEFT JOIN wiki ON current.id = wiki.id \
34 WHERE current.deleted IS FALSE \
38 def make_path(self
, page
, path
):
39 # Nothing to do for absolute links
40 if path
.startswith("/"):
43 # Relative links (one-level down)
44 elif path
.startswith("./"):
45 path
= os
.path
.join(page
, path
)
47 # All other relative links
49 p
= os
.path
.dirname(page
)
50 path
= os
.path
.join(p
, path
)
53 return os
.path
.normpath(path
)
55 def page_exists(self
, path
):
56 page
= self
.get_page(path
)
58 # Page must have been found and not deleted
59 return page
and not page
.was_deleted()
61 def get_page_title(self
, page
, default
=None):
62 doc
= self
.get_page(page
)
66 title
= os
.path
.basename(page
)
70 def get_page(self
, page
, revision
=None):
71 page
= Page
.sanitise_page_name(page
)
73 # Split the path into parts
74 parts
= page
.split("/")
76 # Check if this is an action
77 if any((part
.startswith("_") for part
in parts
)):
81 return self
._get
_page
("SELECT * FROM wiki WHERE page = %s \
82 AND timestamp = %s", page
, revision
)
84 return self
._get
_page
("SELECT * FROM wiki WHERE page = %s \
85 ORDER BY timestamp DESC LIMIT 1", page
)
87 def get_recent_changes(self
, account
, limit
=None):
88 pages
= self
._get
_pages
("SELECT * FROM wiki \
89 ORDER BY timestamp DESC")
92 if not page
.check_acl(account
):
101 def create_page(self
, page
, author
, content
, changes
=None, address
=None):
102 page
= Page
.sanitise_page_name(page
)
104 # Write page to the database
105 page
= self
._get
_page
("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
106 VALUES(%s, %s, %s, %s, %s) RETURNING *", page
, author
.uid
, content
or None, changes
, address
)
108 # Send email to all watchers
109 page
._send
_watcher
_emails
(excludes
=[author
])
113 def delete_page(self
, page
, author
, **kwargs
):
114 # Do nothing if the page does not exist
115 if not self
.get_page(page
):
118 # Just creates a blank last version of the page
119 self
.create_page(page
, author
=author
, content
=None, **kwargs
)
121 def make_breadcrumbs(self
, url
):
122 # Split and strip all empty elements (double slashes)
123 parts
= list(e
for e
in url
.split("/") if e
)
126 for part
in ("/".join(parts
[:i
]) for i
in range(1, len(parts
))):
127 ret
.append(("/%s" % part
, self
.get_page_title(part
, os
.path
.basename(part
))))
131 def search(self
, query
, account
=None, limit
=None):
132 res
= self
._get
_pages
("SELECT wiki.* FROM wiki_search_index search_index \
133 LEFT JOIN wiki ON search_index.wiki_id = wiki.id \
134 WHERE search_index.document @@ websearch_to_tsquery('english', %s) \
135 ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC",
140 # Skip any pages the user doesn't have permission for
141 if not page
.check_acl(account
):
144 # Return any other pages
147 # Break when we have found enough pages
148 if limit
and len(pages
) >= limit
:
155 Needs to be called after a page has been changed
157 self
.db
.execute("REFRESH MATERIALIZED VIEW wiki_search_index")
159 def get_watchlist(self
, account
):
160 pages
= self
._get
_pages
("""
167 wiki ON wiki_current.id = wiki.id
173 wiki_watchlist watchlist
175 pages ON watchlist.page = pages.page
185 def check_acl(self
, page
, account
):
186 res
= self
.db
.query("SELECT * FROM wiki_acls \
187 WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page
)
190 # Access not permitted when user is not logged in
194 # If user is in a matching group, we grant permission
195 for group
in row
.groups
:
196 if account
.is_member_of_group(group
):
199 # Otherwise access is not permitted
202 # If no ACLs are found, we permit access
207 def _get_files(self
, query
, *args
):
208 res
= self
.db
.query(query
, *args
)
211 yield File(self
.backend
, row
.id, data
=row
)
213 def _get_file(self
, query
, *args
):
214 res
= self
.db
.get(query
, *args
)
217 return File(self
.backend
, res
.id, data
=res
)
219 def get_files(self
, path
):
220 files
= self
._get
_files
("SELECT * FROM wiki_files \
221 WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path
)
225 def get_file_by_path(self
, path
, revision
=None):
226 path
, filename
= os
.path
.dirname(path
), os
.path
.basename(path
)
229 # Fetch a specific revision
230 return self
._get
_file
("SELECT * FROM wiki_files \
231 WHERE path = %s AND filename = %s AND created_at <= %s \
232 ORDER BY created_at DESC LIMIT 1", path
, filename
, revision
)
234 # Fetch latest version
235 return self
._get
_file
("SELECT * FROM wiki_files \
236 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
239 def get_file_by_path_and_filename(self
, path
, filename
):
240 return self
._get
_file
("SELECT * FROM wiki_files \
241 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
244 def upload(self
, path
, filename
, data
, mimetype
, author
, address
):
245 # Replace any existing files
246 file = self
.get_file_by_path_and_filename(path
, filename
)
250 # Upload the blob first
251 blob
= self
.db
.get("INSERT INTO wiki_blobs(data) VALUES(%s) \
252 ON CONFLICT (digest(data, %s)) DO UPDATE SET data = EXCLUDED.data \
253 RETURNING id", data
, "MD5")
255 # Create entry for file
256 return self
._get
_file
("INSERT INTO wiki_files(path, filename, author_uid, address, \
257 mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path
,
258 filename
, author
.uid
, address
, mimetype
, blob
.id, len(data
))
260 def render(self
, path
, text
):
261 r
= WikiRenderer(self
.backend
, path
)
263 return r
.render(text
)
266 class Page(misc
.Object
):
267 def init(self
, id, data
=None):
272 return "<%s %s %s>" % (self
.__class
__.__name
__, self
.page
, self
.timestamp
)
274 def __eq__(self
, other
):
275 if isinstance(other
, self
.__class
__):
276 return self
.id == other
.id
278 return NotImplemented
280 def __lt__(self
, other
):
281 if isinstance(other
, self
.__class
__):
282 if self
.page
== other
.page
:
283 return self
.timestamp
< other
.timestamp
285 return self
.page
< other
.page
287 return NotImplemented
290 def sanitise_page_name(page
):
294 # Make sure that the page name does NOT end with a /
295 if page
.endswith("/"):
298 # Make sure the page name starts with a /
299 if not page
.startswith("/"):
302 # Remove any double slashes
303 page
= page
.replace("//", "/")
309 return "/docs%s" % self
.page
313 return "https://www.ipfire.org%s" % self
.url
317 return self
.data
.page
321 return self
._title
or os
.path
.basename(self
.page
[1:])
325 if not self
.markdown
:
328 # Find first H1 headline in markdown
329 markdown
= self
.markdown
.splitlines()
331 m
= re
.match(r
"^#\s*(.*)( #)?$", markdown
[0])
337 if self
.data
.author_uid
:
338 return self
.backend
.accounts
.get_by_uid(self
.data
.author_uid
)
342 return self
.data
.markdown
or ""
348 # Strip off the first line if it contains a heading (as it will be shown separately)
349 for i
, line
in enumerate(self
.markdown
.splitlines()):
350 if i
== 0 and line
.startswith("#"):
355 return self
.backend
.wiki
.render(self
.page
, "\n".join(lines
))
359 return self
.data
.timestamp
361 def was_deleted(self
):
362 return not self
.markdown
365 def breadcrumbs(self
):
366 return self
.backend
.wiki
.make_breadcrumbs(self
.page
)
368 def is_latest_revision(self
):
369 return self
.get_latest_revision() == self
371 def get_latest_revision(self
):
372 revisions
= self
.get_revisions()
374 # Return first object
375 for rev
in revisions
:
378 def get_revisions(self
):
379 return self
.backend
.wiki
._get
_pages
("SELECT * FROM wiki \
380 WHERE page = %s ORDER BY timestamp DESC", self
.page
)
383 def previous_revision(self
):
384 return self
.backend
.wiki
._get
_page
("SELECT * FROM wiki \
385 WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \
386 LIMIT 1", self
.page
, self
.timestamp
)
390 return self
.data
.changes
394 def check_acl(self
, account
):
395 return self
.backend
.wiki
.check_acl(self
.page
, account
)
401 if self
.previous_revision
:
402 diff
= difflib
.unified_diff(
403 self
.previous_revision
.markdown
.splitlines(),
404 self
.markdown
.splitlines(),
407 return "\n".join(diff
)
411 res
= self
.db
.query("SELECT uid FROM wiki_watchlist \
412 WHERE page = %s", self
.page
)
415 # Search for account by UID and skip if none was found
416 account
= self
.backend
.accounts
.get_by_uid(row
.uid
)
423 def is_watched_by(self
, account
):
424 res
= self
.db
.get("SELECT 1 FROM wiki_watchlist \
425 WHERE page = %s AND uid = %s", self
.page
, account
.uid
)
432 def add_watcher(self
, account
):
433 if self
.is_watched_by(account
):
436 self
.db
.execute("INSERT INTO wiki_watchlist(page, uid) \
437 VALUES(%s, %s)", self
.page
, account
.uid
)
439 def remove_watcher(self
, account
):
440 self
.db
.execute("DELETE FROM wiki_watchlist \
441 WHERE page = %s AND uid = %s", self
.page
, account
.uid
)
443 def _send_watcher_emails(self
, excludes
=[]):
444 # Nothing to do if there was no previous revision
445 if not self
.previous_revision
:
448 for watcher
in self
.watchers
:
449 # Skip everyone who is excluded
450 if watcher
in excludes
:
451 logging
.debug("Excluding %s" % watcher
)
455 if not self
.backend
.wiki
.check_acl(self
.page
, watcher
):
456 logging
.debug("Watcher %s does not have permissions" % watcher
)
459 logging
.debug("Sending watcher email to %s" % watcher
)
462 self
.backend
.messages
.send_template("wiki/messages/page-changed",
463 account
=watcher
, page
=self
, priority
=-10)
465 def restore(self
, author
, address
, comment
=None):
466 changes
= "Restore to revision from %s" % self
.timestamp
.isoformat()
470 changes
= "%s: %s" % (changes
, comment
)
472 return self
.backend
.wiki
.create_page(self
.page
,
473 author
, self
.markdown
, changes
=changes
, address
=address
)
476 class File(misc
.Object
):
477 def init(self
, id, data
):
481 def __eq__(self
, other
):
482 if isinstance(other
, self
.__class
__):
483 return self
.id == other
.id
487 return "/docs%s" % os
.path
.join(self
.path
, self
.filename
)
491 return self
.data
.path
495 return self
.data
.filename
499 return self
.data
.mimetype
503 return self
.data
.size
507 if self
.data
.author_uid
:
508 return self
.backend
.accounts
.get_by_uid(self
.data
.author_uid
)
511 def created_at(self
):
512 return self
.data
.created_at
514 def delete(self
, author
=None):
515 self
.db
.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \
516 WHERE id = %s", author
.uid
if author
else None, self
.id)
519 def deleted_at(self
):
520 return self
.data
.deleted_at
522 def get_latest_revision(self
):
523 revisions
= self
.get_revisions()
525 # Return first object
526 for rev
in revisions
:
529 def get_revisions(self
):
530 revisions
= self
.backend
.wiki
._get
_files
("SELECT * FROM wiki_files \
531 WHERE path = %s AND filename = %s ORDER BY created_at DESC", self
.path
, self
.filename
)
533 return list(revisions
)
536 return self
.mimetype
in ("application/pdf", "application/x-pdf")
539 return self
.mimetype
.startswith("image/")
541 def is_vector_image(self
):
542 return self
.mimetype
in ("image/svg+xml",)
544 def is_bitmap_image(self
):
545 return self
.is_image() and not self
.is_vector_image()
549 res
= self
.db
.get("SELECT data FROM wiki_blobs \
550 WHERE id = %s", self
.data
.blob_id
)
553 return bytes(res
.data
)
555 async def get_thumbnail(self
, size
):
556 assert self
.is_bitmap_image()
558 cache_key
= "-".join((
560 util
.normalize(self
.filename
),
561 self
.created_at
.isoformat(),
565 # Try to fetch the data from the cache
566 thumbnail
= await self
.backend
.cache
.get(cache_key
)
570 # Generate the thumbnail
571 thumbnail
= util
.generate_thumbnail(self
.blob
, size
)
573 # Put it into the cache for forever
574 await self
.backend
.cache
.set(cache_key
, thumbnail
)
579 class PrettyLinksExtension(markdown
.extensions
.Extension
):
580 def extendMarkdown(self
, md
):
581 # Create links to Bugzilla
582 md
.preprocessors
.register(BugzillaLinksPreprocessor(md
), "bugzilla", 10)
584 # Create links to CVE
585 md
.preprocessors
.register(CVELinksPreprocessor(md
), "cve", 10)
588 class BugzillaLinksPreprocessor(markdown
.preprocessors
.Preprocessor
):
589 regex
= re
.compile(r
"(?:#(\d{5,}))", re
.I
)
591 def run(self
, lines
):
593 yield self
.regex
.sub(r
"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line
)
596 class CVELinksPreprocessor(markdown
.preprocessors
.Preprocessor
):
597 regex
= re
.compile(r
"(?:CVE)[\s\-](\d{4}\-\d+)")
599 def run(self
, lines
):
601 yield self
.regex
.sub(r
"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line
)
604 class WikiRenderer(misc
.Object
):
617 links
= re
.compile(r
"<a href=\"(.*?
)\">(.*?
)</a
>")
620 images = re.compile(r"<img
alt(?
:=\"(.*?
)\")? src
=\"(.*?
)\" (?
:title
=\"(.*?
)\" )?
/>")
623 renderer = markdown.Markdown(
625 PrettyLinksExtension(),
636 def init(self, path):
639 def _render_link(self, m):
640 url, text = m.groups()
643 for schema in self.schemas:
644 if url.startswith(schema):
645 return """<a class="link
-external
" href="%s">%s</a>""" % \
651 if url.startswith("mailto
:"):
654 return """<a class="link
-external
" href="mailto
:%s">%s</a>""" % \
657 # Everything else must be an internal link
658 path = self.backend.wiki.make_path(self.path, url)
660 return """<a href="/docs
%s">%s</a>""" % \
661 (path, text or self.backend.wiki.get_page_title(path))
663 def _render_image(self, m):
664 alt_text, url, caption = m.groups()
666 # Compute a hash over the URL
667 h = hashlib.new("md5
")
668 h.update(url.encode())
672 <div class="columns
is-centered
">
673 <div class="column
is-8">
674 <figure class="image modal
-trigger
" data-target="%(id)s">
675 <img src="/docs
%(url)s" alt="%(caption)s">
677 <figcaption class="figure
-caption
">%(caption)s</figcaption>
680 <div class="modal
is-large
" id="%(id)s">
681 <div class="modal
-background
"></div>
683 <div class="modal
-content
">
685 <img src="/docs
%(plain_url)s?s
=1920" alt="%(caption)s"
690 <button class="modal
-close
is-large
" aria-label="close
"></button>
696 # Skip any absolute and external URLs
697 if url.startswith("/") or url.startswith("https
://") or url.startswith("http
://"):
699 "caption
" : caption or "",
705 # Try to split query string
706 url, delimiter, qs = url.partition("?
")
708 # Parse query arguments
709 args = urllib.parse.parse_qs(qs)
711 # Build absolute path
712 plain_url = url = self.backend.wiki.make_path(self.path, url)
715 file = self.backend.wiki.get_file_by_path(url)
716 if not file or not file.is_image():
717 return "<!-- Could
not find image
%s in %s -->" % (url, self.path)
719 # Scale down the image if not already done
723 # Append arguments to the URL
725 url = "%s?
%s" % (url, urllib.parse.urlencode(args))
728 "caption
" : caption or "",
730 "plain_url
" : plain_url,
734 def render(self, text):
735 logging.debug("Rendering
%s" % self.path)
738 text = self.renderer.convert(text)
741 text = self.links.sub(self._render_link, text)
743 # Postprocess images to <figure>
744 text = self.images.sub(self._render_image, text)