]>
Commit | Line | Data |
---|---|---|
181d08f3 MT |
1 | #!/usr/bin/python3 |
2 | ||
4ed1dadb | 3 | import difflib |
181d08f3 | 4 | import logging |
6ac7e934 | 5 | import os.path |
181d08f3 | 6 | import re |
addc18d5 | 7 | import tornado.gen |
9e90e800 | 8 | import urllib.parse |
181d08f3 MT |
9 | |
10 | from . import misc | |
9523790a | 11 | from . import util |
181d08f3 MT |
12 | from .decorators import * |
13 | ||
c683a1ff MT |
14 | INTERWIKIS = { |
15 | "google" : ("https://www.google.com/search?q=%(url)s", None, "fab fa-google"), | |
16 | "rfc" : ("https://tools.ietf.org/html/rfc%(name)s", "RFC %s", None), | |
17 | "wp" : ("https://en.wikipedia.org/wiki/%(name)s", None, "fab fa-wikipedia-w"), | |
18 | } | |
19 | ||
181d08f3 MT |
20 | class Wiki(misc.Object): |
21 | def _get_pages(self, query, *args): | |
22 | res = self.db.query(query, *args) | |
23 | ||
24 | for row in res: | |
25 | yield Page(self.backend, row.id, data=row) | |
26 | ||
d398ca08 MT |
27 | def _get_page(self, query, *args): |
28 | res = self.db.get(query, *args) | |
29 | ||
30 | if res: | |
31 | return Page(self.backend, res.id, data=res) | |
32 | ||
6ac7e934 | 33 | def get_page_title(self, page, default=None): |
50c8dc11 MT |
34 | # Try to retrieve title from cache |
35 | title = self.memcache.get("wiki:title:%s" % page) | |
36 | if title: | |
37 | return title | |
38 | ||
39 | # If the title has not been in the cache, we will | |
40 | # have to look it up | |
6ac7e934 MT |
41 | doc = self.get_page(page) |
42 | if doc: | |
50c8dc11 MT |
43 | title = doc.title |
44 | else: | |
45 | title = os.path.basename(page) | |
6ac7e934 | 46 | |
50c8dc11 MT |
47 | # Save in cache for forever |
48 | self.memcache.set("wiki:title:%s" % page, title) | |
49 | ||
50 | return title | |
6ac7e934 | 51 | |
181d08f3 MT |
52 | def get_page(self, page, revision=None): |
53 | page = Page.sanitise_page_name(page) | |
54 | assert page | |
55 | ||
56 | if revision: | |
d398ca08 | 57 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ |
181d08f3 MT |
58 | AND timestamp = %s", page, revision) |
59 | else: | |
d398ca08 | 60 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ |
181d08f3 MT |
61 | ORDER BY timestamp DESC LIMIT 1", page) |
62 | ||
11afe905 MT |
63 | def get_recent_changes(self, account, limit=None): |
64 | pages = self._get_pages("SELECT * FROM wiki \ | |
11afe905 MT |
65 | ORDER BY timestamp DESC") |
66 | ||
67 | for page in pages: | |
68 | if not page.check_acl(account): | |
69 | continue | |
70 | ||
71 | yield page | |
72 | ||
73 | limit -= 1 | |
74 | if not limit: | |
75 | break | |
181d08f3 | 76 | |
495e9dc4 | 77 | def create_page(self, page, author, content, changes=None, address=None): |
181d08f3 MT |
78 | page = Page.sanitise_page_name(page) |
79 | ||
aba5e58a MT |
80 | # Write page to the database |
81 | page = self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \ | |
df01767e | 82 | VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address) |
181d08f3 | 83 | |
50c8dc11 | 84 | # Update cache |
980e486d | 85 | self.memcache.set("wiki:title:%s" % page.page, page.title) |
50c8dc11 | 86 | |
aba5e58a MT |
87 | # Send email to all watchers |
88 | page._send_watcher_emails(excludes=[author]) | |
89 | ||
90 | return page | |
91 | ||
495e9dc4 | 92 | def delete_page(self, page, author, **kwargs): |
181d08f3 MT |
93 | # Do nothing if the page does not exist |
94 | if not self.get_page(page): | |
95 | return | |
96 | ||
97 | # Just creates a blank last version of the page | |
495e9dc4 | 98 | self.create_page(page, author=author, content=None, **kwargs) |
181d08f3 | 99 | |
3168788e MT |
100 | def make_breadcrumbs(self, url): |
101 | # Split and strip all empty elements (double slashes) | |
181d08f3 MT |
102 | parts = list(e for e in url.split("/") if e) |
103 | ||
3168788e | 104 | ret = [] |
b1bf7d48 | 105 | for part in ("/".join(parts[:i]) for i in range(1, len(parts))): |
3168788e | 106 | ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part)))) |
181d08f3 | 107 | |
3168788e | 108 | return ret |
181d08f3 | 109 | |
11afe905 | 110 | def search(self, query, account=None, limit=None): |
9523790a MT |
111 | query = util.parse_search_query(query) |
112 | ||
113 | res = self._get_pages("SELECT wiki.* FROM wiki_search_index search_index \ | |
114 | LEFT JOIN wiki ON search_index.wiki_id = wiki.id \ | |
115 | WHERE search_index.document @@ to_tsquery('english', %s) \ | |
11afe905 MT |
116 | ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC", |
117 | query, query) | |
9523790a | 118 | |
df80be2c | 119 | pages = [] |
11afe905 MT |
120 | for page in res: |
121 | # Skip any pages the user doesn't have permission for | |
122 | if not page.check_acl(account): | |
123 | continue | |
124 | ||
125 | # Return any other pages | |
df80be2c | 126 | pages.append(page) |
11afe905 | 127 | |
df80be2c MT |
128 | # Break when we have found enough pages |
129 | if limit and len(pages) >= limit: | |
11afe905 | 130 | break |
9523790a | 131 | |
df80be2c MT |
132 | return pages |
133 | ||
9523790a MT |
134 | def refresh(self): |
135 | """ | |
136 | Needs to be called after a page has been changed | |
137 | """ | |
138 | self.db.execute("REFRESH MATERIALIZED VIEW wiki_search_index") | |
139 | ||
2f23c558 MT |
140 | def get_watchlist(self, account): |
141 | pages = self._get_pages( | |
142 | "WITH pages AS (SELECT * FROM wiki_current \ | |
143 | LEFT JOIN wiki ON wiki_current.id = wiki.id) \ | |
144 | SELECT * FROM wiki_watchlist watchlist \ | |
145 | LEFT JOIN pages ON watchlist.page = pages.page \ | |
146 | WHERE watchlist.uid = %s", | |
147 | account.uid, | |
148 | ) | |
149 | ||
150 | return sorted(pages) | |
151 | ||
11afe905 MT |
152 | # ACL |
153 | ||
154 | def check_acl(self, page, account): | |
155 | res = self.db.query("SELECT * FROM wiki_acls \ | |
156 | WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page) | |
157 | ||
158 | for row in res: | |
159 | # Access not permitted when user is not logged in | |
160 | if not account: | |
161 | return False | |
162 | ||
163 | # If user is in a matching group, we grant permission | |
164 | for group in row.groups: | |
165 | if group in account.groups: | |
166 | return True | |
167 | ||
168 | # Otherwise access is not permitted | |
169 | return False | |
170 | ||
171 | # If no ACLs are found, we permit access | |
172 | return True | |
173 | ||
f2cfd873 MT |
174 | # Files |
175 | ||
176 | def _get_files(self, query, *args): | |
177 | res = self.db.query(query, *args) | |
178 | ||
179 | for row in res: | |
180 | yield File(self.backend, row.id, data=row) | |
181 | ||
182 | def _get_file(self, query, *args): | |
183 | res = self.db.get(query, *args) | |
184 | ||
185 | if res: | |
186 | return File(self.backend, res.id, data=res) | |
187 | ||
188 | def get_files(self, path): | |
189 | files = self._get_files("SELECT * FROM wiki_files \ | |
190 | WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path) | |
191 | ||
192 | return list(files) | |
193 | ||
194 | def get_file_by_path(self, path): | |
195 | path, filename = os.path.dirname(path), os.path.basename(path) | |
196 | ||
197 | return self._get_file("SELECT * FROM wiki_files \ | |
198 | WHERE path = %s AND filename = %s AND deleted_at IS NULL", path, filename) | |
199 | ||
200 | def upload(self, path, filename, data, mimetype, author, address): | |
201 | # Upload the blob first | |
202 | blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) RETURNING id", data) | |
203 | ||
204 | # Create entry for file | |
205 | return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \ | |
206 | mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path, | |
207 | filename, author.uid, address, mimetype, blob.id, len(data)) | |
208 | ||
9e90e800 MT |
209 | def find_image(self, path, filename): |
210 | for p in (path, os.path.dirname(path)): | |
211 | file = self.get_file_by_path(os.path.join(p, filename)) | |
212 | ||
213 | if file and file.is_image(): | |
214 | return file | |
215 | ||
181d08f3 MT |
216 | |
217 | class Page(misc.Object): | |
db1e727e MT |
218 | # Wiki links |
219 | wiki_link = re.compile(r"\[\[([\w\d\/\-\.]+)(?:\|(.+?))?\]\]") | |
220 | ||
0c9bada3 | 221 | # External links |
6d12be2d | 222 | external_link = re.compile(r"\[\[((?:ftp|git|https?|rsync|sftp|ssh|webcal)\:\/\/.+?)(?:\|(.+?))?\]\]") |
0c9bada3 | 223 | |
e2205cff MT |
224 | # Interwiki links e.g. [[wp>IPFire]] |
225 | interwiki_link = re.compile(r"\[\[(\w+)>(.+?)(?:\|(.+?))?\]\]") | |
226 | ||
154f6179 MT |
227 | # Mail link |
228 | email_link = re.compile(r"\[\[([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)(?:\|(.+?))?\]\]") | |
229 | ||
181d08f3 MT |
230 | def init(self, id, data=None): |
231 | self.id = id | |
232 | self.data = data | |
233 | ||
dc847af5 MT |
234 | def __repr__(self): |
235 | return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp) | |
236 | ||
c21ffadb MT |
237 | def __eq__(self, other): |
238 | if isinstance(other, self.__class__): | |
239 | return self.id == other.id | |
240 | ||
181d08f3 MT |
241 | def __lt__(self, other): |
242 | if isinstance(other, self.__class__): | |
243 | if self.page == other.page: | |
244 | return self.timestamp < other.timestamp | |
245 | ||
246 | return self.page < other.page | |
247 | ||
248 | @staticmethod | |
249 | def sanitise_page_name(page): | |
250 | if not page: | |
251 | return "/" | |
252 | ||
253 | # Make sure that the page name does NOT end with a / | |
254 | if page.endswith("/"): | |
255 | page = page[:-1] | |
256 | ||
257 | # Make sure the page name starts with a / | |
258 | if not page.startswith("/"): | |
259 | page = "/%s" % page | |
260 | ||
261 | # Remove any double slashes | |
262 | page = page.replace("//", "/") | |
263 | ||
264 | return page | |
265 | ||
266 | @property | |
267 | def url(self): | |
db8448d9 | 268 | return self.page |
181d08f3 | 269 | |
4ed1dadb MT |
270 | @property |
271 | def full_url(self): | |
272 | return "https://wiki.ipfire.org%s" % self.url | |
273 | ||
181d08f3 MT |
274 | @property |
275 | def page(self): | |
276 | return self.data.page | |
277 | ||
278 | @property | |
279 | def title(self): | |
51e7a876 | 280 | return self._title or os.path.basename(self.page[1:]) |
181d08f3 MT |
281 | |
282 | @property | |
283 | def _title(self): | |
284 | if not self.markdown: | |
285 | return | |
286 | ||
287 | # Find first H1 headline in markdown | |
288 | markdown = self.markdown.splitlines() | |
289 | ||
290 | m = re.match(r"^# (.*)( #)?$", markdown[0]) | |
291 | if m: | |
292 | return m.group(1) | |
293 | ||
3b05ef6e MT |
294 | @lazy_property |
295 | def author(self): | |
296 | if self.data.author_uid: | |
297 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
298 | ||
db1e727e MT |
299 | def _render_wiki_link(self, m): |
300 | path, alias = m.groups() | |
301 | ||
302 | # Allow relative links | |
303 | if not path.startswith("/"): | |
304 | path = os.path.join(self.page, path) | |
305 | ||
306 | # Normalise links | |
307 | path = os.path.normpath(path) | |
308 | ||
309 | return """<a href="%s">%s</a>""" % ( | |
310 | path, | |
311 | alias or self.backend.wiki.get_page_title(path), | |
312 | ) | |
313 | ||
0c9bada3 MT |
314 | def _render_external_link(self, m): |
315 | url, alias = m.groups() | |
316 | ||
317 | return """<a class="link-external" href="%s">%s</a>""" % (url, alias or url) | |
318 | ||
c683a1ff MT |
319 | def _render_interwiki_link(self, m): |
320 | wiki = m.group(1) | |
321 | if not wiki: | |
322 | return | |
323 | ||
324 | # Retrieve URL | |
325 | try: | |
326 | url, repl, icon = INTERWIKIS[wiki] | |
327 | except KeyError: | |
328 | logging.warning("Invalid interwiki: %s" % wiki) | |
329 | return | |
330 | ||
331 | # Name of the page | |
332 | name = m.group(2) | |
333 | ||
334 | # Expand URL | |
335 | url = url % { | |
336 | "name" : name, | |
337 | "url" : urllib.parse.quote(name), | |
338 | } | |
339 | ||
340 | # Get alias (if present) | |
341 | alias = m.group(3) | |
342 | ||
343 | if not alias and repl: | |
344 | alias = repl % name | |
345 | ||
346 | # Put everything together | |
347 | s = [] | |
348 | ||
349 | if icon: | |
350 | s.append("<span class=\"%s\"></span>" % icon) | |
351 | ||
352 | s.append("""<a class="link-external" href="%s">%s</a>""" % (url, alias or name)) | |
353 | ||
354 | return " ".join(s) | |
355 | ||
154f6179 MT |
356 | def _render_email_link(self, m): |
357 | address, alias = m.groups() | |
358 | ||
359 | return """<a class="link-external" href="mailto:%s">%s</a>""" \ | |
360 | % (address, alias or address) | |
361 | ||
181d08f3 MT |
362 | def _render(self, text): |
363 | logging.debug("Rendering %s" % self) | |
364 | ||
9e90e800 MT |
365 | # Link images |
366 | replacements = [] | |
df6dd1a3 | 367 | for match in re.finditer(r"!\[(.*?)\]\((.*?)\)", text): |
9e90e800 MT |
368 | alt_text, url = match.groups() |
369 | ||
370 | # Skip any absolute and external URLs | |
371 | if url.startswith("/") or url.startswith("https://") or url.startswith("http://"): | |
372 | continue | |
373 | ||
374 | # Try to split query string | |
375 | url, delimiter, qs = url.partition("?") | |
376 | ||
377 | # Parse query arguments | |
378 | args = urllib.parse.parse_qs(qs) | |
379 | ||
380 | # Find image | |
381 | file = self.backend.wiki.find_image(self.page, url) | |
382 | if not file: | |
383 | continue | |
384 | ||
385 | # Scale down the image if not already done | |
386 | if not "s" in args: | |
387 | args["s"] = "768" | |
388 | ||
389 | # Format URL | |
390 | url = "%s?%s" % (file.url, urllib.parse.urlencode(args)) | |
391 | ||
bf59e35d | 392 | replacements.append((match.span(), file, alt_text, url)) |
9e90e800 MT |
393 | |
394 | # Apply all replacements | |
bf59e35d MT |
395 | for (start, end), file, alt_text, url in reversed(replacements): |
396 | text = text[:start] + "[![%s](%s)](%s?action=detail)" % (alt_text, url, file.url) + text[end:] | |
9e90e800 | 397 | |
db1e727e MT |
398 | # Handle wiki links |
399 | text = self.wiki_link.sub(self._render_wiki_link, text) | |
400 | ||
c683a1ff | 401 | # Handle interwiki links |
e2205cff | 402 | text = self.interwiki_link.sub(self._render_interwiki_link, text) |
c683a1ff | 403 | |
0c9bada3 MT |
404 | # Handle external links |
405 | text = self.external_link.sub(self._render_external_link, text) | |
406 | ||
154f6179 MT |
407 | # Handle email links |
408 | text = self.email_link.sub(self._render_email_link, text) | |
409 | ||
045ea3db MT |
410 | # Borrow this from the blog |
411 | return self.backend.blog._render_text(text, lang="markdown") | |
181d08f3 MT |
412 | |
413 | @property | |
414 | def markdown(self): | |
c21ffadb | 415 | return self.data.markdown or "" |
181d08f3 MT |
416 | |
417 | @property | |
418 | def html(self): | |
31834b04 | 419 | return self._render(self.markdown) |
addc18d5 | 420 | |
181d08f3 MT |
421 | @property |
422 | def timestamp(self): | |
423 | return self.data.timestamp | |
424 | ||
425 | def was_deleted(self): | |
426 | return self.markdown is None | |
427 | ||
428 | @lazy_property | |
429 | def breadcrumbs(self): | |
430 | return self.backend.wiki.make_breadcrumbs(self.page) | |
431 | ||
432 | def get_latest_revision(self): | |
7d699684 MT |
433 | revisions = self.get_revisions() |
434 | ||
435 | # Return first object | |
436 | for rev in revisions: | |
437 | return rev | |
438 | ||
439 | def get_revisions(self): | |
440 | return self.backend.wiki._get_pages("SELECT * FROM wiki \ | |
441 | WHERE page = %s ORDER BY timestamp DESC", self.page) | |
091ac36b | 442 | |
c21ffadb MT |
443 | @lazy_property |
444 | def previous_revision(self): | |
445 | return self.backend.wiki._get_page("SELECT * FROM wiki \ | |
446 | WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \ | |
447 | LIMIT 1", self.page, self.timestamp) | |
448 | ||
d398ca08 MT |
449 | @property |
450 | def changes(self): | |
451 | return self.data.changes | |
452 | ||
11afe905 MT |
453 | # ACL |
454 | ||
455 | def check_acl(self, account): | |
456 | return self.backend.wiki.check_acl(self.page, account) | |
457 | ||
091ac36b MT |
458 | # Sidebar |
459 | ||
460 | @lazy_property | |
461 | def sidebar(self): | |
462 | parts = self.page.split("/") | |
463 | ||
464 | while parts: | |
3cc5f666 | 465 | sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts)) |
091ac36b MT |
466 | if sidebar: |
467 | return sidebar | |
468 | ||
469 | parts.pop() | |
f2cfd873 | 470 | |
d64a1e35 MT |
471 | # Watchers |
472 | ||
4ed1dadb MT |
473 | @lazy_property |
474 | def diff(self): | |
475 | if self.previous_revision: | |
476 | diff = difflib.unified_diff( | |
477 | self.previous_revision.markdown.splitlines(), | |
478 | self.markdown.splitlines(), | |
479 | ) | |
480 | ||
481 | return "\n".join(diff) | |
482 | ||
aba5e58a MT |
483 | @property |
484 | def watchers(self): | |
485 | res = self.db.query("SELECT uid FROM wiki_watchlist \ | |
486 | WHERE page = %s", self.page) | |
487 | ||
488 | for row in res: | |
489 | # Search for account by UID and skip if none was found | |
490 | account = self.backend.accounts.get_by_uid(row.uid) | |
491 | if not account: | |
492 | continue | |
493 | ||
494 | # Return the account | |
495 | yield account | |
496 | ||
f2e25ded | 497 | def is_watched_by(self, account): |
d64a1e35 MT |
498 | res = self.db.get("SELECT 1 FROM wiki_watchlist \ |
499 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
500 | ||
501 | if res: | |
502 | return True | |
503 | ||
504 | return False | |
505 | ||
506 | def add_watcher(self, account): | |
f2e25ded | 507 | if self.is_watched_by(account): |
d64a1e35 MT |
508 | return |
509 | ||
510 | self.db.execute("INSERT INTO wiki_watchlist(page, uid) \ | |
511 | VALUES(%s, %s)", self.page, account.uid) | |
512 | ||
513 | def remove_watcher(self, account): | |
514 | self.db.execute("DELETE FROM wiki_watchlist \ | |
515 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
516 | ||
aba5e58a MT |
517 | def _send_watcher_emails(self, excludes=[]): |
518 | # Nothing to do if there was no previous revision | |
519 | if not self.previous_revision: | |
520 | return | |
521 | ||
522 | for watcher in self.watchers: | |
523 | # Skip everyone who is excluded | |
524 | if watcher in excludes: | |
525 | logging.debug("Excluding %s" % watcher) | |
526 | continue | |
527 | ||
528 | logging.debug("Sending watcher email to %s" % watcher) | |
529 | ||
4ed1dadb MT |
530 | # Compose message |
531 | self.backend.messages.send_template("wiki/messages/page-changed", | |
532 | recipients=[watcher], page=self, priority=-10) | |
aba5e58a | 533 | |
f2cfd873 MT |
534 | |
535 | class File(misc.Object): | |
536 | def init(self, id, data): | |
537 | self.id = id | |
538 | self.data = data | |
539 | ||
540 | @property | |
541 | def url(self): | |
542 | return os.path.join(self.path, self.filename) | |
543 | ||
544 | @property | |
545 | def path(self): | |
546 | return self.data.path | |
547 | ||
548 | @property | |
549 | def filename(self): | |
550 | return self.data.filename | |
551 | ||
552 | @property | |
553 | def mimetype(self): | |
554 | return self.data.mimetype | |
555 | ||
556 | @property | |
557 | def size(self): | |
558 | return self.data.size | |
559 | ||
8cb0bea4 MT |
560 | @lazy_property |
561 | def author(self): | |
562 | if self.data.author_uid: | |
563 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
564 | ||
565 | @property | |
566 | def created_at(self): | |
567 | return self.data.created_at | |
568 | ||
569 | def is_pdf(self): | |
570 | return self.mimetype in ("application/pdf", "application/x-pdf") | |
571 | ||
f2cfd873 MT |
572 | def is_image(self): |
573 | return self.mimetype.startswith("image/") | |
574 | ||
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 MT |
582 | |
583 | def get_thumbnail(self, size): | |
75d9b3da MT |
584 | cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size)) |
585 | ||
586 | # Try to fetch the data from the cache | |
587 | thumbnail = self.memcache.get(cache_key) | |
588 | if thumbnail: | |
589 | return thumbnail | |
590 | ||
591 | # Generate the thumbnail | |
5ef115cd | 592 | thumbnail = util.generate_thumbnail(self.blob, size) |
75d9b3da MT |
593 | |
594 | # Put it into the cache for forever | |
595 | self.memcache.set(cache_key, thumbnail) | |
596 | ||
597 | return thumbnail |