]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/wiki.py
Ship local copy of hwdata module
[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
495e9dc4 63 return self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
df01767e 64 VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
181d08f3 65
495e9dc4 66 def delete_page(self, page, author, **kwargs):
181d08f3
MT
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
495e9dc4 72 self.create_page(page, author=author, content=None, **kwargs)
181d08f3 73
3168788e
MT
74 def make_breadcrumbs(self, url):
75 # Split and strip all empty elements (double slashes)
181d08f3
MT
76 parts = list(e for e in url.split("/") if e)
77
3168788e 78 ret = []
b1bf7d48 79 for part in ("/".join(parts[:i]) for i in range(1, len(parts))):
3168788e 80 ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part))))
181d08f3 81
3168788e 82 return ret
181d08f3 83
11afe905 84 def search(self, query, account=None, limit=None):
9523790a
MT
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) \
11afe905
MT
90 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC",
91 query, query)
9523790a 92
11afe905
MT
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
9523790a
MT
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
11afe905
MT
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
f2cfd873
MT
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
9e90e800
MT
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
181d08f3
MT
175
176class Page(misc.Object):
177 def init(self, id, data=None):
178 self.id = id
179 self.data = data
180
dc847af5
MT
181 def __repr__(self):
182 return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp)
183
181d08f3
MT
184 def __lt__(self, other):
185 if isinstance(other, self.__class__):
186 if self.page == other.page:
187 return self.timestamp < other.timestamp
188
189 return self.page < other.page
190
191 @staticmethod
192 def sanitise_page_name(page):
193 if not page:
194 return "/"
195
196 # Make sure that the page name does NOT end with a /
197 if page.endswith("/"):
198 page = page[:-1]
199
200 # Make sure the page name starts with a /
201 if not page.startswith("/"):
202 page = "/%s" % page
203
204 # Remove any double slashes
205 page = page.replace("//", "/")
206
207 return page
208
209 @property
210 def url(self):
db8448d9 211 return self.page
181d08f3
MT
212
213 @property
214 def page(self):
215 return self.data.page
216
217 @property
218 def title(self):
219 return self._title or self.page[1:]
220
221 @property
222 def _title(self):
223 if not self.markdown:
224 return
225
226 # Find first H1 headline in markdown
227 markdown = self.markdown.splitlines()
228
229 m = re.match(r"^# (.*)( #)?$", markdown[0])
230 if m:
231 return m.group(1)
232
3b05ef6e
MT
233 @lazy_property
234 def author(self):
235 if self.data.author_uid:
236 return self.backend.accounts.get_by_uid(self.data.author_uid)
237
181d08f3
MT
238 def _render(self, text):
239 logging.debug("Rendering %s" % self)
240
9e90e800
MT
241 # Link images
242 replacements = []
243 for match in re.finditer(r"!\[(.*)\]\((.*)\)", text):
244 alt_text, url = match.groups()
245
246 # Skip any absolute and external URLs
247 if url.startswith("/") or url.startswith("https://") or url.startswith("http://"):
248 continue
249
250 # Try to split query string
251 url, delimiter, qs = url.partition("?")
252
253 # Parse query arguments
254 args = urllib.parse.parse_qs(qs)
255
256 # Find image
257 file = self.backend.wiki.find_image(self.page, url)
258 if not file:
259 continue
260
261 # Scale down the image if not already done
262 if not "s" in args:
263 args["s"] = "768"
264
265 # Format URL
266 url = "%s?%s" % (file.url, urllib.parse.urlencode(args))
267
bf59e35d 268 replacements.append((match.span(), file, alt_text, url))
9e90e800
MT
269
270 # Apply all replacements
bf59e35d
MT
271 for (start, end), file, alt_text, url in reversed(replacements):
272 text = text[:start] + "[![%s](%s)](%s?action=detail)" % (alt_text, url, file.url) + text[end:]
9e90e800 273
9e90e800 274 # Add wiki links
574794da
MT
275 patterns = (
276 (r"\[\[([\w\d\/]+)(?:\|([\w\d\s]+))\]\]", r"/\1", r"\2", None, None),
277 (r"\[\[([\w\d\/\-]+)\]\]", r"/\1", r"\1", self.backend.wiki.get_page_title, r"\1"),
278 )
279
280 for pattern, link, title, repl, args in patterns:
281 replacements = []
282
283 for match in re.finditer(pattern, text):
284 l = match.expand(link)
285 t = match.expand(title)
286
287 if callable(repl):
288 t = repl(match.expand(args)) or t
289
290 replacements.append((match.span(), t or l, l))
291
292 # Apply all replacements
293 for (start, end), t, l in reversed(replacements):
294 text = text[:start] + "[%s](%s)" % (t, l) + text[end:]
295
045ea3db
MT
296 # Borrow this from the blog
297 return self.backend.blog._render_text(text, lang="markdown")
181d08f3
MT
298
299 @property
300 def markdown(self):
301 return self.data.markdown
302
303 @property
304 def html(self):
305 return self.data.html or self._render(self.markdown)
306
307 @property
308 def timestamp(self):
309 return self.data.timestamp
310
311 def was_deleted(self):
312 return self.markdown is None
313
314 @lazy_property
315 def breadcrumbs(self):
316 return self.backend.wiki.make_breadcrumbs(self.page)
317
318 def get_latest_revision(self):
7d699684
MT
319 revisions = self.get_revisions()
320
321 # Return first object
322 for rev in revisions:
323 return rev
324
325 def get_revisions(self):
326 return self.backend.wiki._get_pages("SELECT * FROM wiki \
327 WHERE page = %s ORDER BY timestamp DESC", self.page)
091ac36b 328
d398ca08
MT
329 @property
330 def changes(self):
331 return self.data.changes
332
11afe905
MT
333 # ACL
334
335 def check_acl(self, account):
336 return self.backend.wiki.check_acl(self.page, account)
337
091ac36b
MT
338 # Sidebar
339
340 @lazy_property
341 def sidebar(self):
342 parts = self.page.split("/")
343
344 while parts:
3cc5f666 345 sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts))
091ac36b
MT
346 if sidebar:
347 return sidebar
348
349 parts.pop()
f2cfd873
MT
350
351
352class File(misc.Object):
353 def init(self, id, data):
354 self.id = id
355 self.data = data
356
357 @property
358 def url(self):
359 return os.path.join(self.path, self.filename)
360
361 @property
362 def path(self):
363 return self.data.path
364
365 @property
366 def filename(self):
367 return self.data.filename
368
369 @property
370 def mimetype(self):
371 return self.data.mimetype
372
373 @property
374 def size(self):
375 return self.data.size
376
8cb0bea4
MT
377 @lazy_property
378 def author(self):
379 if self.data.author_uid:
380 return self.backend.accounts.get_by_uid(self.data.author_uid)
381
382 @property
383 def created_at(self):
384 return self.data.created_at
385
386 def is_pdf(self):
387 return self.mimetype in ("application/pdf", "application/x-pdf")
388
f2cfd873
MT
389 def is_image(self):
390 return self.mimetype.startswith("image/")
391
392 @lazy_property
393 def blob(self):
394 res = self.db.get("SELECT data FROM wiki_blobs \
395 WHERE id = %s", self.data.blob_id)
396
397 if res:
398 return bytes(res.data)
79dd9a0f
MT
399
400 def get_thumbnail(self, size):
75d9b3da
MT
401 cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size))
402
403 # Try to fetch the data from the cache
404 thumbnail = self.memcache.get(cache_key)
405 if thumbnail:
406 return thumbnail
407
408 # Generate the thumbnail
409 thumbnail = self._generate_thumbnail(size)
410
411 # Put it into the cache for forever
412 self.memcache.set(cache_key, thumbnail)
413
414 return thumbnail
415
416 def _generate_thumbnail(self, size):
79dd9a0f
MT
417 image = PIL.Image.open(io.BytesIO(self.blob))
418
419 # Resize the image to the desired resolution
420 image.thumbnail((size, size), PIL.Image.ANTIALIAS)
421
422 with io.BytesIO() as f:
423 # If writing out the image does not work with optimization,
424 # we try to write it out without any optimization.
425 try:
426 image.save(f, image.format, optimize=True, quality=98)
427 except:
428 image.save(f, image.format, quality=98)
429
430 return f.getvalue()