]>
Commit | Line | Data |
---|---|---|
181d08f3 MT |
1 | #!/usr/bin/python3 |
2 | ||
4ed1dadb | 3 | import difflib |
ef963ecb | 4 | import hashlib |
181d08f3 | 5 | import logging |
245a2e36 MT |
6 | import markdown |
7 | import markdown.extensions | |
8 | import markdown.preprocessors | |
6ac7e934 | 9 | import os.path |
181d08f3 | 10 | import re |
9e90e800 | 11 | import urllib.parse |
181d08f3 MT |
12 | |
13 | from . import misc | |
9523790a | 14 | from . import util |
181d08f3 MT |
15 | from .decorators import * |
16 | ||
181d08f3 MT |
17 | class Wiki(misc.Object): |
18 | def _get_pages(self, query, *args): | |
19 | res = self.db.query(query, *args) | |
20 | ||
21 | for row in res: | |
22 | yield Page(self.backend, row.id, data=row) | |
23 | ||
d398ca08 MT |
24 | def _get_page(self, query, *args): |
25 | res = self.db.get(query, *args) | |
26 | ||
27 | if res: | |
28 | return Page(self.backend, res.id, data=res) | |
29 | ||
86368c12 | 30 | def __iter__(self): |
53333caa MT |
31 | return self._get_pages(""" |
32 | SELECT | |
33 | wiki.* | |
34 | FROM | |
35 | wiki_current current | |
36 | LEFT JOIN | |
37 | wiki ON current.id = wiki.id | |
38 | WHERE | |
39 | current.deleted IS FALSE | |
40 | ORDER BY page | |
41 | """, | |
86368c12 MT |
42 | ) |
43 | ||
c78ad26e MT |
44 | def make_path(self, page, path): |
45 | # Nothing to do for absolute links | |
46 | if path.startswith("/"): | |
47 | pass | |
48 | ||
49 | # Relative links (one-level down) | |
50 | elif path.startswith("./"): | |
51 | path = os.path.join(page, path) | |
52 | ||
53 | # All other relative links | |
54 | else: | |
55 | p = os.path.dirname(page) | |
56 | path = os.path.join(p, path) | |
57 | ||
58 | # Normalise links | |
59 | return os.path.normpath(path) | |
60 | ||
0c1186ef MT |
61 | def _make_url(self, path): |
62 | """ | |
63 | Composes the URL out of the path | |
64 | """ | |
65 | # Remove any leading slashes (if present) | |
66 | path = path.removeprefix("/") | |
67 | ||
68 | return os.path.join("/docs", path) | |
69 | ||
6ac7e934 MT |
70 | def get_page_title(self, page, default=None): |
71 | doc = self.get_page(page) | |
72 | if doc: | |
50c8dc11 MT |
73 | title = doc.title |
74 | else: | |
75 | title = os.path.basename(page) | |
6ac7e934 | 76 | |
50c8dc11 | 77 | return title |
6ac7e934 | 78 | |
181d08f3 MT |
79 | def get_page(self, page, revision=None): |
80 | page = Page.sanitise_page_name(page) | |
947224b4 MT |
81 | |
82 | # Split the path into parts | |
83 | parts = page.split("/") | |
84 | ||
85 | # Check if this is an action | |
86 | if any((part.startswith("_") for part in parts)): | |
87 | return | |
181d08f3 MT |
88 | |
89 | if revision: | |
d398ca08 | 90 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ |
181d08f3 MT |
91 | AND timestamp = %s", page, revision) |
92 | else: | |
d398ca08 | 93 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ |
181d08f3 MT |
94 | ORDER BY timestamp DESC LIMIT 1", page) |
95 | ||
11afe905 MT |
96 | def get_recent_changes(self, account, limit=None): |
97 | pages = self._get_pages("SELECT * FROM wiki \ | |
11afe905 MT |
98 | ORDER BY timestamp DESC") |
99 | ||
100 | for page in pages: | |
101 | if not page.check_acl(account): | |
102 | continue | |
103 | ||
104 | yield page | |
105 | ||
106 | limit -= 1 | |
107 | if not limit: | |
108 | break | |
181d08f3 | 109 | |
495e9dc4 | 110 | def create_page(self, page, author, content, changes=None, address=None): |
181d08f3 MT |
111 | page = Page.sanitise_page_name(page) |
112 | ||
aba5e58a | 113 | # Write page to the database |
53333caa MT |
114 | page = self._get_page(""" |
115 | INSERT INTO | |
116 | wiki | |
117 | ( | |
118 | page, | |
119 | author_uid, | |
120 | markdown, | |
121 | changes, | |
122 | address | |
123 | ) VALUES ( | |
124 | %s, %s, %s, %s, %s | |
125 | ) | |
126 | RETURNING * | |
127 | """, page, author.uid, content or None, changes, address, | |
128 | ) | |
181d08f3 | 129 | |
2ab2c753 MT |
130 | # Store any linked files |
131 | page._store_linked_files() | |
132 | ||
aba5e58a MT |
133 | # Send email to all watchers |
134 | page._send_watcher_emails(excludes=[author]) | |
135 | ||
136 | return page | |
137 | ||
495e9dc4 | 138 | def delete_page(self, page, author, **kwargs): |
181d08f3 MT |
139 | # Do nothing if the page does not exist |
140 | if not self.get_page(page): | |
141 | return | |
142 | ||
143 | # Just creates a blank last version of the page | |
495e9dc4 | 144 | self.create_page(page, author=author, content=None, **kwargs) |
181d08f3 | 145 | |
0c1186ef | 146 | def make_breadcrumbs(self, path): |
3168788e | 147 | ret = [] |
181d08f3 | 148 | |
0c1186ef MT |
149 | while path: |
150 | # Cut off everything after the last slash | |
151 | path, _, _ = path.rpartition("/") | |
152 | ||
153 | # Do not include the root | |
154 | if not path: | |
155 | break | |
156 | ||
157 | # Find the page | |
158 | page = self.get_page(path) | |
159 | ||
160 | # Append the URL and title to the output | |
161 | ret.append(( | |
162 | page.url if page else self._make_url(path), | |
163 | page.title if page else os.path.basename(path), | |
164 | )) | |
165 | ||
166 | # Return the breadcrumbs in order | |
167 | return reversed(ret) | |
181d08f3 | 168 | |
11afe905 | 169 | def search(self, query, account=None, limit=None): |
53333caa MT |
170 | res = self._get_pages(""" |
171 | SELECT | |
172 | wiki.* | |
173 | FROM | |
174 | wiki_search_index search_index | |
175 | LEFT JOIN | |
176 | wiki ON search_index.wiki_id = wiki.id | |
177 | WHERE | |
178 | search_index.document @@ websearch_to_tsquery('english', %s) | |
179 | ORDER BY | |
180 | ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC | |
181 | """, query, query, | |
182 | ) | |
9523790a | 183 | |
df80be2c | 184 | pages = [] |
11afe905 MT |
185 | for page in res: |
186 | # Skip any pages the user doesn't have permission for | |
187 | if not page.check_acl(account): | |
188 | continue | |
189 | ||
190 | # Return any other pages | |
df80be2c | 191 | pages.append(page) |
11afe905 | 192 | |
df80be2c MT |
193 | # Break when we have found enough pages |
194 | if limit and len(pages) >= limit: | |
11afe905 | 195 | break |
9523790a | 196 | |
df80be2c MT |
197 | return pages |
198 | ||
9523790a MT |
199 | def refresh(self): |
200 | """ | |
201 | Needs to be called after a page has been changed | |
202 | """ | |
86e057ac | 203 | self.db.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY wiki_search_index") |
9523790a | 204 | |
2f23c558 | 205 | def get_watchlist(self, account): |
e1d2efef MT |
206 | pages = self._get_pages(""" |
207 | WITH pages AS ( | |
208 | SELECT | |
209 | * | |
210 | FROM | |
211 | wiki_current | |
212 | LEFT JOIN | |
213 | wiki ON wiki_current.id = wiki.id | |
214 | ) | |
215 | ||
216 | SELECT | |
217 | * | |
218 | FROM | |
219 | wiki_watchlist watchlist | |
220 | JOIN | |
221 | pages ON watchlist.page = pages.page | |
222 | WHERE | |
223 | watchlist.uid = %s | |
224 | """, account.uid, | |
2f23c558 MT |
225 | ) |
226 | ||
227 | return sorted(pages) | |
228 | ||
11afe905 MT |
229 | # ACL |
230 | ||
231 | def check_acl(self, page, account): | |
53333caa MT |
232 | res = self.db.query(""" |
233 | SELECT | |
234 | * | |
235 | FROM | |
236 | wiki_acls | |
237 | WHERE | |
238 | %s ILIKE (path || '%%') | |
239 | ORDER BY | |
240 | LENGTH(path) DESC | |
241 | LIMIT 1 | |
242 | """, page, | |
243 | ) | |
11afe905 MT |
244 | |
245 | for row in res: | |
246 | # Access not permitted when user is not logged in | |
247 | if not account: | |
248 | return False | |
249 | ||
250 | # If user is in a matching group, we grant permission | |
251 | for group in row.groups: | |
93402e56 | 252 | if account.is_member_of_group(group): |
11afe905 MT |
253 | return True |
254 | ||
255 | # Otherwise access is not permitted | |
256 | return False | |
257 | ||
258 | # If no ACLs are found, we permit access | |
259 | return True | |
260 | ||
f2cfd873 MT |
261 | # Files |
262 | ||
263 | def _get_files(self, query, *args): | |
264 | res = self.db.query(query, *args) | |
265 | ||
266 | for row in res: | |
267 | yield File(self.backend, row.id, data=row) | |
268 | ||
269 | def _get_file(self, query, *args): | |
270 | res = self.db.get(query, *args) | |
271 | ||
272 | if res: | |
273 | return File(self.backend, res.id, data=res) | |
274 | ||
275 | def get_files(self, path): | |
53333caa MT |
276 | files = self._get_files(""" |
277 | SELECT | |
278 | * | |
279 | FROM | |
280 | wiki_files | |
281 | WHERE | |
282 | path = %s | |
283 | AND | |
284 | deleted_at IS NULL | |
285 | ORDER BY filename | |
286 | """, path, | |
287 | ) | |
f2cfd873 MT |
288 | |
289 | return list(files) | |
290 | ||
ff14dea3 | 291 | def get_file_by_path(self, path, revision=None): |
f2cfd873 MT |
292 | path, filename = os.path.dirname(path), os.path.basename(path) |
293 | ||
ff14dea3 MT |
294 | if revision: |
295 | # Fetch a specific revision | |
53333caa MT |
296 | return self._get_file(""" |
297 | SELECT | |
298 | * | |
299 | FROM | |
300 | wiki_files | |
301 | WHERE | |
302 | path = %s | |
303 | AND | |
304 | filename = %s | |
305 | AND | |
306 | created_at <= %s | |
307 | ORDER BY | |
308 | created_at DESC | |
309 | LIMIT 1 | |
310 | """, path, filename, revision, | |
311 | ) | |
ff14dea3 MT |
312 | |
313 | # Fetch latest version | |
53333caa MT |
314 | return self._get_file(""" |
315 | SELECT | |
316 | * | |
317 | FROM | |
318 | wiki_files | |
319 | WHERE | |
320 | path = %s | |
321 | AND | |
322 | filename = %s | |
323 | AND | |
324 | deleted_at IS NULL | |
325 | """, path, filename, | |
326 | ) | |
ff14dea3 MT |
327 | |
328 | def get_file_by_path_and_filename(self, path, filename): | |
53333caa MT |
329 | return self._get_file(""" |
330 | SELECT | |
331 | * | |
332 | FROM | |
333 | wiki_files | |
334 | WHERE | |
335 | path = %s | |
336 | AND | |
337 | filename = %s | |
338 | AND | |
339 | deleted_at IS NULL | |
340 | """, path, filename, | |
341 | ) | |
f2cfd873 MT |
342 | |
343 | def upload(self, path, filename, data, mimetype, author, address): | |
ff14dea3 MT |
344 | # Replace any existing files |
345 | file = self.get_file_by_path_and_filename(path, filename) | |
346 | if file: | |
347 | file.delete(author) | |
348 | ||
f2cfd873 | 349 | # Upload the blob first |
53333caa MT |
350 | blob = self.db.get(""" |
351 | INSERT INTO | |
352 | wiki_blobs(data) | |
353 | VALUES | |
354 | (%s) | |
355 | ON CONFLICT | |
356 | (digest(data, %s)) | |
357 | DO UPDATE | |
358 | SET data = EXCLUDED.data | |
359 | RETURNING id | |
360 | """, data, "MD5", | |
361 | ) | |
f2cfd873 MT |
362 | |
363 | # Create entry for file | |
53333caa MT |
364 | return self._get_file(""" |
365 | INSERT INTO | |
366 | wiki_files | |
367 | ( | |
368 | path, | |
369 | filename, | |
370 | author_uid, | |
371 | address, | |
372 | mimetype, | |
373 | blob_id, | |
374 | size | |
375 | ) VALUES ( | |
376 | %s, %s, %s, %s, %s, %s, %s | |
377 | ) | |
378 | RETURNING * | |
379 | """, path, filename, author.uid, address, mimetype, blob.id, len(data), | |
380 | ) | |
f2cfd873 | 381 | |
25e2dbe5 MT |
382 | def render(self, path, text, **kwargs): |
383 | return WikiRenderer(self.backend, path, text, **kwargs) | |
e2205cff | 384 | |
154f6179 | 385 | |
2901b734 | 386 | class Page(misc.Object): |
181d08f3 MT |
387 | def init(self, id, data=None): |
388 | self.id = id | |
389 | self.data = data | |
390 | ||
dc847af5 MT |
391 | def __repr__(self): |
392 | return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp) | |
393 | ||
c21ffadb MT |
394 | def __eq__(self, other): |
395 | if isinstance(other, self.__class__): | |
396 | return self.id == other.id | |
397 | ||
0713d9ae MT |
398 | return NotImplemented |
399 | ||
181d08f3 MT |
400 | def __lt__(self, other): |
401 | if isinstance(other, self.__class__): | |
402 | if self.page == other.page: | |
403 | return self.timestamp < other.timestamp | |
404 | ||
405 | return self.page < other.page | |
406 | ||
0713d9ae MT |
407 | return NotImplemented |
408 | ||
55ed268d MT |
409 | def __hash__(self): |
410 | return hash(self.page) | |
411 | ||
181d08f3 MT |
412 | @staticmethod |
413 | def sanitise_page_name(page): | |
414 | if not page: | |
415 | return "/" | |
416 | ||
417 | # Make sure that the page name does NOT end with a / | |
418 | if page.endswith("/"): | |
419 | page = page[:-1] | |
420 | ||
421 | # Make sure the page name starts with a / | |
422 | if not page.startswith("/"): | |
423 | page = "/%s" % page | |
424 | ||
425 | # Remove any double slashes | |
426 | page = page.replace("//", "/") | |
427 | ||
428 | return page | |
429 | ||
430 | @property | |
431 | def url(self): | |
0c1186ef | 432 | return self.backend.wiki._make_url(self.page) |
181d08f3 | 433 | |
4ed1dadb MT |
434 | @property |
435 | def full_url(self): | |
0805ae90 | 436 | return "https://www.ipfire.org%s" % self.url |
4ed1dadb | 437 | |
181d08f3 MT |
438 | @property |
439 | def page(self): | |
440 | return self.data.page | |
441 | ||
442 | @property | |
443 | def title(self): | |
51e7a876 | 444 | return self._title or os.path.basename(self.page[1:]) |
181d08f3 MT |
445 | |
446 | @property | |
447 | def _title(self): | |
448 | if not self.markdown: | |
449 | return | |
450 | ||
451 | # Find first H1 headline in markdown | |
452 | markdown = self.markdown.splitlines() | |
453 | ||
0074e919 | 454 | m = re.match(r"^#\s*(.*)( #)?$", markdown[0]) |
181d08f3 MT |
455 | if m: |
456 | return m.group(1) | |
457 | ||
3b05ef6e MT |
458 | @lazy_property |
459 | def author(self): | |
460 | if self.data.author_uid: | |
461 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
462 | ||
181d08f3 MT |
463 | @property |
464 | def markdown(self): | |
c21ffadb | 465 | return self.data.markdown or "" |
181d08f3 MT |
466 | |
467 | @property | |
468 | def html(self): | |
f9e077ed MT |
469 | lines = [] |
470 | ||
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("#"): | |
474 | continue | |
475 | ||
476 | lines.append(line) | |
477 | ||
25e2dbe5 | 478 | renderer = self.backend.wiki.render(self.page, "\n".join(lines), revision=self.timestamp) |
5ab70651 MT |
479 | |
480 | return renderer.html | |
481 | ||
2ab2c753 MT |
482 | # Linked Files |
483 | ||
5ab70651 | 484 | @property |
2ab2c753 | 485 | def files(self): |
25e2dbe5 | 486 | renderer = self.backend.wiki.render(self.page, self.markdown, revision=self.timestamp) |
5ab70651 | 487 | |
2ab2c753 MT |
488 | return renderer.files |
489 | ||
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)) | |
addc18d5 | 493 | |
181d08f3 MT |
494 | @property |
495 | def timestamp(self): | |
496 | return self.data.timestamp | |
497 | ||
498 | def was_deleted(self): | |
4c13230c | 499 | return not self.markdown |
181d08f3 MT |
500 | |
501 | @lazy_property | |
502 | def breadcrumbs(self): | |
503 | return self.backend.wiki.make_breadcrumbs(self.page) | |
504 | ||
d4c68c5c MT |
505 | def is_latest_revision(self): |
506 | return self.get_latest_revision() == self | |
507 | ||
181d08f3 | 508 | def get_latest_revision(self): |
7d699684 MT |
509 | revisions = self.get_revisions() |
510 | ||
511 | # Return first object | |
512 | for rev in revisions: | |
513 | return rev | |
514 | ||
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) | |
091ac36b | 518 | |
c21ffadb MT |
519 | @lazy_property |
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) | |
524 | ||
d398ca08 MT |
525 | @property |
526 | def changes(self): | |
527 | return self.data.changes | |
528 | ||
11afe905 MT |
529 | # ACL |
530 | ||
531 | def check_acl(self, account): | |
532 | return self.backend.wiki.check_acl(self.page, account) | |
533 | ||
d64a1e35 MT |
534 | # Watchers |
535 | ||
4ed1dadb MT |
536 | @lazy_property |
537 | def diff(self): | |
538 | if self.previous_revision: | |
539 | diff = difflib.unified_diff( | |
540 | self.previous_revision.markdown.splitlines(), | |
541 | self.markdown.splitlines(), | |
542 | ) | |
543 | ||
544 | return "\n".join(diff) | |
545 | ||
aba5e58a MT |
546 | @property |
547 | def watchers(self): | |
548 | res = self.db.query("SELECT uid FROM wiki_watchlist \ | |
549 | WHERE page = %s", self.page) | |
550 | ||
551 | for row in res: | |
552 | # Search for account by UID and skip if none was found | |
553 | account = self.backend.accounts.get_by_uid(row.uid) | |
554 | if not account: | |
555 | continue | |
556 | ||
557 | # Return the account | |
558 | yield account | |
559 | ||
f2e25ded | 560 | def is_watched_by(self, account): |
d64a1e35 MT |
561 | res = self.db.get("SELECT 1 FROM wiki_watchlist \ |
562 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
563 | ||
564 | if res: | |
565 | return True | |
566 | ||
567 | return False | |
568 | ||
569 | def add_watcher(self, account): | |
f2e25ded | 570 | if self.is_watched_by(account): |
d64a1e35 MT |
571 | return |
572 | ||
573 | self.db.execute("INSERT INTO wiki_watchlist(page, uid) \ | |
574 | VALUES(%s, %s)", self.page, account.uid) | |
575 | ||
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) | |
579 | ||
aba5e58a MT |
580 | def _send_watcher_emails(self, excludes=[]): |
581 | # Nothing to do if there was no previous revision | |
582 | if not self.previous_revision: | |
583 | return | |
584 | ||
585 | for watcher in self.watchers: | |
586 | # Skip everyone who is excluded | |
587 | if watcher in excludes: | |
588 | logging.debug("Excluding %s" % watcher) | |
589 | continue | |
590 | ||
516da0a9 MT |
591 | # Check permissions |
592 | if not self.backend.wiki.check_acl(self.page, watcher): | |
593 | logging.debug("Watcher %s does not have permissions" % watcher) | |
594 | continue | |
595 | ||
aba5e58a MT |
596 | logging.debug("Sending watcher email to %s" % watcher) |
597 | ||
4ed1dadb MT |
598 | # Compose message |
599 | self.backend.messages.send_template("wiki/messages/page-changed", | |
ba14044c | 600 | account=watcher, page=self, priority=-10) |
aba5e58a | 601 | |
9f1cfab7 | 602 | def restore(self, author, address, comment=None): |
d4c68c5c MT |
603 | changes = "Restore to revision from %s" % self.timestamp.isoformat() |
604 | ||
9f1cfab7 MT |
605 | # Append comment |
606 | if comment: | |
607 | changes = "%s: %s" % (changes, comment) | |
608 | ||
d4c68c5c MT |
609 | return self.backend.wiki.create_page(self.page, |
610 | author, self.markdown, changes=changes, address=address) | |
611 | ||
f2cfd873 MT |
612 | |
613 | class File(misc.Object): | |
614 | def init(self, id, data): | |
615 | self.id = id | |
616 | self.data = data | |
617 | ||
ff14dea3 MT |
618 | def __eq__(self, other): |
619 | if isinstance(other, self.__class__): | |
620 | return self.id == other.id | |
621 | ||
9406e5e2 MT |
622 | return NotImplemented |
623 | ||
f2cfd873 MT |
624 | @property |
625 | def url(self): | |
a82de4b1 | 626 | return "/docs%s" % os.path.join(self.path, self.filename) |
f2cfd873 MT |
627 | |
628 | @property | |
629 | def path(self): | |
630 | return self.data.path | |
631 | ||
632 | @property | |
633 | def filename(self): | |
634 | return self.data.filename | |
635 | ||
636 | @property | |
637 | def mimetype(self): | |
638 | return self.data.mimetype | |
639 | ||
640 | @property | |
641 | def size(self): | |
642 | return self.data.size | |
643 | ||
8cb0bea4 MT |
644 | @lazy_property |
645 | def author(self): | |
646 | if self.data.author_uid: | |
647 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
648 | ||
649 | @property | |
650 | def created_at(self): | |
651 | return self.data.created_at | |
652 | ||
25e2dbe5 MT |
653 | timestamp = created_at |
654 | ||
b26c705a | 655 | def delete(self, author=None): |
9406e5e2 MT |
656 | if not self.can_be_deleted(): |
657 | raise RuntimeError("Cannot delete %s" % self) | |
658 | ||
b26c705a MT |
659 | self.db.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \ |
660 | WHERE id = %s", author.uid if author else None, self.id) | |
ff14dea3 | 661 | |
9406e5e2 MT |
662 | def can_be_deleted(self): |
663 | # Cannot be deleted if still in use | |
664 | if self.pages: | |
665 | return False | |
666 | ||
667 | # Can be deleted | |
668 | return True | |
669 | ||
ff14dea3 MT |
670 | @property |
671 | def deleted_at(self): | |
672 | return self.data.deleted_at | |
673 | ||
674 | def get_latest_revision(self): | |
675 | revisions = self.get_revisions() | |
676 | ||
677 | # Return first object | |
678 | for rev in revisions: | |
679 | return rev | |
680 | ||
681 | def get_revisions(self): | |
682 | revisions = self.backend.wiki._get_files("SELECT * FROM wiki_files \ | |
2225edd9 | 683 | WHERE path = %s AND filename = %s ORDER BY created_at DESC", self.path, self.filename) |
ff14dea3 MT |
684 | |
685 | return list(revisions) | |
686 | ||
8cb0bea4 MT |
687 | def is_pdf(self): |
688 | return self.mimetype in ("application/pdf", "application/x-pdf") | |
689 | ||
f2cfd873 MT |
690 | def is_image(self): |
691 | return self.mimetype.startswith("image/") | |
692 | ||
8a62e589 MT |
693 | def is_vector_image(self): |
694 | return self.mimetype in ("image/svg+xml",) | |
695 | ||
696 | def is_bitmap_image(self): | |
697 | return self.is_image() and not self.is_vector_image() | |
698 | ||
f2cfd873 MT |
699 | @lazy_property |
700 | def blob(self): | |
701 | res = self.db.get("SELECT data FROM wiki_blobs \ | |
702 | WHERE id = %s", self.data.blob_id) | |
703 | ||
704 | if res: | |
705 | return bytes(res.data) | |
79dd9a0f | 706 | |
ccfb1584 | 707 | async def get_thumbnail(self, size, format=None): |
8a62e589 MT |
708 | assert self.is_bitmap_image() |
709 | ||
531c4989 MT |
710 | # Let thumbnails live in the cache for up to 24h |
711 | ttl = 24 * 3600 | |
712 | ||
ccfb1584 MT |
713 | cache_key = ":".join(( |
714 | "wiki", | |
715 | "thumbnail", | |
df4f5dfb MT |
716 | self.path, |
717 | util.normalize(self.filename), | |
718 | self.created_at.isoformat(), | |
ccfb1584 | 719 | format or "N/A", |
df4f5dfb MT |
720 | "%spx" % size, |
721 | )) | |
75d9b3da MT |
722 | |
723 | # Try to fetch the data from the cache | |
531c4989 MT |
724 | async with await self.backend.cache.pipeline() as p: |
725 | # Fetch the key | |
726 | await p.get(cache_key) | |
727 | ||
728 | # Reset the TTL | |
729 | await p.expire(cache_key, ttl) | |
730 | ||
731 | # Execute the pipeline | |
732 | thumbnail, _ = await p.execute() | |
733 | ||
734 | # Return the cached value | |
75d9b3da MT |
735 | if thumbnail: |
736 | return thumbnail | |
737 | ||
738 | # Generate the thumbnail | |
60b2a858 | 739 | thumbnail = util.generate_thumbnail(self.blob, size, format=format, quality=95) |
75d9b3da | 740 | |
531c4989 MT |
741 | # Put it into the cache for 24h |
742 | await self.backend.cache.set(cache_key, thumbnail, ttl) | |
75d9b3da MT |
743 | |
744 | return thumbnail | |
2901b734 | 745 | |
9406e5e2 MT |
746 | @property |
747 | def pages(self): | |
748 | """ | |
749 | Returns a list of all pages this file is linked by | |
750 | """ | |
751 | pages = self.backend.wiki._get_pages(""" | |
752 | SELECT | |
753 | wiki.* | |
754 | FROM | |
755 | wiki_linked_files | |
756 | JOIN | |
757 | wiki_current ON wiki_linked_files.page_id = wiki_current.id | |
758 | LEFT JOIN | |
759 | wiki ON wiki_linked_files.page_id = wiki.id | |
760 | WHERE | |
761 | wiki_linked_files.path = %s | |
762 | ORDER BY | |
763 | wiki.page | |
764 | """, os.path.join(self.path, self.filename), | |
765 | ) | |
766 | ||
767 | return list(pages) | |
768 | ||
2901b734 MT |
769 | |
770 | class WikiRenderer(misc.Object): | |
4ddad3e5 MT |
771 | schemas = ( |
772 | "ftp://", | |
773 | "git://", | |
774 | "http://", | |
775 | "https://", | |
776 | "rsync://", | |
777 | "sftp://", | |
778 | "ssh://", | |
779 | "webcal://", | |
780 | ) | |
781 | ||
782 | # Links | |
5ab70651 | 783 | _links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>") |
2901b734 | 784 | |
c78ad26e | 785 | # Images |
5ab70651 | 786 | _images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>") |
245a2e36 | 787 | |
25e2dbe5 | 788 | def init(self, path, text, revision=None): |
2901b734 | 789 | self.path = path |
5ab70651 MT |
790 | self.text = text |
791 | ||
25e2dbe5 MT |
792 | # Optionally, the revision of the rendered page |
793 | self.revision = revision | |
794 | ||
5ab70651 | 795 | # Markdown Renderer |
886d5f7b MT |
796 | self.renderer = Markdown( |
797 | self.backend, | |
5ab70651 | 798 | extensions=[ |
2ab2c753 | 799 | LinkedFilesExtractorExtension(), |
5ab70651 MT |
800 | PrettyLinksExtension(), |
801 | "codehilite", | |
802 | "fenced_code", | |
803 | "footnotes", | |
804 | "nl2br", | |
805 | "sane_lists", | |
806 | "tables", | |
807 | "toc", | |
808 | ], | |
809 | ) | |
810 | ||
811 | # Render! | |
812 | self.html = self._render() | |
2901b734 | 813 | |
4ddad3e5 MT |
814 | def _render_link(self, m): |
815 | url, text = m.groups() | |
2901b734 | 816 | |
0a17d51e MT |
817 | # Treat linkes starting with a double slash as absolute |
818 | if url.startswith("//"): | |
819 | # Remove the double-lash | |
820 | url = url.removeprefix("/") | |
821 | ||
822 | # Return a link | |
823 | return """<a href="%s">%s</a>""" % (url, text or url) | |
824 | ||
e50a437a MT |
825 | # External Links |
826 | for schema in self.schemas: | |
827 | if url.startswith(schema): | |
828 | return """<a class="link-external" href="%s">%s</a>""" % \ | |
829 | (url, text or url) | |
830 | ||
4ddad3e5 MT |
831 | # Emails |
832 | if "@" in url: | |
833 | # Strip mailto: | |
834 | if url.startswith("mailto:"): | |
835 | url = url[7:] | |
2901b734 | 836 | |
4ddad3e5 MT |
837 | return """<a class="link-external" href="mailto:%s">%s</a>""" % \ |
838 | (url, text or url) | |
2901b734 | 839 | |
4ddad3e5 MT |
840 | # Everything else must be an internal link |
841 | path = self.backend.wiki.make_path(self.path, url) | |
2901b734 | 842 | |
46b77977 | 843 | return """<a href="/docs%s">%s</a>""" % \ |
4ddad3e5 | 844 | (path, text or self.backend.wiki.get_page_title(path)) |
2901b734 | 845 | |
c78ad26e | 846 | def _render_image(self, m): |
e9c6d581 | 847 | alt_text, url, caption = m.groups() |
2901b734 | 848 | |
ef963ecb MT |
849 | # Compute a hash over the URL |
850 | h = hashlib.new("md5") | |
851 | h.update(url.encode()) | |
852 | id = h.hexdigest() | |
853 | ||
4a1bfdd5 | 854 | html = """ |
3ae53eac MT |
855 | <div class="columns is-centered"> |
856 | <div class="column is-8"> | |
ef963ecb | 857 | <figure class="image modal-trigger" data-target="%(id)s"> |
9e1bc5f1 | 858 | <img src="/docs%(url)s?s=960&%(args)s" alt="%(caption)s"> |
ef963ecb MT |
859 | |
860 | <figcaption class="figure-caption">%(caption)s</figcaption> | |
3ae53eac | 861 | </figure> |
ef963ecb MT |
862 | |
863 | <div class="modal is-large" id="%(id)s"> | |
864 | <div class="modal-background"></div> | |
865 | ||
866 | <div class="modal-content"> | |
867 | <p class="image"> | |
9e1bc5f1 | 868 | <img src="/docs%(url)s?s=2048&%(args)s" alt="%(caption)s" |
ef963ecb MT |
869 | loading="lazy"> |
870 | </p> | |
aabfb733 MT |
871 | |
872 | <a class="button is-small" href="/docs%(url)s?action=detail"> | |
873 | <span class="icon"> | |
874 | <i class="fa-solid fa-circle-info"></i> | |
875 | </span> | |
876 | </a> | |
ef963ecb MT |
877 | </div> |
878 | ||
879 | <button class="modal-close is-large" aria-label="close"></button> | |
880 | </div> | |
3ae53eac MT |
881 | </div> |
882 | </div> | |
4a1bfdd5 MT |
883 | """ |
884 | ||
a1f5f64b MT |
885 | # Try to split query string |
886 | url, delimiter, qs = url.partition("?") | |
887 | ||
888 | # Parse query arguments | |
889 | args = urllib.parse.parse_qs(qs) | |
890 | ||
c78ad26e | 891 | # Skip any absolute and external URLs |
25e2dbe5 | 892 | if url.startswith("https://") or url.startswith("http://"): |
ef963ecb MT |
893 | return html % { |
894 | "caption" : caption or "", | |
895 | "id" : id, | |
ef963ecb | 896 | "url" : url, |
fcfccf8a | 897 | "args" : args, |
ef963ecb | 898 | } |
2901b734 | 899 | |
c78ad26e | 900 | # Build absolute path |
25e2dbe5 | 901 | url = self.backend.wiki.make_path(self.path, url) |
2901b734 | 902 | |
c78ad26e | 903 | # Find image |
25e2dbe5 | 904 | file = self.backend.wiki.get_file_by_path(url, revision=self.revision) |
c78ad26e MT |
905 | if not file or not file.is_image(): |
906 | return "<!-- Could not find image %s in %s -->" % (url, self.path) | |
2901b734 | 907 | |
6d8a51d9 MT |
908 | # Remove any requested size |
909 | if "s" in args: | |
910 | del args["s"] | |
2901b734 | 911 | |
25e2dbe5 MT |
912 | # Link the image that has been the current version at the time of the page edit |
913 | if file: | |
914 | args["revision"] = file.timestamp | |
4a1bfdd5 | 915 | |
ef963ecb MT |
916 | return html % { |
917 | "caption" : caption or "", | |
918 | "id" : id, | |
ef963ecb | 919 | "url" : url, |
25e2dbe5 | 920 | "args" : urllib.parse.urlencode(args), |
ef963ecb | 921 | } |
2901b734 | 922 | |
5ab70651 | 923 | def _render(self): |
c78ad26e | 924 | logging.debug("Rendering %s" % self.path) |
2901b734 | 925 | |
245a2e36 | 926 | # Render... |
5ab70651 | 927 | text = self.renderer.convert(self.text) |
9881e9ef | 928 | |
4ddad3e5 | 929 | # Postprocess links |
5ab70651 | 930 | text = self._links.sub(self._render_link, text) |
4ddad3e5 | 931 | |
9881e9ef | 932 | # Postprocess images to <figure> |
5ab70651 | 933 | text = self._images.sub(self._render_image, text) |
c78ad26e | 934 | |
9881e9ef | 935 | return text |
5ab70651 MT |
936 | |
937 | @lazy_property | |
2ab2c753 | 938 | def files(self): |
5ab70651 | 939 | """ |
2ab2c753 | 940 | A list of all linked files that have been part of the rendered markup |
5ab70651 | 941 | """ |
2ab2c753 | 942 | files = [] |
5ab70651 | 943 | |
2ab2c753 | 944 | for url in self.renderer.files: |
5ab70651 MT |
945 | # Skip external images |
946 | if url.startswith("https://") or url.startswith("http://"): | |
947 | continue | |
948 | ||
949 | # Make the URL absolute | |
950 | url = self.backend.wiki.make_path(self.path, url) | |
951 | ||
ca493e11 MT |
952 | # Check if this is a file (it could also just be a page) |
953 | file = self.backend.wiki.get_file_by_path(url) | |
954 | if file: | |
955 | files.append(url) | |
5ab70651 | 956 | |
2ab2c753 | 957 | return files |
5ab70651 MT |
958 | |
959 | ||
886d5f7b MT |
960 | class Markdown(markdown.Markdown): |
961 | def __init__(self, backend, *args, **kwargs): | |
962 | # Store the backend | |
963 | self.backend = backend | |
964 | ||
965 | # Call inherited setup routine | |
966 | super().__init__(*args, **kwargs) | |
967 | ||
968 | ||
5ab70651 MT |
969 | class PrettyLinksExtension(markdown.extensions.Extension): |
970 | def extendMarkdown(self, md): | |
971 | # Create links to Bugzilla | |
972 | md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10) | |
973 | ||
974 | # Create links to CVE | |
975 | md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10) | |
976 | ||
8d4a78e8 MT |
977 | # Link mentioned users |
978 | md.preprocessors.register(UserMentionPreprocessor(md), "user-mention", 10) | |
979 | ||
5ab70651 MT |
980 | |
981 | class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor): | |
982 | regex = re.compile(r"(?:#(\d{5,}))", re.I) | |
983 | ||
984 | def run(self, lines): | |
985 | for line in lines: | |
986 | yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line) | |
987 | ||
988 | ||
989 | class CVELinksPreprocessor(markdown.preprocessors.Preprocessor): | |
990 | regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)") | |
991 | ||
992 | def run(self, lines): | |
993 | for line in lines: | |
994 | yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line) | |
995 | ||
996 | ||
8d4a78e8 | 997 | class UserMentionPreprocessor(markdown.preprocessors.Preprocessor): |
09734369 | 998 | regex = re.compile(r"\b@(\w+)") |
8d4a78e8 MT |
999 | |
1000 | def run(self, lines): | |
1001 | for line in lines: | |
1002 | yield self.regex.sub(self._replace, line) | |
1003 | ||
1004 | def _replace(self, m): | |
1005 | # Fetch the user's handle | |
1006 | uid, = m.groups() | |
1007 | ||
1008 | # Fetch the user | |
1009 | user = self.md.backend.accounts.get_by_uid(uid) | |
1010 | ||
1011 | # If the user was not found, we put back the matched text | |
1012 | if not user: | |
1013 | return m.group(0) | |
1014 | ||
1015 | # Link the user | |
1016 | return "[%s](//users/%s)" % (user, user.uid) | |
1017 | ||
1018 | ||
2ab2c753 | 1019 | class LinkedFilesExtractor(markdown.treeprocessors.Treeprocessor): |
5ab70651 | 1020 | """ |
2ab2c753 | 1021 | Finds all Linked Files |
5ab70651 | 1022 | """ |
67be8d58 MT |
1023 | def __init__(self, *args, **kwargs): |
1024 | super().__init__(*args, **kwargs) | |
1025 | ||
2ab2c753 | 1026 | self.md.files = [] |
5ab70651 | 1027 | |
67be8d58 | 1028 | def run(self, root): |
5ab70651 MT |
1029 | # Find all images and store the URLs |
1030 | for image in root.findall(".//img"): | |
1031 | src = image.get("src") | |
1032 | ||
2ab2c753 | 1033 | self.md.files.append(src) |
5ab70651 | 1034 | |
ca493e11 MT |
1035 | # Find all links |
1036 | for link in root.findall(".//a"): | |
1037 | href = link.get("href") | |
1038 | ||
1039 | self.md.files.append(href) | |
1040 | ||
5ab70651 | 1041 | |
2ab2c753 | 1042 | class LinkedFilesExtractorExtension(markdown.extensions.Extension): |
5ab70651 | 1043 | def extendMarkdown(self, md): |
2ab2c753 | 1044 | md.treeprocessors.register(LinkedFilesExtractor(md), "linked-files-extractor", 10) |