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