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