]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/wiki.py
accounts: Use stored email address
[ipfire.org.git] / src / backend / wiki.py
CommitLineData
181d08f3
MT
1#!/usr/bin/python3
2
79dd9a0f
MT
3import PIL
4import io
181d08f3 5import logging
6ac7e934 6import os.path
181d08f3 7import re
9e90e800 8import urllib.parse
181d08f3
MT
9
10from . import misc
9523790a 11from . import util
181d08f3
MT
12from .decorators import *
13
181d08f3
MT
14class Wiki(misc.Object):
15 def _get_pages(self, query, *args):
16 res = self.db.query(query, *args)
17
18 for row in res:
19 yield Page(self.backend, row.id, data=row)
20
d398ca08
MT
21 def _get_page(self, query, *args):
22 res = self.db.get(query, *args)
23
24 if res:
25 return Page(self.backend, res.id, data=res)
26
6ac7e934
MT
27 def get_page_title(self, page, default=None):
28 doc = self.get_page(page)
29 if doc:
30 return doc.title
31
0b62a7f9 32 return default or os.path.basename(page)
6ac7e934 33
181d08f3
MT
34 def get_page(self, page, revision=None):
35 page = Page.sanitise_page_name(page)
36 assert page
37
38 if revision:
d398ca08 39 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
40 AND timestamp = %s", page, revision)
41 else:
d398ca08 42 return self._get_page("SELECT * FROM wiki WHERE page = %s \
181d08f3
MT
43 ORDER BY timestamp DESC LIMIT 1", page)
44
11afe905
MT
45 def get_recent_changes(self, account, limit=None):
46 pages = self._get_pages("SELECT * FROM wiki \
f9db574a 47 WHERE timestamp >= NOW() - INTERVAL '4 weeks' \
11afe905
MT
48 ORDER BY timestamp DESC")
49
50 for page in pages:
51 if not page.check_acl(account):
52 continue
53
54 yield page
55
56 limit -= 1
57 if not limit:
58 break
181d08f3 59
495e9dc4 60 def create_page(self, page, author, content, changes=None, address=None):
181d08f3
MT
61 page = Page.sanitise_page_name(page)
62
aba5e58a
MT
63 # Write page to the database
64 page = self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
df01767e 65 VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
181d08f3 66
aba5e58a
MT
67 # Send email to all watchers
68 page._send_watcher_emails(excludes=[author])
69
70 return page
71
495e9dc4 72 def delete_page(self, page, author, **kwargs):
181d08f3
MT
73 # Do nothing if the page does not exist
74 if not self.get_page(page):
75 return
76
77 # Just creates a blank last version of the page
495e9dc4 78 self.create_page(page, author=author, content=None, **kwargs)
181d08f3 79
3168788e
MT
80 def make_breadcrumbs(self, url):
81 # Split and strip all empty elements (double slashes)
181d08f3
MT
82 parts = list(e for e in url.split("/") if e)
83
3168788e 84 ret = []
b1bf7d48 85 for part in ("/".join(parts[:i]) for i in range(1, len(parts))):
3168788e 86 ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part))))
181d08f3 87
3168788e 88 return ret
181d08f3 89
11afe905 90 def search(self, query, account=None, limit=None):
9523790a
MT
91 query = util.parse_search_query(query)
92
93 res = self._get_pages("SELECT wiki.* FROM wiki_search_index search_index \
94 LEFT JOIN wiki ON search_index.wiki_id = wiki.id \
95 WHERE search_index.document @@ to_tsquery('english', %s) \
11afe905
MT
96 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC",
97 query, query)
9523790a 98
11afe905
MT
99 for page in res:
100 # Skip any pages the user doesn't have permission for
101 if not page.check_acl(account):
102 continue
103
104 # Return any other pages
105 yield page
106
107 limit -= 1
108 if not limit:
109 break
9523790a
MT
110
111 def refresh(self):
112 """
113 Needs to be called after a page has been changed
114 """
115 self.db.execute("REFRESH MATERIALIZED VIEW wiki_search_index")
116
11afe905
MT
117 # ACL
118
119 def check_acl(self, page, account):
120 res = self.db.query("SELECT * FROM wiki_acls \
121 WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page)
122
123 for row in res:
124 # Access not permitted when user is not logged in
125 if not account:
126 return False
127
128 # If user is in a matching group, we grant permission
129 for group in row.groups:
130 if group in account.groups:
131 return True
132
133 # Otherwise access is not permitted
134 return False
135
136 # If no ACLs are found, we permit access
137 return True
138
f2cfd873
MT
139 # Files
140
141 def _get_files(self, query, *args):
142 res = self.db.query(query, *args)
143
144 for row in res:
145 yield File(self.backend, row.id, data=row)
146
147 def _get_file(self, query, *args):
148 res = self.db.get(query, *args)
149
150 if res:
151 return File(self.backend, res.id, data=res)
152
153 def get_files(self, path):
154 files = self._get_files("SELECT * FROM wiki_files \
155 WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path)
156
157 return list(files)
158
159 def get_file_by_path(self, path):
160 path, filename = os.path.dirname(path), os.path.basename(path)
161
162 return self._get_file("SELECT * FROM wiki_files \
163 WHERE path = %s AND filename = %s AND deleted_at IS NULL", path, filename)
164
165 def upload(self, path, filename, data, mimetype, author, address):
166 # Upload the blob first
167 blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) RETURNING id", data)
168
169 # Create entry for file
170 return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \
171 mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path,
172 filename, author.uid, address, mimetype, blob.id, len(data))
173
9e90e800
MT
174 def find_image(self, path, filename):
175 for p in (path, os.path.dirname(path)):
176 file = self.get_file_by_path(os.path.join(p, filename))
177
178 if file and file.is_image():
179 return file
180
181d08f3
MT
181
182class Page(misc.Object):
183 def init(self, id, data=None):
184 self.id = id
185 self.data = data
186
dc847af5
MT
187 def __repr__(self):
188 return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp)
189
c21ffadb
MT
190 def __eq__(self, other):
191 if isinstance(other, self.__class__):
192 return self.id == other.id
193
181d08f3
MT
194 def __lt__(self, other):
195 if isinstance(other, self.__class__):
196 if self.page == other.page:
197 return self.timestamp < other.timestamp
198
199 return self.page < other.page
200
201 @staticmethod
202 def sanitise_page_name(page):
203 if not page:
204 return "/"
205
206 # Make sure that the page name does NOT end with a /
207 if page.endswith("/"):
208 page = page[:-1]
209
210 # Make sure the page name starts with a /
211 if not page.startswith("/"):
212 page = "/%s" % page
213
214 # Remove any double slashes
215 page = page.replace("//", "/")
216
217 return page
218
219 @property
220 def url(self):
db8448d9 221 return self.page
181d08f3
MT
222
223 @property
224 def page(self):
225 return self.data.page
226
227 @property
228 def title(self):
51e7a876 229 return self._title or os.path.basename(self.page[1:])
181d08f3
MT
230
231 @property
232 def _title(self):
233 if not self.markdown:
234 return
235
236 # Find first H1 headline in markdown
237 markdown = self.markdown.splitlines()
238
239 m = re.match(r"^# (.*)( #)?$", markdown[0])
240 if m:
241 return m.group(1)
242
3b05ef6e
MT
243 @lazy_property
244 def author(self):
245 if self.data.author_uid:
246 return self.backend.accounts.get_by_uid(self.data.author_uid)
247
181d08f3
MT
248 def _render(self, text):
249 logging.debug("Rendering %s" % self)
250
9e90e800
MT
251 # Link images
252 replacements = []
253 for match in re.finditer(r"!\[(.*)\]\((.*)\)", text):
254 alt_text, url = match.groups()
255
256 # Skip any absolute and external URLs
257 if url.startswith("/") or url.startswith("https://") or url.startswith("http://"):
258 continue
259
260 # Try to split query string
261 url, delimiter, qs = url.partition("?")
262
263 # Parse query arguments
264 args = urllib.parse.parse_qs(qs)
265
266 # Find image
267 file = self.backend.wiki.find_image(self.page, url)
268 if not file:
269 continue
270
271 # Scale down the image if not already done
272 if not "s" in args:
273 args["s"] = "768"
274
275 # Format URL
276 url = "%s?%s" % (file.url, urllib.parse.urlencode(args))
277
bf59e35d 278 replacements.append((match.span(), file, alt_text, url))
9e90e800
MT
279
280 # Apply all replacements
bf59e35d
MT
281 for (start, end), file, alt_text, url in reversed(replacements):
282 text = text[:start] + "[![%s](%s)](%s?action=detail)" % (alt_text, url, file.url) + text[end:]
9e90e800 283
9e90e800 284 # Add wiki links
574794da
MT
285 patterns = (
286 (r"\[\[([\w\d\/]+)(?:\|([\w\d\s]+))\]\]", r"/\1", r"\2", None, None),
287 (r"\[\[([\w\d\/\-]+)\]\]", r"/\1", r"\1", self.backend.wiki.get_page_title, r"\1"),
288 )
289
290 for pattern, link, title, repl, args in patterns:
291 replacements = []
292
293 for match in re.finditer(pattern, text):
294 l = match.expand(link)
295 t = match.expand(title)
296
297 if callable(repl):
298 t = repl(match.expand(args)) or t
299
300 replacements.append((match.span(), t or l, l))
301
302 # Apply all replacements
303 for (start, end), t, l in reversed(replacements):
304 text = text[:start] + "[%s](%s)" % (t, l) + text[end:]
305
045ea3db
MT
306 # Borrow this from the blog
307 return self.backend.blog._render_text(text, lang="markdown")
181d08f3
MT
308
309 @property
310 def markdown(self):
c21ffadb 311 return self.data.markdown or ""
181d08f3
MT
312
313 @property
314 def html(self):
315 return self.data.html or self._render(self.markdown)
316
317 @property
318 def timestamp(self):
319 return self.data.timestamp
320
321 def was_deleted(self):
322 return self.markdown is None
323
324 @lazy_property
325 def breadcrumbs(self):
326 return self.backend.wiki.make_breadcrumbs(self.page)
327
328 def get_latest_revision(self):
7d699684
MT
329 revisions = self.get_revisions()
330
331 # Return first object
332 for rev in revisions:
333 return rev
334
335 def get_revisions(self):
336 return self.backend.wiki._get_pages("SELECT * FROM wiki \
337 WHERE page = %s ORDER BY timestamp DESC", self.page)
091ac36b 338
c21ffadb
MT
339 @lazy_property
340 def previous_revision(self):
341 return self.backend.wiki._get_page("SELECT * FROM wiki \
342 WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \
343 LIMIT 1", self.page, self.timestamp)
344
d398ca08
MT
345 @property
346 def changes(self):
347 return self.data.changes
348
11afe905
MT
349 # ACL
350
351 def check_acl(self, account):
352 return self.backend.wiki.check_acl(self.page, account)
353
091ac36b
MT
354 # Sidebar
355
356 @lazy_property
357 def sidebar(self):
358 parts = self.page.split("/")
359
360 while parts:
3cc5f666 361 sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts))
091ac36b
MT
362 if sidebar:
363 return sidebar
364
365 parts.pop()
f2cfd873 366
d64a1e35
MT
367 # Watchers
368
aba5e58a
MT
369 @property
370 def watchers(self):
371 res = self.db.query("SELECT uid FROM wiki_watchlist \
372 WHERE page = %s", self.page)
373
374 for row in res:
375 # Search for account by UID and skip if none was found
376 account = self.backend.accounts.get_by_uid(row.uid)
377 if not account:
378 continue
379
380 # Return the account
381 yield account
382
f2e25ded 383 def is_watched_by(self, account):
d64a1e35
MT
384 res = self.db.get("SELECT 1 FROM wiki_watchlist \
385 WHERE page = %s AND uid = %s", self.page, account.uid)
386
387 if res:
388 return True
389
390 return False
391
392 def add_watcher(self, account):
f2e25ded 393 if self.is_watched_by(account):
d64a1e35
MT
394 return
395
396 self.db.execute("INSERT INTO wiki_watchlist(page, uid) \
397 VALUES(%s, %s)", self.page, account.uid)
398
399 def remove_watcher(self, account):
400 self.db.execute("DELETE FROM wiki_watchlist \
401 WHERE page = %s AND uid = %s", self.page, account.uid)
402
aba5e58a
MT
403 def _send_watcher_emails(self, excludes=[]):
404 # Nothing to do if there was no previous revision
405 if not self.previous_revision:
406 return
407
408 for watcher in self.watchers:
409 # Skip everyone who is excluded
410 if watcher in excludes:
411 logging.debug("Excluding %s" % watcher)
412 continue
413
414 logging.debug("Sending watcher email to %s" % watcher)
415
416 pass # TODO
417
f2cfd873
MT
418
419class File(misc.Object):
420 def init(self, id, data):
421 self.id = id
422 self.data = data
423
424 @property
425 def url(self):
426 return os.path.join(self.path, self.filename)
427
428 @property
429 def path(self):
430 return self.data.path
431
432 @property
433 def filename(self):
434 return self.data.filename
435
436 @property
437 def mimetype(self):
438 return self.data.mimetype
439
440 @property
441 def size(self):
442 return self.data.size
443
8cb0bea4
MT
444 @lazy_property
445 def author(self):
446 if self.data.author_uid:
447 return self.backend.accounts.get_by_uid(self.data.author_uid)
448
449 @property
450 def created_at(self):
451 return self.data.created_at
452
453 def is_pdf(self):
454 return self.mimetype in ("application/pdf", "application/x-pdf")
455
f2cfd873
MT
456 def is_image(self):
457 return self.mimetype.startswith("image/")
458
459 @lazy_property
460 def blob(self):
461 res = self.db.get("SELECT data FROM wiki_blobs \
462 WHERE id = %s", self.data.blob_id)
463
464 if res:
465 return bytes(res.data)
79dd9a0f
MT
466
467 def get_thumbnail(self, size):
75d9b3da
MT
468 cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size))
469
470 # Try to fetch the data from the cache
471 thumbnail = self.memcache.get(cache_key)
472 if thumbnail:
473 return thumbnail
474
475 # Generate the thumbnail
476 thumbnail = self._generate_thumbnail(size)
477
478 # Put it into the cache for forever
479 self.memcache.set(cache_key, thumbnail)
480
481 return thumbnail
482
483 def _generate_thumbnail(self, size):
79dd9a0f
MT
484 image = PIL.Image.open(io.BytesIO(self.blob))
485
486 # Resize the image to the desired resolution
487 image.thumbnail((size, size), PIL.Image.ANTIALIAS)
488
489 with io.BytesIO() as f:
490 # If writing out the image does not work with optimization,
491 # we try to write it out without any optimization.
492 try:
493 image.save(f, image.format, optimize=True, quality=98)
494 except:
495 image.save(f, image.format, quality=98)
496
497 return f.getvalue()