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