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