]>
git.ipfire.org Git - ipfire.org.git/blob - src/backend/wiki.py
11 from .decorators
import *
13 class Wiki(misc
.Object
):
14 def _get_pages(self
, query
, *args
):
15 res
= self
.db
.query(query
, *args
)
18 yield Page(self
.backend
, row
.id, data
=row
)
20 def _get_page(self
, query
, *args
):
21 res
= self
.db
.get(query
, *args
)
24 return Page(self
.backend
, res
.id, data
=res
)
27 return self
._get
_pages
(
28 "SELECT wiki.* FROM wiki_current current \
29 LEFT JOIN wiki ON current.id = wiki.id \
30 WHERE current.deleted IS FALSE \
34 def make_path(self
, page
, path
):
35 # Nothing to do for absolute links
36 if path
.startswith("/"):
39 # Relative links (one-level down)
40 elif path
.startswith("./"):
41 path
= os
.path
.join(page
, path
)
43 # All other relative links
45 p
= os
.path
.dirname(page
)
46 path
= os
.path
.join(p
, path
)
49 return os
.path
.normpath(path
)
51 def page_exists(self
, path
):
52 page
= self
.get_page(path
)
54 # Page must have been found and not deleted
55 return page
and not page
.was_deleted()
57 def get_page_title(self
, page
, default
=None):
58 # Try to retrieve title from cache
59 title
= self
.memcache
.get("wiki:title:%s" % page
)
63 # If the title has not been in the cache, we will
65 doc
= self
.get_page(page
)
69 title
= os
.path
.basename(page
)
71 # Save in cache for forever
72 self
.memcache
.set("wiki:title:%s" % page
, title
)
76 def get_page(self
, page
, revision
=None):
77 page
= Page
.sanitise_page_name(page
)
79 # Split the path into parts
80 parts
= page
.split("/")
82 # Check if this is an action
83 if any((part
.startswith("_") for part
in parts
)):
87 return self
._get
_page
("SELECT * FROM wiki WHERE page = %s \
88 AND timestamp = %s", page
, revision
)
90 return self
._get
_page
("SELECT * FROM wiki WHERE page = %s \
91 ORDER BY timestamp DESC LIMIT 1", page
)
93 def get_recent_changes(self
, account
, limit
=None):
94 pages
= self
._get
_pages
("SELECT * FROM wiki \
95 ORDER BY timestamp DESC")
98 if not page
.check_acl(account
):
107 def create_page(self
, page
, author
, content
, changes
=None, address
=None):
108 page
= Page
.sanitise_page_name(page
)
110 # Write page to the database
111 page
= self
._get
_page
("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
112 VALUES(%s, %s, %s, %s, %s) RETURNING *", page
, author
.uid
, content
or None, changes
, address
)
115 self
.memcache
.set("wiki:title:%s" % page
.page
, page
.title
)
117 # Send email to all watchers
118 page
._send
_watcher
_emails
(excludes
=[author
])
122 def delete_page(self
, page
, author
, **kwargs
):
123 # Do nothing if the page does not exist
124 if not self
.get_page(page
):
127 # Just creates a blank last version of the page
128 self
.create_page(page
, author
=author
, content
=None, **kwargs
)
130 def make_breadcrumbs(self
, url
):
131 # Split and strip all empty elements (double slashes)
132 parts
= list(e
for e
in url
.split("/") if e
)
135 for part
in ("/".join(parts
[:i
]) for i
in range(1, len(parts
))):
136 ret
.append(("/%s" % part
, self
.get_page_title(part
, os
.path
.basename(part
))))
140 def search(self
, query
, account
=None, limit
=None):
141 res
= self
._get
_pages
("SELECT wiki.* FROM wiki_search_index search_index \
142 LEFT JOIN wiki ON search_index.wiki_id = wiki.id \
143 WHERE search_index.document @@ websearch_to_tsquery('english', %s) \
144 ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC",
149 # Skip any pages the user doesn't have permission for
150 if not page
.check_acl(account
):
153 # Return any other pages
156 # Break when we have found enough pages
157 if limit
and len(pages
) >= limit
:
164 Needs to be called after a page has been changed
166 self
.db
.execute("REFRESH MATERIALIZED VIEW wiki_search_index")
168 def get_watchlist(self
, account
):
169 pages
= self
._get
_pages
("""
176 wiki ON wiki_current.id = wiki.id
182 wiki_watchlist watchlist
184 pages ON watchlist.page = pages.page
194 def check_acl(self
, page
, account
):
195 res
= self
.db
.query("SELECT * FROM wiki_acls \
196 WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page
)
199 # Access not permitted when user is not logged in
203 # If user is in a matching group, we grant permission
204 for group
in row
.groups
:
205 if account
.is_member_of_group(group
):
208 # Otherwise access is not permitted
211 # If no ACLs are found, we permit access
216 def _get_files(self
, query
, *args
):
217 res
= self
.db
.query(query
, *args
)
220 yield File(self
.backend
, row
.id, data
=row
)
222 def _get_file(self
, query
, *args
):
223 res
= self
.db
.get(query
, *args
)
226 return File(self
.backend
, res
.id, data
=res
)
228 def get_files(self
, path
):
229 files
= self
._get
_files
("SELECT * FROM wiki_files \
230 WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path
)
234 def get_file_by_path(self
, path
, revision
=None):
235 path
, filename
= os
.path
.dirname(path
), os
.path
.basename(path
)
238 # Fetch a specific revision
239 return self
._get
_file
("SELECT * FROM wiki_files \
240 WHERE path = %s AND filename = %s AND created_at <= %s \
241 ORDER BY created_at DESC LIMIT 1", path
, filename
, revision
)
243 # Fetch latest version
244 return self
._get
_file
("SELECT * FROM wiki_files \
245 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
248 def get_file_by_path_and_filename(self
, path
, filename
):
249 return self
._get
_file
("SELECT * FROM wiki_files \
250 WHERE path = %s AND filename = %s AND deleted_at IS NULL",
253 def upload(self
, path
, filename
, data
, mimetype
, author
, address
):
254 # Replace any existing files
255 file = self
.get_file_by_path_and_filename(path
, filename
)
259 # Upload the blob first
260 blob
= self
.db
.get("INSERT INTO wiki_blobs(data) VALUES(%s) \
261 ON CONFLICT (digest(data, %s)) DO UPDATE SET data = EXCLUDED.data \
262 RETURNING id", data
, "MD5")
264 # Create entry for file
265 return self
._get
_file
("INSERT INTO wiki_files(path, filename, author_uid, address, \
266 mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path
,
267 filename
, author
.uid
, address
, mimetype
, blob
.id, len(data
))
269 def render(self
, path
, text
):
270 r
= WikiRenderer(self
.backend
, path
)
272 return r
.render(text
)
275 class Page(misc
.Object
):
276 def init(self
, id, data
=None):
281 return "<%s %s %s>" % (self
.__class
__.__name
__, self
.page
, self
.timestamp
)
283 def __eq__(self
, other
):
284 if isinstance(other
, self
.__class
__):
285 return self
.id == other
.id
287 return NotImplemented
289 def __lt__(self
, other
):
290 if isinstance(other
, self
.__class
__):
291 if self
.page
== other
.page
:
292 return self
.timestamp
< other
.timestamp
294 return self
.page
< other
.page
296 return NotImplemented
299 def sanitise_page_name(page
):
303 # Make sure that the page name does NOT end with a /
304 if page
.endswith("/"):
307 # Make sure the page name starts with a /
308 if not page
.startswith("/"):
311 # Remove any double slashes
312 page
= page
.replace("//", "/")
318 return "/docs%s" % self
.page
322 return "https://www.ipfire.org%s" % self
.url
326 return self
.data
.page
330 return self
._title
or os
.path
.basename(self
.page
[1:])
334 if not self
.markdown
:
337 # Find first H1 headline in markdown
338 markdown
= self
.markdown
.splitlines()
340 m
= re
.match(r
"^#\s*(.*)( #)?$", markdown
[0])
346 if self
.data
.author_uid
:
347 return self
.backend
.accounts
.get_by_uid(self
.data
.author_uid
)
351 return self
.data
.markdown
or ""
357 # Strip off the first line if it contains a heading (as it will be shown separately)
358 for i
, line
in enumerate(self
.markdown
.splitlines()):
359 if i
== 0 and line
.startswith("#"):
364 return self
.backend
.wiki
.render(self
.page
, "\n".join(lines
))
368 return self
.data
.timestamp
370 def was_deleted(self
):
371 return not self
.markdown
374 def breadcrumbs(self
):
375 return self
.backend
.wiki
.make_breadcrumbs(self
.page
)
377 def is_latest_revision(self
):
378 return self
.get_latest_revision() == self
380 def get_latest_revision(self
):
381 revisions
= self
.get_revisions()
383 # Return first object
384 for rev
in revisions
:
387 def get_revisions(self
):
388 return self
.backend
.wiki
._get
_pages
("SELECT * FROM wiki \
389 WHERE page = %s ORDER BY timestamp DESC", self
.page
)
392 def previous_revision(self
):
393 return self
.backend
.wiki
._get
_page
("SELECT * FROM wiki \
394 WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \
395 LIMIT 1", self
.page
, self
.timestamp
)
399 return self
.data
.changes
403 def check_acl(self
, account
):
404 return self
.backend
.wiki
.check_acl(self
.page
, account
)
410 if self
.previous_revision
:
411 diff
= difflib
.unified_diff(
412 self
.previous_revision
.markdown
.splitlines(),
413 self
.markdown
.splitlines(),
416 return "\n".join(diff
)
420 res
= self
.db
.query("SELECT uid FROM wiki_watchlist \
421 WHERE page = %s", self
.page
)
424 # Search for account by UID and skip if none was found
425 account
= self
.backend
.accounts
.get_by_uid(row
.uid
)
432 def is_watched_by(self
, account
):
433 res
= self
.db
.get("SELECT 1 FROM wiki_watchlist \
434 WHERE page = %s AND uid = %s", self
.page
, account
.uid
)
441 def add_watcher(self
, account
):
442 if self
.is_watched_by(account
):
445 self
.db
.execute("INSERT INTO wiki_watchlist(page, uid) \
446 VALUES(%s, %s)", self
.page
, account
.uid
)
448 def remove_watcher(self
, account
):
449 self
.db
.execute("DELETE FROM wiki_watchlist \
450 WHERE page = %s AND uid = %s", self
.page
, account
.uid
)
452 def _send_watcher_emails(self
, excludes
=[]):
453 # Nothing to do if there was no previous revision
454 if not self
.previous_revision
:
457 for watcher
in self
.watchers
:
458 # Skip everyone who is excluded
459 if watcher
in excludes
:
460 logging
.debug("Excluding %s" % watcher
)
464 if not self
.backend
.wiki
.check_acl(self
.page
, watcher
):
465 logging
.debug("Watcher %s does not have permissions" % watcher
)
468 logging
.debug("Sending watcher email to %s" % watcher
)
471 self
.backend
.messages
.send_template("wiki/messages/page-changed",
472 account
=watcher
, page
=self
, priority
=-10)
474 def restore(self
, author
, address
, comment
=None):
475 changes
= "Restore to revision from %s" % self
.timestamp
.isoformat()
479 changes
= "%s: %s" % (changes
, comment
)
481 return self
.backend
.wiki
.create_page(self
.page
,
482 author
, self
.markdown
, changes
=changes
, address
=address
)
485 class File(misc
.Object
):
486 def init(self
, id, data
):
490 def __eq__(self
, other
):
491 if isinstance(other
, self
.__class
__):
492 return self
.id == other
.id
496 return os
.path
.join(self
.path
, self
.filename
)
500 return self
.data
.path
504 return self
.data
.filename
508 return self
.data
.mimetype
512 return self
.data
.size
516 if self
.data
.author_uid
:
517 return self
.backend
.accounts
.get_by_uid(self
.data
.author_uid
)
520 def created_at(self
):
521 return self
.data
.created_at
523 def delete(self
, author
=None):
524 self
.db
.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \
525 WHERE id = %s", author
.uid
if author
else None, self
.id)
528 def deleted_at(self
):
529 return self
.data
.deleted_at
531 def get_latest_revision(self
):
532 revisions
= self
.get_revisions()
534 # Return first object
535 for rev
in revisions
:
538 def get_revisions(self
):
539 revisions
= self
.backend
.wiki
._get
_files
("SELECT * FROM wiki_files \
540 WHERE path = %s AND filename = %s ORDER BY created_at DESC", self
.path
, self
.filename
)
542 return list(revisions
)
545 return self
.mimetype
in ("application/pdf", "application/x-pdf")
548 return self
.mimetype
.startswith("image/")
550 def is_vector_image(self
):
551 return self
.mimetype
in ("image/svg+xml",)
553 def is_bitmap_image(self
):
554 return self
.is_image() and not self
.is_vector_image()
558 res
= self
.db
.get("SELECT data FROM wiki_blobs \
559 WHERE id = %s", self
.data
.blob_id
)
562 return bytes(res
.data
)
564 def get_thumbnail(self
, size
):
565 assert self
.is_bitmap_image()
567 cache_key
= "-".join((self
.path
, util
.normalize(self
.filename
), self
.created_at
.isoformat(), "%spx" % size
))
569 # Try to fetch the data from the cache
570 thumbnail
= self
.memcache
.get(cache_key
)
574 # Generate the thumbnail
575 thumbnail
= util
.generate_thumbnail(self
.blob
, size
)
577 # Put it into the cache for forever
578 self
.memcache
.set(cache_key
, thumbnail
)
583 class WikiRenderer(misc
.Object
):
596 links
= re
.compile(r
"<a href=\"(.*?
)\">(.*?
)</a
>")
599 images = re.compile(r"<img
alt(?
:=\"(.*?
)\")? src
=\"(.*?
)\" (?
:title
=\"(.*?
)\" )?
/>")
601 def init(self, path):
604 def _render_link(self, m):
605 url, text = m.groups()
608 for schema in self.schemas:
609 if url.startswith(schema):
610 return """<a class="link
-external
" href="%s">%s</a>""" % \
616 if url.startswith("mailto
:"):
619 return """<a class="link
-external
" href="mailto
:%s">%s</a>""" % \
622 # Everything else must be an internal link
623 path = self.backend.wiki.make_path(self.path, url)
625 return """<a href="/docs
%s">%s</a>""" % \
626 (path, text or self.backend.wiki.get_page_title(path))
628 def _render_image(self, m):
629 alt_text, url, caption = m.groups()
632 <div class="columns
is-centered
">
633 <div class="column
is-8">
634 <figure class="image
">
635 <img src="/docs
%s" alt="%s">
636 <figcaption class="figure
-caption
">%s</figcaption>
642 # Skip any absolute and external URLs
643 if url.startswith("/") or url.startswith("https
://") or url.startswith("http
://"):
644 return html % (url, alt_text, caption or "")
646 # Try to split query string
647 url, delimiter, qs = url.partition("?
")
649 # Parse query arguments
650 args = urllib.parse.parse_qs(qs)
652 # Build absolute path
653 url = self.backend.wiki.make_path(self.path, url)
656 file = self.backend.wiki.get_file_by_path(url)
657 if not file or not file.is_image():
658 return "<!-- Could
not find image
%s in %s -->" % (url, self.path)
660 # Scale down the image if not already done
664 # Append arguments to the URL
666 url = "%s?
%s" % (url, urllib.parse.urlencode(args))
668 return html % (url, caption, caption or "")
670 def render(self, text):
671 logging.debug("Rendering
%s" % self.path)
673 # Borrow this from the blog
674 text = self.backend.blog._render_text(text, lang="markdown
")
677 text = self.links.sub(self._render_link, text)
679 # Postprocess images to <figure>
680 text = self.images.sub(self._render_image, text)