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